mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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
|
## 26.10.2
|
||||||
|
|
||||||
### Added
|
### 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
@@ -512,6 +512,13 @@
|
|||||||
"when": 1791935283211,
|
"when": 1791935283211,
|
||||||
"tag": "0072_unique_username",
|
"tag": "0072_unique_username",
|
||||||
"breakpoints": true
|
"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
@@ -512,6 +512,13 @@
|
|||||||
"when": 1791935282340,
|
"when": 1791935282340,
|
||||||
"tag": "0072_unique_username",
|
"tag": "0072_unique_username",
|
||||||
"breakpoints": true
|
"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
@@ -477,6 +477,13 @@
|
|||||||
"when": 1791935281343,
|
"when": 1791935281343,
|
||||||
"tag": "0067_unique_username",
|
"tag": "0067_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792357127581,
|
||||||
|
"tag": "0068_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -370,6 +370,12 @@
|
|||||||
"default": "false",
|
"default": "false",
|
||||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
"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",
|
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.2",
|
"version": "26.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.2",
|
"version": "26.10.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.2",
|
"version": "26.10.3",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.11
|
||||||
|
|
||||||
Plugin API 1.3.0.
|
Plugin API 1.3.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# 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
|
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.
|
`@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`,
|
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
`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
|
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.
|
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 |
|
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||||
| `registerOnboardingStep` | A first run setup step |
|
| `registerOnboardingStep` | A first run setup step |
|
||||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
| `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 |
|
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
| `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`,
|
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
`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
|
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
|
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
|
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||||
https://docs.termix.site/develop/more-extension-points.
|
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
|
## Looking native
|
||||||
|
|
||||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
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 |
|
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||||
| `device:serial` | high | Open serial and USB ports on the server |
|
| `device:serial` | high | Open serial and USB ports on the server |
|
||||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
| `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 |
|
| `hosts:write` | medium | Add and edit hosts |
|
||||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||||
| `credentials:write` | medium | Save new credentials |
|
| `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 |
|
| `settings:read-core` | low | Read Termix server settings |
|
||||||
| `plugins:read` | low | See installed plugins |
|
| `plugins:read` | low | See installed plugins |
|
||||||
| `ui:surface` | low | Add tabs, panels and settings |
|
| `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.
|
An update that adds a capability waits for an admin to approve it.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
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 { sha256 } from "../lib/signing.mjs";
|
||||||
import { validate } from "./validate.mjs";
|
import { validate } from "./validate.mjs";
|
||||||
|
|
||||||
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
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);
|
fs.writeFileSync(outfile, archive);
|
||||||
|
|
||||||
console.log(`packed ${path.basename(outfile)}`);
|
console.log(`packed ${path.basename(outfile)}`);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
|
|||||||
|
|
||||||
export async function validate({ cwd }) {
|
export async function validate({ cwd }) {
|
||||||
const raw = readManifest(cwd);
|
const raw = readManifest(cwd);
|
||||||
const { parseManifest } = await loadSdkModule("manifest");
|
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
|
||||||
const { errors } = parseManifest(raw);
|
const { errors } = parseManifest(raw);
|
||||||
|
|
||||||
const problems = [...errors];
|
const problems = [...errors];
|
||||||
@@ -34,6 +34,7 @@ export async function validate({ cwd }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||||
problems.push(
|
problems.push(
|
||||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
...(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)}`);
|
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. */
|
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||||
const CORE_ENV = new Set([
|
const CORE_ENV = new Set([
|
||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import zlib from "node:zlib";
|
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. */
|
/** What a .tmxplug holds, in the order the spec lists it. */
|
||||||
export const TMXPLUG_CONTENTS = [
|
export const TMXPLUG_CONTENTS = [
|
||||||
"manifest.json",
|
"manifest.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -82,7 +82,11 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"maxItems": 20,
|
"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."
|
"description": "Short lines listing what the plugin does, shown on its page."
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
@@ -98,11 +102,24 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "description"],
|
"required": ["name", "description"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
"name": {
|
||||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
"type": "string",
|
||||||
"default": { "type": "string" },
|
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
|
||||||
"required": { "type": "boolean" },
|
},
|
||||||
"secret": { "type": "boolean" }
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 300
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -155,7 +172,11 @@
|
|||||||
"files:own",
|
"files:own",
|
||||||
"secrets:own",
|
"secrets:own",
|
||||||
"settings:read-core",
|
"settings:read-core",
|
||||||
"ui:surface"
|
"ui:surface",
|
||||||
|
"notify:hub",
|
||||||
|
"plugins:read",
|
||||||
|
"ui:layout",
|
||||||
|
"ui:theme"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -944,7 +965,9 @@
|
|||||||
"defaultLevels": {
|
"defaultLevels": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"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."
|
"description": "Levels that may set a default for the field. All three by default."
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
@@ -1127,6 +1150,86 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
"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}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("auth:provide", "high"),
|
entry("auth:provide", "high"),
|
||||||
entry("device:serial", "high"),
|
entry("device:serial", "high"),
|
||||||
entry("notify:hub", "high"),
|
entry("notify:hub", "high"),
|
||||||
|
entry("ui:layout", "high"),
|
||||||
|
|
||||||
entry("hosts:write", "medium"),
|
entry("hosts:write", "medium"),
|
||||||
entry("credentials:use", "medium"),
|
entry("credentials:use", "medium"),
|
||||||
@@ -70,6 +71,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("settings:read-core", "low"),
|
entry("settings:read-core", "low"),
|
||||||
entry("plugins:read", "low"),
|
entry("plugins:read", "low"),
|
||||||
entry("ui:surface", "low"),
|
entry("ui:surface", "low"),
|
||||||
|
entry("ui:theme", "low"),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
||||||
|
|||||||
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
|
|||||||
onReady: (listener: () => void) => Disposer;
|
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
|
// Registrations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -988,6 +1111,12 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||||
registerSecondFactorUI: (factor: SecondFactorContribution) => 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
|
* 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 };
|
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||||
useCurrentUser: () => CurrentUser | null;
|
useCurrentUser: () => CurrentUser | null;
|
||||||
useTheme: () => { theme: "light" | "dark" };
|
useTheme: () => { theme: "light" | "dark" };
|
||||||
|
useShell: () => ShellController | null;
|
||||||
toast: ToastApi;
|
toast: ToastApi;
|
||||||
getApi: (pluginId: string) => PluginApiClient;
|
getApi: (pluginId: string) => PluginApiClient;
|
||||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||||
@@ -1372,6 +1502,14 @@ export function useTheme(): { theme: "light" | "dark" } {
|
|||||||
return requireHost().useTheme();
|
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 {
|
export function useToast(): ToastApi {
|
||||||
return requireHost().toast;
|
return requireHost().toast;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type PluginEnvVar,
|
type PluginEnvVar,
|
||||||
} from "./docs.js";
|
} from "./docs.js";
|
||||||
|
|
||||||
|
export { validateThemeCss } from "./theme-css.js";
|
||||||
export {
|
export {
|
||||||
ENV_NAME_PATTERN,
|
ENV_NAME_PATTERN,
|
||||||
MAX_ENV_DESCRIPTION_LENGTH,
|
MAX_ENV_DESCRIPTION_LENGTH,
|
||||||
@@ -36,7 +37,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* 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. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
|
|||||||
keyboardInteractive?: string[];
|
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 {
|
export interface PluginContributions {
|
||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
panels?: PluginViewContribution[];
|
panels?: PluginViewContribution[];
|
||||||
@@ -487,6 +522,10 @@ export interface PluginContributions {
|
|||||||
* ctx.credentials.resolveHostProtocol.
|
* ctx.credentials.resolveHostProtocol.
|
||||||
*/
|
*/
|
||||||
protocols?: PluginProtocolContribution[];
|
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. */
|
/** A field a protocol login carries besides username and password. */
|
||||||
@@ -708,6 +747,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"uses",
|
"uses",
|
||||||
"keybindingActions",
|
"keybindingActions",
|
||||||
"protocols",
|
"protocols",
|
||||||
|
"layouts",
|
||||||
|
"themes",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SETTINGS_FIELD = [
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
@@ -1300,6 +1341,111 @@ function validateContributes(
|
|||||||
validateUiPresets(contributes.uiPresets, errors);
|
validateUiPresets(contributes.uiPresets, errors);
|
||||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||||
validateProtocols(contributes.protocols, 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}$/;
|
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 ?? []) {
|
for (const secret of manifest.requiresSecret ?? []) {
|
||||||
if (secret.plugin === manifest.id) {
|
if (secret.plugin === manifest.id) {
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const offline = () =>
|
|||||||
useHostStatus: () => null,
|
useHostStatus: () => null,
|
||||||
useCurrentUser: () => null,
|
useCurrentUser: () => null,
|
||||||
useTheme: () => ({ theme: "dark" }),
|
useTheme: () => ({ theme: "dark" }),
|
||||||
|
useShell: () => null,
|
||||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||||
getApi: () => ({
|
getApi: () => ({
|
||||||
get: offline,
|
get: offline,
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -173,6 +173,7 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
|||||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
|
ShellConfirmCapture (value): () => null
|
||||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => 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
|
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
|
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
|
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
|
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
|
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
|
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||||
|
|||||||
@@ -416,3 +416,71 @@ describe("rebuilding local plugins", () => {
|
|||||||
expect(run.sort()).toEqual(["a", "b"]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,6 +194,7 @@ const ALLOWED = [
|
|||||||
"SheetHeader",
|
"SheetHeader",
|
||||||
"SheetTitle",
|
"SheetTitle",
|
||||||
"SheetTrigger",
|
"SheetTrigger",
|
||||||
|
"ShellConfirmCapture",
|
||||||
"Skeleton",
|
"Skeleton",
|
||||||
"Slider",
|
"Slider",
|
||||||
"Sparkline",
|
"Sparkline",
|
||||||
@@ -204,6 +205,7 @@ const ALLOWED = [
|
|||||||
"Switch",
|
"Switch",
|
||||||
"SwitchRow",
|
"SwitchRow",
|
||||||
"TOTPDialog",
|
"TOTPDialog",
|
||||||
|
"TabStage",
|
||||||
"TabStrip",
|
"TabStrip",
|
||||||
"TabStripItem",
|
"TabStripItem",
|
||||||
"TagInput",
|
"TagInput",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
* enabledByEnv counts the plugin as recommended when any of those
|
* enabledByEnv counts the plugin as recommended when any of those
|
||||||
* environment variables is set, for a server configured before first boot.
|
* environment variables is set, for a server configured before first boot.
|
||||||
* Plugins without it start unchecked.
|
* 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");
|
const crypto = require("node:crypto");
|
||||||
@@ -110,6 +115,12 @@ function parseBundledPlugins(raw) {
|
|||||||
}
|
}
|
||||||
const before = problems.length;
|
const before = problems.length;
|
||||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
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;
|
if (problems.length > before) continue;
|
||||||
plugins.push({
|
plugins.push({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -118,6 +129,7 @@ function parseBundledPlugins(raw) {
|
|||||||
path: hasPath ? entry.path : null,
|
path: hasPath ? entry.path : null,
|
||||||
sha256: entry.sha256,
|
sha256: entry.sha256,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
defaultLook: entry.defaultLook === true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||||
@@ -218,8 +230,12 @@ function buildBundledIndex(plugins, stagedIds) {
|
|||||||
const staged = new Set(stagedIds);
|
const staged = new Set(stagedIds);
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
if (!staged.has(plugin.id)) continue;
|
||||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||||
|
out[plugin.id] = {
|
||||||
|
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
|
||||||
|
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { version: 1, plugins: out };
|
return { version: 1, plugins: out };
|
||||||
}
|
}
|
||||||
@@ -238,12 +254,14 @@ function pinsFromIndex(index, ids, current = []) {
|
|||||||
const latest = byId.get(id)?.versions?.[0];
|
const latest = byId.get(id)?.versions?.[0];
|
||||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||||
const onboarding = currentById.get(id)?.onboarding;
|
const onboarding = currentById.get(id)?.onboarding;
|
||||||
|
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
source: "tmxplug",
|
source: "tmxplug",
|
||||||
url: latest.url,
|
url: latest.url,
|
||||||
sha256: latest.sha256,
|
sha256: latest.sha256,
|
||||||
...(onboarding ? { onboarding } : {}),
|
...(onboarding ? { onboarding } : {}),
|
||||||
|
...(defaultLook ? { defaultLook } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,9 +375,26 @@ function rebuildLocalPlugins(dir, ids, run) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Copies a local plugin build into the staging folder. */
|
/** 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) {
|
function copyLocalBuild(repo, destination) {
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
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);
|
const from = path.join(repo, entry);
|
||||||
if (fs.existsSync(from)) {
|
if (fs.existsSync(from)) {
|
||||||
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
||||||
|
|||||||
@@ -142,3 +142,29 @@ describe("termix-plugin pack", () => {
|
|||||||
expect(fs.readdirSync(out)).toEqual([]);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,3 +49,53 @@ describe("termix-plugin validate migrations", () => {
|
|||||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
|||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import openTabsRoutes from "./routes/open-tabs.js";
|
import openTabsRoutes from "./routes/open-tabs.js";
|
||||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||||
|
import lookRoutes from "./routes/look.js";
|
||||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||||
@@ -1616,6 +1617,7 @@ app.use("/credentials", credentialsRoutes);
|
|||||||
app.use("/ssh-auth", sshAuthRoutes);
|
app.use("/ssh-auth", sshAuthRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
|
app.use("/user-preferences/look", lookRoutes);
|
||||||
app.use("/user-preferences", userPreferencesRoutes);
|
app.use("/user-preferences", userPreferencesRoutes);
|
||||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||||
|
|||||||
@@ -985,6 +985,8 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "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");
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
|
|||||||
@@ -709,6 +709,8 @@ export const userPreferences = mysqlTable("user_preferences", {
|
|||||||
compactHostView: boolean("compact_host_view"),
|
compactHostView: boolean("compact_host_view"),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
|||||||
@@ -709,6 +709,8 @@ export const userPreferences = pgTable("user_preferences", {
|
|||||||
compactHostView: boolean("compact_host_view"),
|
compactHostView: boolean("compact_host_view"),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -703,6 +703,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
|||||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import express, { type Request, type Response } from "express";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
|
import {
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
createCurrentUserPreferenceRepository,
|
||||||
|
createCurrentUserRepository,
|
||||||
|
} from "../repositories/factory.js";
|
||||||
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
|
import {
|
||||||
|
LAYOUT_SETTING,
|
||||||
|
LOOK_ID_PATTERN,
|
||||||
|
THEME_SETTING,
|
||||||
|
resolveDefaultLook,
|
||||||
|
safeLayoutForced,
|
||||||
|
unavailableLooks,
|
||||||
|
} from "../../plugins/looks.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
|
async function adminDefaults() {
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
return {
|
||||||
|
layout: await settings.get(LAYOUT_SETTING),
|
||||||
|
theme: await settings.get(THEME_SETTING),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /user-preferences/look:
|
||||||
|
* get:
|
||||||
|
* summary: Get the layout and theme to show the current user
|
||||||
|
* description: The user's own choice, the admin defaults, the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||||
|
* tags:
|
||||||
|
* - User Preferences
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: What the look is chosen from.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* layoutId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* themeId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* defaults:
|
||||||
|
* type: object
|
||||||
|
* description: Admin defaults, null where unset.
|
||||||
|
* properties:
|
||||||
|
* layout:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* theme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* defaultLook:
|
||||||
|
* type: object
|
||||||
|
* description: The default-look plugin's layout and theme, null where none is installed.
|
||||||
|
* properties:
|
||||||
|
* layout:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* theme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* safeLayout:
|
||||||
|
* type: boolean
|
||||||
|
* unavailable:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* layouts:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* themes:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* error:
|
||||||
|
* type: string
|
||||||
|
*/
|
||||||
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const preferences =
|
||||||
|
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||||
|
const { loader } = getPluginRuntime();
|
||||||
|
res.json({
|
||||||
|
layoutId: preferences?.layoutId ?? null,
|
||||||
|
themeId: preferences?.themeId ?? null,
|
||||||
|
defaults: await adminDefaults(),
|
||||||
|
defaultLook: resolveDefaultLook(loader),
|
||||||
|
safeLayout: safeLayoutForced(),
|
||||||
|
unavailable: unavailableLooks(loader),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get the look", error, {
|
||||||
|
operation: "get_look",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to get the look" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /user-preferences/look/defaults:
|
||||||
|
* put:
|
||||||
|
* summary: Set the default layout and theme (admin only)
|
||||||
|
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
|
||||||
|
* tags:
|
||||||
|
* - User Preferences
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* layout:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* theme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The defaults now in effect.
|
||||||
|
* 400:
|
||||||
|
* description: Not a layout or theme id.
|
||||||
|
* 403:
|
||||||
|
* description: Not an admin.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/defaults",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await createCurrentUserRepository().findById(userId);
|
||||||
|
if (!actor?.isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
|
||||||
|
const changes: Array<[string, unknown]> = [
|
||||||
|
[LAYOUT_SETTING, body.layout],
|
||||||
|
[THEME_SETTING, body.theme],
|
||||||
|
];
|
||||||
|
for (const [key, value] of changes) {
|
||||||
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
value !== null &&
|
||||||
|
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
for (const [key, value] of changes) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (value === null) await settings.delete(key);
|
||||||
|
else await settings.set(key, value as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_default_look",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
res.json(await adminDefaults());
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to set the default look", error, {
|
||||||
|
operation: "set_default_look",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to set the default look" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { getPluginRuntime } from "../../plugins/index.js";
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||||
|
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||||
@@ -352,6 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
dependencies,
|
dependencies,
|
||||||
optionalDependencies,
|
optionalDependencies,
|
||||||
docs,
|
docs,
|
||||||
|
defaultLook: isDefaultLookPlugin(record.id),
|
||||||
...describePluginFrontend(loaded),
|
...describePluginFrontend(loaded),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2306,7 +2308,7 @@ router.post(
|
|||||||
* 404:
|
* 404:
|
||||||
* description: No such plugin.
|
* description: No such plugin.
|
||||||
* 409:
|
* 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(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
|||||||
compactHostView: row?.compactHostView ?? null,
|
compactHostView: row?.compactHostView ?? null,
|
||||||
statusColorScheme: row?.statusColorScheme ?? null,
|
statusColorScheme: row?.statusColorScheme ?? null,
|
||||||
customKeybindings: row?.customKeybindings ?? 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
|
* @openapi
|
||||||
* /user-preferences:
|
* /user-preferences:
|
||||||
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
|||||||
* type: string
|
* type: string
|
||||||
* nullable: true
|
* 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.
|
* 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) => {
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* customKeybindings:
|
* customKeybindings:
|
||||||
* type: string
|
* 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.
|
* 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Preferences updated successfully.
|
* description: Preferences updated successfully.
|
||||||
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
confirmTabClose,
|
confirmTabClose,
|
||||||
hiddenRailTabs,
|
hiddenRailTabs,
|
||||||
customKeybindings,
|
customKeybindings,
|
||||||
|
layoutId,
|
||||||
|
themeId,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
reopenTabsOnLogin?: boolean;
|
reopenTabsOnLogin?: boolean;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
confirmTabClose?: boolean | null;
|
confirmTabClose?: boolean | null;
|
||||||
hiddenRailTabs?: string | null;
|
hiddenRailTabs?: string | null;
|
||||||
customKeybindings?: string | null;
|
customKeybindings?: string | null;
|
||||||
|
layoutId?: string | null;
|
||||||
|
themeId?: string | null;
|
||||||
};
|
};
|
||||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
// 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> = {
|
const boolFields: Record<string, boolean | null | undefined> = {
|
||||||
commandPaletteEnabled,
|
commandPaletteEnabled,
|
||||||
pinAppRail,
|
pinAppRail,
|
||||||
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||||
if (customKeybindings !== undefined)
|
if (customKeybindings !== undefined)
|
||||||
updates.customKeybindings = customKeybindings;
|
updates.customKeybindings = customKeybindings;
|
||||||
|
if (layoutId !== undefined) updates.layoutId = layoutId;
|
||||||
|
if (themeId !== undefined) updates.themeId = themeId;
|
||||||
|
|
||||||
if (Object.keys(updates).length === 1) {
|
if (Object.keys(updates).length === 1) {
|
||||||
return res.status(400).json({ error: "No preferences provided" });
|
return res.status(400).json({ error: "No preferences provided" });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
|
||||||
|
// CSS files its themes declare.
|
||||||
//
|
//
|
||||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||||
// bearer header, and nothing here is secret. It only ever serves files from
|
// bearer header, and nothing here is secret. It only ever serves files from
|
||||||
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
|
|||||||
assetVersion: string | null;
|
assetVersion: string | null;
|
||||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||||
locales: string[];
|
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(
|
function frontendEntry(
|
||||||
@@ -72,9 +89,16 @@ export function describePluginFrontend(
|
|||||||
css: false,
|
css: false,
|
||||||
assetVersion: null,
|
assetVersion: null,
|
||||||
locales: [],
|
locales: [],
|
||||||
|
themeVersion: null,
|
||||||
};
|
};
|
||||||
if (!plugin) return none;
|
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 locales: string[] = [];
|
||||||
const localeFiles: string[] = [];
|
const localeFiles: string[] = [];
|
||||||
const dir = localesDir(plugin);
|
const dir = localesDir(plugin);
|
||||||
@@ -96,7 +120,8 @@ export function describePluginFrontend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = frontendEntry(plugin);
|
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 cssFile = path.join(entry.dir, "frontend.css");
|
||||||
const css = fileExists(cssFile);
|
const css = fileExists(cssFile);
|
||||||
@@ -111,6 +136,7 @@ export function describePluginFrontend(
|
|||||||
css,
|
css,
|
||||||
assetVersion,
|
assetVersion,
|
||||||
locales: locales.sort(),
|
locales: locales.sort(),
|
||||||
|
themeVersion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
|
|||||||
|
|
||||||
let root: string | null;
|
let root: string | null;
|
||||||
let rest: string;
|
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);
|
root = localesDir(plugin);
|
||||||
rest = clean.slice("locales/".length);
|
rest = clean.slice("locales/".length);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* The onboarding defaults of the bundled plugins, from
|
* The onboarding defaults and the default look of the bundled plugins, from
|
||||||
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
||||||
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
||||||
* fresh install keeps lives in that file and not here.
|
* fresh install keeps lives in that file and not here.
|
||||||
@@ -27,6 +27,7 @@ interface BundledIndex {
|
|||||||
consent?: boolean;
|
consent?: boolean;
|
||||||
enabledByEnv?: string[];
|
enabledByEnv?: string[];
|
||||||
};
|
};
|
||||||
|
defaultLook?: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
|
|||||||
return cached;
|
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 {
|
export function resetBundledIndexCache(): void {
|
||||||
cached = undefined;
|
cached = undefined;
|
||||||
}
|
}
|
||||||
@@ -96,6 +114,8 @@ export function initialPluginState(
|
|||||||
): "enabled" | "disabled" {
|
): "enabled" | "disabled" {
|
||||||
if (source !== "bundled") return "disabled";
|
if (source !== "bundled") return "disabled";
|
||||||
if (!freshInstall) return "enabled";
|
if (!freshInstall) return "enabled";
|
||||||
|
// The default look is what the app is drawn with.
|
||||||
|
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||||
return recommended && !consent ? "enabled" : "disabled";
|
return recommended && !consent ? "enabled" : "disabled";
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,11 @@ import path from "node:path";
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import * as tar from "tar";
|
import * as tar from "tar";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
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 { getLocalVersion } from "../utils/app-version.js";
|
||||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
|
|||||||
signedTier?: KeyTier;
|
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 {
|
export interface PluginLoaderOptions {
|
||||||
errorThreshold?: number;
|
errorThreshold?: number;
|
||||||
errorWindowMs?: number;
|
errorWindowMs?: number;
|
||||||
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
|
|||||||
onFailed?: (plugin: LoadedPlugin) => void;
|
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 {
|
export class PluginLoader {
|
||||||
private readonly plugins = new Map<string, LoadedPlugin>();
|
private readonly plugins = new Map<string, LoadedPlugin>();
|
||||||
|
private skippedPlugins: SkippedPlugin[] = [];
|
||||||
/** Version of each bundled plugin as shipped, the floor for any update. */
|
/** Version of each bundled plugin as shipped, the floor for any update. */
|
||||||
private readonly bundledVersions = new Map<string, string>();
|
private readonly bundledVersions = new Map<string, string>();
|
||||||
/** Activation order, so shutdown can run it backwards. */
|
/** Activation order, so shutdown can run it backwards. */
|
||||||
@@ -113,6 +192,11 @@ export class PluginLoader {
|
|||||||
return [...this.plugins.values()];
|
return [...this.plugins.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plugin folders the last full load skipped, with the reason. */
|
||||||
|
skipped(): SkippedPlugin[] {
|
||||||
|
return [...this.skippedPlugins];
|
||||||
|
}
|
||||||
|
|
||||||
get(pluginId: string): LoadedPlugin | undefined {
|
get(pluginId: string): LoadedPlugin | undefined {
|
||||||
return this.plugins.get(pluginId);
|
return this.plugins.get(pluginId);
|
||||||
}
|
}
|
||||||
@@ -210,6 +294,8 @@ export class PluginLoader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validatePluginThemes(dir, manifest);
|
||||||
|
|
||||||
if (this.plugins.has(manifest.id)) {
|
if (this.plugins.has(manifest.id)) {
|
||||||
throw new Error(`another plugin already uses the id "${manifest.id}"`);
|
throw new Error(`another plugin already uses the id "${manifest.id}"`);
|
||||||
}
|
}
|
||||||
@@ -258,6 +344,7 @@ export class PluginLoader {
|
|||||||
async loadAll(): Promise<LoadedPlugin[]> {
|
async loadAll(): Promise<LoadedPlugin[]> {
|
||||||
this.plugins.clear();
|
this.plugins.clear();
|
||||||
this.bundledVersions.clear();
|
this.bundledVersions.clear();
|
||||||
|
this.skippedPlugins = [];
|
||||||
await removeStaleStaging();
|
await removeStaleStaging();
|
||||||
const loaded: LoadedPlugin[] = [];
|
const loaded: LoadedPlugin[] = [];
|
||||||
// Every bundled folder name, loaded or not: a bundled plugin that fails
|
// Every bundled folder name, loaded or not: a bundled plugin that fails
|
||||||
@@ -322,6 +409,9 @@ export class PluginLoader {
|
|||||||
}
|
}
|
||||||
loaded.push(plugin);
|
loaded.push(plugin);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.skippedPlugins.push(
|
||||||
|
describeSkipped(dir, entry.name, isArtifact, error),
|
||||||
|
);
|
||||||
pluginLogger.error(
|
pluginLogger.error(
|
||||||
`Skipping plugin directory ${entry.name} in ${root}`,
|
`Skipping plugin directory ${entry.name} in ${root}`,
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Layouts and themes on the server side: which look a fresh install shows,
|
||||||
|
* and which installed looks could not load. Core names no plugin here; the
|
||||||
|
* default look comes from the bundled index's defaultLook flag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defaultLookPluginIds } from "./bundled-index.js";
|
||||||
|
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
|
||||||
|
|
||||||
|
export interface DefaultLook {
|
||||||
|
layout: string | null;
|
||||||
|
theme: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LookLoader {
|
||||||
|
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
|
||||||
|
skipped(): SkippedPlugin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first default-look plugin's default layout (else its first one) and
|
||||||
|
* that layout's theme. A default-look plugin with themes only fills in the
|
||||||
|
* theme. Null where none is installed, and the browser uses its built-in.
|
||||||
|
*/
|
||||||
|
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
|
||||||
|
const look: DefaultLook = { layout: null, theme: null };
|
||||||
|
for (const id of defaultLookPluginIds()) {
|
||||||
|
const contributes = loader.get(id)?.manifest.contributes;
|
||||||
|
const layouts = contributes?.layouts ?? [];
|
||||||
|
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
|
||||||
|
if (layout && !look.layout) {
|
||||||
|
look.layout = layout.id;
|
||||||
|
look.theme ??= layout.defaultTheme ?? null;
|
||||||
|
}
|
||||||
|
if (!look.theme && contributes?.themes?.length) {
|
||||||
|
look.theme = contributes.themes[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return look;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plugins with a layout or theme that were found but did not load. */
|
||||||
|
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
|
||||||
|
return loader
|
||||||
|
.skipped()
|
||||||
|
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
|
||||||
|
export function safeLayoutForced(): boolean {
|
||||||
|
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||||
|
export const LAYOUT_SETTING = "ui.layout";
|
||||||
|
export const THEME_SETTING = "ui.theme";
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
* 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
|
* 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
|
* 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";
|
import crypto from "node:crypto";
|
||||||
@@ -15,6 +17,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||||
import {
|
import {
|
||||||
activatePlugin,
|
activatePlugin,
|
||||||
deactivatePlugin,
|
deactivatePlugin,
|
||||||
@@ -1154,6 +1157,13 @@ export async function uninstallPluginUnlocked(
|
|||||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||||
}
|
}
|
||||||
const shipped = loader.bundledIds().has(pluginId);
|
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());
|
const dependents = dependentChain(pluginId, await enabledIds());
|
||||||
if (dependents.length > 0) {
|
if (dependents.length > 0) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export {
|
|||||||
pluginFeatures,
|
pluginFeatures,
|
||||||
pluginDocsUrl,
|
pluginDocsUrl,
|
||||||
pluginEnvVars,
|
pluginEnvVars,
|
||||||
|
validateThemeCss,
|
||||||
} from "@termix-ssh/plugin-sdk/manifest";
|
} from "@termix-ssh/plugin-sdk/manifest";
|
||||||
|
|
||||||
export type {
|
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",
|
"name",
|
||||||
"optionalDependencies",
|
"optionalDependencies",
|
||||||
"state",
|
"state",
|
||||||
|
"themeVersion",
|
||||||
"version",
|
"version",
|
||||||
].sort(),
|
].sort(),
|
||||||
);
|
);
|
||||||
@@ -624,10 +625,12 @@ describe("plugins route", () => {
|
|||||||
contributes: null,
|
contributes: null,
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
optionalDependencies: {},
|
optionalDependencies: {},
|
||||||
|
defaultLook: false,
|
||||||
frontend: false,
|
frontend: false,
|
||||||
css: false,
|
css: false,
|
||||||
assetVersion: null,
|
assetVersion: null,
|
||||||
locales: [],
|
locales: [],
|
||||||
|
themeVersion: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
row: null as Record<string, unknown> | null,
|
||||||
|
upserts: [] as Record<string, unknown>[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() => (req: express.Request, _res: unknown, next: () => void) => {
|
||||||
|
(req as unknown as { userId: string }).userId = "user-1";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentUserPreferenceRepository: () => ({
|
||||||
|
findByUserId: async () => state.row,
|
||||||
|
upsert: async (_userId: string, updates: Record<string, unknown>) => {
|
||||||
|
state.upserts.push(updates);
|
||||||
|
state.row = { ...(state.row ?? {}), ...updates };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
|
||||||
|
isValidKeybinding: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { default: routes } =
|
||||||
|
await import("../../../database/routes/user-preferences.js");
|
||||||
|
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
state.row = null;
|
||||||
|
state.upserts = [];
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/user-preferences", routes);
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||||
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function put(body: unknown) {
|
||||||
|
const res = await fetch(`${baseUrl}/user-preferences`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("user preferences: layout and theme", () => {
|
||||||
|
it("reads null for both until chosen", async () => {
|
||||||
|
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
|
||||||
|
expect(body).toMatchObject({ layoutId: null, themeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a layout and theme id, and null to follow the default", async () => {
|
||||||
|
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
expect(state.upserts[0]).toMatchObject({
|
||||||
|
layoutId: "fancy",
|
||||||
|
themeId: "nord",
|
||||||
|
});
|
||||||
|
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
|
||||||
|
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
|
||||||
|
|
||||||
|
await put({ layoutId: null });
|
||||||
|
expect(state.upserts[1]).toMatchObject({ layoutId: null });
|
||||||
|
expect(state.upserts[1]).not.toHaveProperty("themeId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses anything that is not an id", async () => {
|
||||||
|
for (const bad of ["Fancy", "../x", "a", 7]) {
|
||||||
|
expect((await put({ themeId: bad })).status).toBe(400);
|
||||||
|
}
|
||||||
|
expect(state.upserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const shell = fs.readFileSync(
|
const shell = fs.readFileSync(
|
||||||
new URL("../../../ui/AppShell.tsx", import.meta.url),
|
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ describe("describePluginFrontend", () => {
|
|||||||
css: false,
|
css: false,
|
||||||
assetVersion: null,
|
assetVersion: null,
|
||||||
locales: [],
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
defaultLookPluginIds,
|
||||||
initialPluginState,
|
initialPluginState,
|
||||||
|
isDefaultLookPlugin,
|
||||||
onboardingDefaults,
|
onboardingDefaults,
|
||||||
resetBundledIndexCache,
|
resetBundledIndexCache,
|
||||||
} from "../../plugins/bundled-index.js";
|
} from "../../plugins/bundled-index.js";
|
||||||
@@ -103,3 +105,28 @@ describe("initialPluginState", () => {
|
|||||||
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("default look", () => {
|
||||||
|
it("lists the plugins flagged as the default look", () => {
|
||||||
|
writeIndex({
|
||||||
|
"some-layout": { defaultLook: true },
|
||||||
|
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
|
||||||
|
other: { onboarding: { recommended: true } },
|
||||||
|
});
|
||||||
|
expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]);
|
||||||
|
expect(isDefaultLookPlugin("some-themes")).toBe(true);
|
||||||
|
expect(isDefaultLookPlugin("other")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has none without an index", () => {
|
||||||
|
expect(defaultLookPluginIds()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the default look enabled even on a fresh install", () => {
|
||||||
|
writeIndex({
|
||||||
|
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
|
||||||
|
});
|
||||||
|
expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled");
|
||||||
|
expect(initialPluginState("some-themes", "user", true)).toBe("disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PluginLoader themes and skipped looks", () => {
|
||||||
|
const themed = {
|
||||||
|
capabilities: ["ui:theme", "ui:layout"],
|
||||||
|
manifestOverrides: {
|
||||||
|
contributes: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
id: "paper",
|
||||||
|
label: "Paper",
|
||||||
|
mode: "light",
|
||||||
|
css: "themes/paper.css",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layouts: [{ id: "fancy", label: "Fancy" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("loads a plugin whose theme CSS is allowed", async () => {
|
||||||
|
const fixture = createFixturePlugin(themed);
|
||||||
|
cleanups.push(fixture.cleanup);
|
||||||
|
fs.mkdirSync(path.join(fixture.dir, "themes"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(fixture.dir, "themes", "paper.css"),
|
||||||
|
".paper { --background: #fff; }",
|
||||||
|
);
|
||||||
|
const plugin = await new PluginLoader().load(fixture.dir, "bundled");
|
||||||
|
expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a plugin whose theme CSS reaches outside", async () => {
|
||||||
|
const fixture = createFixturePlugin(themed);
|
||||||
|
cleanups.push(fixture.cleanup);
|
||||||
|
fs.mkdirSync(path.join(fixture.dir, "themes"));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(fixture.dir, "themes", "paper.css"),
|
||||||
|
'@import url("https://example.com/x.css");',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
new PluginLoader().load(fixture.dir, "bundled"),
|
||||||
|
).rejects.toThrow(/theme paper is not allowed: @import/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a plugin whose theme file is missing", async () => {
|
||||||
|
const fixture = createFixturePlugin(themed);
|
||||||
|
cleanups.push(fixture.cleanup);
|
||||||
|
await expect(
|
||||||
|
new PluginLoader().load(fixture.dir, "bundled"),
|
||||||
|
).rejects.toThrow(/theme paper file themes\/paper.css is missing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers why a layout plugin was skipped", async () => {
|
||||||
|
const bundled = tempRoot("termix-bundled-");
|
||||||
|
const user = tempRoot("termix-data-");
|
||||||
|
createFixturePlugin({
|
||||||
|
...themed,
|
||||||
|
id: "fancy-layout",
|
||||||
|
root: bundled,
|
||||||
|
manifestOverrides: {
|
||||||
|
...themed.manifestOverrides,
|
||||||
|
version: "2.0.0",
|
||||||
|
engine: { termix: ">=99.0.0", api: "1" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setRoots(bundled, user);
|
||||||
|
|
||||||
|
const loader = new PluginLoader();
|
||||||
|
await loader.loadAll();
|
||||||
|
const [skipped] = loader.skipped();
|
||||||
|
expect(skipped).toMatchObject({
|
||||||
|
id: "fancy-layout",
|
||||||
|
version: "2.0.0",
|
||||||
|
layouts: ["fancy"],
|
||||||
|
themes: ["paper"],
|
||||||
|
});
|
||||||
|
expect(skipped.error).toMatch(/needs Termix >=99.0.0/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PluginLoader dependency resolution", () => {
|
describe("PluginLoader dependency resolution", () => {
|
||||||
async function loadAll(
|
async function loadAll(
|
||||||
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
|
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const index = vi.hoisted(() => ({ ids: [] as string[] }));
|
||||||
|
vi.mock("../../plugins/bundled-index.js", () => ({
|
||||||
|
defaultLookPluginIds: () => index.ids,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveDefaultLook,
|
||||||
|
safeLayoutForced,
|
||||||
|
unavailableLooks,
|
||||||
|
} from "../../plugins/looks.js";
|
||||||
|
import type { SkippedPlugin } from "../../plugins/loader.js";
|
||||||
|
|
||||||
|
type Contributes = Record<string, unknown>;
|
||||||
|
|
||||||
|
function loaderWith(
|
||||||
|
plugins: Record<string, Contributes>,
|
||||||
|
skipped: SkippedPlugin[] = [],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
get: (id: string) =>
|
||||||
|
plugins[id]
|
||||||
|
? ({ manifest: { contributes: plugins[id] } } as never)
|
||||||
|
: undefined,
|
||||||
|
skipped: () => skipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
index.ids = [];
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveDefaultLook", () => {
|
||||||
|
it("is empty without a default-look plugin", () => {
|
||||||
|
expect(resolveDefaultLook(loaderWith({}))).toEqual({
|
||||||
|
layout: null,
|
||||||
|
theme: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the layout marked default and its theme", () => {
|
||||||
|
index.ids = ["look-a"];
|
||||||
|
const loader = loaderWith({
|
||||||
|
"look-a": {
|
||||||
|
layouts: [
|
||||||
|
{ id: "first", label: "First", defaultTheme: "one" },
|
||||||
|
{ id: "main", label: "Main", defaultTheme: "two", default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resolveDefaultLook(loader)).toEqual({
|
||||||
|
layout: "main",
|
||||||
|
theme: "two",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the first layout, and a theme from a themes-only plugin", () => {
|
||||||
|
index.ids = ["look-a", "look-b"];
|
||||||
|
const loader = loaderWith({
|
||||||
|
"look-a": { themes: [{ id: "paper", label: "Paper" }] },
|
||||||
|
"look-b": { layouts: [{ id: "solo", label: "Solo" }] },
|
||||||
|
});
|
||||||
|
expect(resolveDefaultLook(loader)).toEqual({
|
||||||
|
layout: "solo",
|
||||||
|
theme: "paper",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a default-look plugin that did not load", () => {
|
||||||
|
index.ids = ["gone"];
|
||||||
|
expect(resolveDefaultLook(loaderWith({}))).toEqual({
|
||||||
|
layout: null,
|
||||||
|
theme: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unavailableLooks", () => {
|
||||||
|
it("lists only skipped plugins that brought a layout or theme", () => {
|
||||||
|
const skipped: SkippedPlugin[] = [
|
||||||
|
{ id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" },
|
||||||
|
{ id: "b", version: null, layouts: [], themes: [], error: "e" },
|
||||||
|
{ id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id),
|
||||||
|
).toEqual(["a", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safeLayoutForced", () => {
|
||||||
|
it("reads TERMIX_SAFE_LAYOUT", () => {
|
||||||
|
expect(safeLayoutForced()).toBe(false);
|
||||||
|
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||||
|
expect(safeLayoutForced()).toBe(true);
|
||||||
|
process.env.TERMIX_SAFE_LAYOUT = "true";
|
||||||
|
expect(safeLayoutForced()).toBe(true);
|
||||||
|
process.env.TERMIX_SAFE_LAYOUT = "0";
|
||||||
|
expect(safeLayoutForced()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => {
|
|||||||
expect(loader.get("docker")?.source).toBe("user");
|
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 () => {
|
it("deletes a plugin dropped in as a plain folder", async () => {
|
||||||
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
||||||
const loader = await boot();
|
const loader = await boot();
|
||||||
|
|||||||
@@ -1002,3 +1002,79 @@ describe("docs and env fields", () => {
|
|||||||
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("layouts and themes", () => {
|
||||||
|
const layouts = [
|
||||||
|
{
|
||||||
|
id: "fancy",
|
||||||
|
label: "layout.fancy",
|
||||||
|
defaultTheme: "paper",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const themes = [
|
||||||
|
{
|
||||||
|
id: "paper",
|
||||||
|
label: "theme.paper",
|
||||||
|
mode: "light",
|
||||||
|
css: "themes/paper.css",
|
||||||
|
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("accepts layouts and themes with their capabilities", () => {
|
||||||
|
const { manifest, errors } = parseManifest(
|
||||||
|
base({
|
||||||
|
capabilities: ["ui:layout", "ui:theme"],
|
||||||
|
contributes: { layouts, themes },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
|
||||||
|
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs ui:layout for layouts and ui:theme for themes", () => {
|
||||||
|
const { errors } = parseManifest(
|
||||||
|
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
|
||||||
|
);
|
||||||
|
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
|
||||||
|
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses bad ids, modes, paths and swatches", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
base({
|
||||||
|
capabilities: ["ui:layout", "ui:theme"],
|
||||||
|
contributes: {
|
||||||
|
layouts: [
|
||||||
|
{ id: "Fancy", label: "x" },
|
||||||
|
{ id: "two", label: "x", default: true },
|
||||||
|
{ id: "three", label: "x", default: true, extra: 1 },
|
||||||
|
],
|
||||||
|
themes: [
|
||||||
|
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
|
||||||
|
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
|
||||||
|
{
|
||||||
|
id: "dusk",
|
||||||
|
label: "x",
|
||||||
|
mode: "dark",
|
||||||
|
css: "dusk.css",
|
||||||
|
swatch: { background: "red", panel: "#000", text: "#fff" },
|
||||||
|
},
|
||||||
|
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const has = (text: string) => errors.some((e) => e.includes(text));
|
||||||
|
expect(has("contributes.layouts[0].id must match")).toBe(true);
|
||||||
|
expect(has("Only one entry")).toBe(true);
|
||||||
|
expect(has('Unknown field "extra"')).toBe(true);
|
||||||
|
expect(has('mode must be "light" or "dark"')).toBe(true);
|
||||||
|
expect(has("relative path inside the plugin")).toBe(true);
|
||||||
|
expect(has("must be a .css file")).toBe(true);
|
||||||
|
expect(has("swatch.background must be a hex color")).toBe(true);
|
||||||
|
expect(has('duplicates "dusk"')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateThemeCss } from "../../plugins/manifest.js";
|
||||||
|
|
||||||
|
describe("validateThemeCss", () => {
|
||||||
|
it("accepts token rules scoped under the theme class", () => {
|
||||||
|
const css = `
|
||||||
|
/* Paper */
|
||||||
|
.paper { --background: #fff; --foreground: #111; }
|
||||||
|
:root.paper, html.paper .card { --card: #f8f8f8; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.paper * { transition: none; }
|
||||||
|
}
|
||||||
|
@supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } }
|
||||||
|
.paper .logo { background: url("logo.svg") no-repeat; }
|
||||||
|
.paper .dot { background: url(data:image/png;base64,AAAA); }
|
||||||
|
`;
|
||||||
|
expect(validateThemeCss(css, "paper")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses selectors outside the theme", () => {
|
||||||
|
const errors = validateThemeCss(
|
||||||
|
".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }",
|
||||||
|
"paper",
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(3);
|
||||||
|
expect(errors[0]).toContain('"body"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses anything that loads or runs", () => {
|
||||||
|
const errors = validateThemeCss(
|
||||||
|
`@import url("https://evil.example/x.css");
|
||||||
|
.paper { background: url(https://evil.example/a.png); }
|
||||||
|
.paper { background: url(//evil.example/a.png); }
|
||||||
|
.paper { width: expression(alert(1)); }
|
||||||
|
.paper { background: url(javascript:alert(1)); }
|
||||||
|
.paper { behavior: url(x.htc); }
|
||||||
|
@font-face { font-family: x; src: url(x.woff2); }
|
||||||
|
@keyframes spin { from { opacity: 0 } }`,
|
||||||
|
"paper",
|
||||||
|
);
|
||||||
|
const has = (text: string) => errors.some((e) => e.includes(text));
|
||||||
|
expect(has("@import is not allowed")).toBe(true);
|
||||||
|
expect(has("url(https://evil.example/a.png) points outside")).toBe(true);
|
||||||
|
expect(has("url(//evil.example/a.png) points outside")).toBe(true);
|
||||||
|
expect(has("expression() is not allowed")).toBe(true);
|
||||||
|
expect(has("javascript: is not allowed")).toBe(true);
|
||||||
|
expect(has("Script bindings")).toBe(true);
|
||||||
|
expect(has("@font-face is not allowed")).toBe(true);
|
||||||
|
expect(has("@keyframes is not allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses nested rules and broken braces", () => {
|
||||||
|
expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([
|
||||||
|
'Nested rules are not allowed in ".paper"',
|
||||||
|
]);
|
||||||
|
expect(validateThemeCss(".paper { color: red", "paper")).toEqual([
|
||||||
|
"A rule is missing its closing brace",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trip on braces inside strings", () => {
|
||||||
|
expect(
|
||||||
|
validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
} from "@/plugin-host/loader";
|
} from "@/plugin-host/loader";
|
||||||
import { settledPromise } from "@/plugin-host/plugin-store";
|
import { settledPromise } from "@/plugin-host/plugin-store";
|
||||||
import { resetShellBridge } from "@/plugin-host/shell-bridge";
|
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 { preloadPermissions } from "@/hooks/use-permissions";
|
||||||
import { getUserPreferences } from "@/api/open-tabs-api";
|
import { getUserPreferences } from "@/api/open-tabs-api";
|
||||||
import { standaloneViewFor } from "@/shell/tab-registry";
|
import { standaloneViewFor } from "@/shell/tab-registry";
|
||||||
@@ -209,6 +212,7 @@ function App() {
|
|||||||
.then(() => settledPromise()),
|
.then(() => settledPromise()),
|
||||||
preloadPermissions(),
|
preloadPermissions(),
|
||||||
getUserPreferences().catch(() => undefined),
|
getUserPreferences().catch(() => undefined),
|
||||||
|
loadLook(),
|
||||||
]);
|
]);
|
||||||
await enterApp();
|
await enterApp();
|
||||||
})
|
})
|
||||||
@@ -296,6 +300,7 @@ function App() {
|
|||||||
.then(() => settledPromise()),
|
.then(() => settledPromise()),
|
||||||
preloadPermissions(),
|
preloadPermissions(),
|
||||||
getUserPreferences().catch(() => undefined),
|
getUserPreferences().catch(() => undefined),
|
||||||
|
loadLook(),
|
||||||
]);
|
]);
|
||||||
await enterApp();
|
await enterApp();
|
||||||
})();
|
})();
|
||||||
@@ -321,6 +326,9 @@ function App() {
|
|||||||
// should survive into the next session.
|
// should survive into the next session.
|
||||||
void stopPluginRuntime();
|
void stopPluginRuntime();
|
||||||
resetShellBridge();
|
resetShellBridge();
|
||||||
|
resetLook();
|
||||||
|
resetPluginThemes();
|
||||||
|
resetPluginLayouts();
|
||||||
clearStoredAuth();
|
clearStoredAuth();
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
if (isElectron() && options?.manual) {
|
if (isElectron() && options?.manual) {
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ export type AdminSection =
|
|||||||
| "api-keys"
|
| "api-keys"
|
||||||
| "audit-log"
|
| "audit-log"
|
||||||
| "ssl";
|
| "ssl";
|
||||||
export type ThemeId =
|
export type ThemeId = string;
|
||||||
|
export type LegacyThemeId =
|
||||||
| "dark"
|
| "dark"
|
||||||
| "light"
|
| "light"
|
||||||
| "system"
|
| "system"
|
||||||
|
|||||||
+3
-3174
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
|||||||
|
import { authApi } from "@/main-axios";
|
||||||
|
|
||||||
|
/** A layout or theme plugin the server found but could not load. */
|
||||||
|
export interface UnavailableLook {
|
||||||
|
id: string;
|
||||||
|
version: string | null;
|
||||||
|
layouts: string[];
|
||||||
|
themes: string[];
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the browser picks the layout and theme from. */
|
||||||
|
export interface LookResponse {
|
||||||
|
layoutId: string | null;
|
||||||
|
themeId: string | null;
|
||||||
|
/** Admin defaults. */
|
||||||
|
defaults: { layout: string | null; theme: string | null };
|
||||||
|
/** The default-look plugin's layout and theme. */
|
||||||
|
defaultLook: { layout: string | null; theme: string | null };
|
||||||
|
/** TERMIX_SAFE_LAYOUT is set on the server. */
|
||||||
|
safeLayout: boolean;
|
||||||
|
unavailable: UnavailableLook[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLook(): Promise<LookResponse> {
|
||||||
|
const response = await authApi.get("/user-preferences/look");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's own layout and theme; null follows the default. */
|
||||||
|
export async function saveUserLook(look: {
|
||||||
|
layoutId?: string | null;
|
||||||
|
themeId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
await authApi.put("/user-preferences", look);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin only. Null clears a default. */
|
||||||
|
export async function saveDefaultLook(defaults: {
|
||||||
|
layout?: string | null;
|
||||||
|
theme?: string | null;
|
||||||
|
}): Promise<{ layout: string | null; theme: string | null }> {
|
||||||
|
const response = await authApi.put(
|
||||||
|
"/user-preferences/look/defaults",
|
||||||
|
defaults,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -116,6 +116,10 @@ export interface UserPreferences {
|
|||||||
compactHostView?: boolean | null;
|
compactHostView?: boolean | null;
|
||||||
statusColorScheme?: string | null;
|
statusColorScheme?: string | null;
|
||||||
customKeybindings?: 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(
|
export function parseCustomKeybindings(
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ export interface PluginViewContribution {
|
|||||||
icon?: string;
|
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 {
|
export interface PluginContributions {
|
||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
panels?: PluginViewContribution[];
|
panels?: PluginViewContribution[];
|
||||||
@@ -87,6 +103,8 @@ export interface PluginContributions {
|
|||||||
settings?: PluginSettingsContribution;
|
settings?: PluginSettingsContribution;
|
||||||
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
||||||
uiPresets?: PluginUiPresets;
|
uiPresets?: PluginUiPresets;
|
||||||
|
layouts?: PluginLayoutContribution[];
|
||||||
|
themes?: PluginThemeContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -236,6 +254,10 @@ export interface PluginSummary {
|
|||||||
assetVersion?: string | null;
|
assetVersion?: string | null;
|
||||||
/** "en" plus the xx_YY names of shipped translations. */
|
/** "en" plus the xx_YY names of shipped translations. */
|
||||||
locales?: string[];
|
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;
|
tier?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import type { ThemeId } from "@/types/ui-types";
|
import type { ThemeId } from "@/types/ui-types";
|
||||||
|
import { SYSTEM_THEME, getTheme, useThemes } from "@/look/theme-registry";
|
||||||
|
import { applyThemeCss } from "@/look/theme-css";
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,22 +12,11 @@ type ThemeProviderProps = {
|
|||||||
|
|
||||||
type ThemeProviderState = {
|
type ThemeProviderState = {
|
||||||
theme: ThemeId;
|
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;
|
setThemePreview: (theme: ThemeId | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_THEME_CLASSES = [
|
|
||||||
"light",
|
|
||||||
"dark",
|
|
||||||
"dracula",
|
|
||||||
"catppuccin",
|
|
||||||
"nord",
|
|
||||||
"solarized",
|
|
||||||
"tokyo-night",
|
|
||||||
"one-dark",
|
|
||||||
"gruvbox",
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialState: ThemeProviderState = {
|
const initialState: ThemeProviderState = {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
@@ -34,6 +25,9 @@ const initialState: ThemeProviderState = {
|
|||||||
|
|
||||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
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({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "dark",
|
defaultTheme = "dark",
|
||||||
@@ -44,29 +38,31 @@ export function ThemeProvider({
|
|||||||
() => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme,
|
() => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme,
|
||||||
);
|
);
|
||||||
const [previewTheme, setPreviewTheme] = useState<ThemeId | null>(null);
|
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;
|
const activeTheme = previewTheme ?? theme;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement;
|
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 applied =
|
||||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
activeTheme === SYSTEM_THEME
|
||||||
.matches
|
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
? "dark"
|
? "dark"
|
||||||
: "light";
|
: "light"
|
||||||
root.classList.add(systemTheme);
|
: activeTheme;
|
||||||
return;
|
root.classList.add(applied);
|
||||||
}
|
appliedClass = applied;
|
||||||
|
applyThemeCss(applied, getTheme(applied));
|
||||||
root.classList.add(activeTheme);
|
}, [activeTheme, themes]);
|
||||||
}, [activeTheme]);
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
theme,
|
theme,
|
||||||
setTheme: (theme: ThemeId) => {
|
setTheme: (theme: ThemeId, persist = true) => {
|
||||||
localStorage.setItem(storageKey, theme);
|
if (persist) localStorage.setItem(storageKey, theme);
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
},
|
},
|
||||||
setThemePreview: (preview: ThemeId | null) => {
|
setThemePreview: (preview: ThemeId | null) => {
|
||||||
|
|||||||
@@ -1770,6 +1770,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"looks": {
|
||||||
|
"builtinLayout": "Classic",
|
||||||
|
"layoutLabel": "Layout",
|
||||||
|
"layoutHint": "How the whole app is laid out. Themes and layouts come from plugins.",
|
||||||
|
"defaultsLabel": "Default for everyone",
|
||||||
|
"defaultsHint": "What people see until they choose their own layout or theme.",
|
||||||
|
"followDefault": "Use the default",
|
||||||
|
"defaultSaved": "Default look saved",
|
||||||
|
"saveFailed": "Could not save the look",
|
||||||
|
"repair": {
|
||||||
|
"title": "This layout could not be shown",
|
||||||
|
"safeTitle": "Safe mode",
|
||||||
|
"layout": "Layout",
|
||||||
|
"plugin": "Plugin",
|
||||||
|
"reason": "Reason",
|
||||||
|
"error": "Error",
|
||||||
|
"noError": "No error message.",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"copyFailed": "Could not copy the details",
|
||||||
|
"retry": "Try again",
|
||||||
|
"reset": "Reset to the default look",
|
||||||
|
"resetEveryone": "Reset the default for everyone",
|
||||||
|
"settings": "Open Settings",
|
||||||
|
"back": "Back",
|
||||||
|
"update": "Update {{name}} to {{version}}",
|
||||||
|
"disable": "Turn off {{name}}",
|
||||||
|
"install": "Find layouts and themes",
|
||||||
|
"installTitle": "Layouts and themes",
|
||||||
|
"installButton": "Install",
|
||||||
|
"nothingToInstall": "No other layouts or themes are available right now.",
|
||||||
|
"registryUnreachable": "The plugin registry could not be reached.",
|
||||||
|
"askAdmin": "Ask an admin to update or fix this layout. You can switch to another layout or theme for yourself below.",
|
||||||
|
"switchLayout": "Switch layout",
|
||||||
|
"switchTheme": "Switch theme",
|
||||||
|
"saveFailed": "Could not save your choice",
|
||||||
|
"reasons": {
|
||||||
|
"safe-url": "The address asked for safe mode. Pick a layout below, or remove ?safe-layout=1 from the address.",
|
||||||
|
"safe-env": "The server is started with TERMIX_SAFE_LAYOUT, so everyone sees this screen until it is removed.",
|
||||||
|
"missing": "No installed plugin provides this layout.",
|
||||||
|
"disabled": "The plugin that provides this layout is turned off.",
|
||||||
|
"blocked": "The plugin that provides this layout is waiting on something it needs.",
|
||||||
|
"incompatible": "The plugin that provides this layout does not work with this version of Termix.",
|
||||||
|
"failed": "The layout failed to start.",
|
||||||
|
"crashed": "The layout crashed twice while drawing the app."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"credentials:read": {
|
"credentials:read": {
|
||||||
@@ -1887,6 +1935,14 @@
|
|||||||
"ui:surface": {
|
"ui:surface": {
|
||||||
"title": "Add its own screens",
|
"title": "Add its own screens",
|
||||||
"consequence": "Tabs, panels and settings you can hide later."
|
"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": {
|
"runtime": {
|
||||||
@@ -2016,6 +2072,7 @@
|
|||||||
"options": "Could not save options for {{name}}: {{error}}",
|
"options": "Could not save options for {{name}}: {{error}}",
|
||||||
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
||||||
"managedByServer": "This desktop is linked to a server, which decides its plugins.",
|
"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.",
|
"hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.",
|
||||||
"missingDependencies": "{{name}} needs {{list}}, which is not installed.",
|
"missingDependencies": "{{name}} needs {{list}}, which is not installed.",
|
||||||
"consentChanged": "What this plugin asks for changed since you looked. Check the list again.",
|
"consentChanged": "What this plugin asks for changed since you looked. Check the list again.",
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
LayoutTemplate,
|
||||||
|
Palette,
|
||||||
|
Power,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
TriangleAlert,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { ListRow } from "@/components/list-kit";
|
||||||
|
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||||
|
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||||
|
import { PluginIconBox } from "@/plugins/plugin-bits";
|
||||||
|
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||||
|
import {
|
||||||
|
ShellConfirmCapture,
|
||||||
|
useShellController,
|
||||||
|
} from "@/shell/controller/ShellControllerProvider";
|
||||||
|
import { TabStage } from "@/shell/stage/TabStage";
|
||||||
|
import {
|
||||||
|
chooseDefaultLook,
|
||||||
|
chooseLayout,
|
||||||
|
chooseThemeForAccount,
|
||||||
|
} from "./look-store";
|
||||||
|
import { layoutLabel, useLayouts } from "./layout-registry";
|
||||||
|
import { themeLabel, useThemes } from "./theme-registry";
|
||||||
|
import { useChooseTheme } from "./use-look-theme";
|
||||||
|
import type { LayoutStatus } from "./layout-status";
|
||||||
|
|
||||||
|
type RepairStatus = Extract<LayoutStatus, { kind: "repair" }>;
|
||||||
|
|
||||||
|
const LOOK_CAPABILITIES = ["ui:layout", "ui:theme"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an admin can do about a broken layout: update or disable its plugin,
|
||||||
|
* and install another layout or theme. Uses the Plugins tab's own flows.
|
||||||
|
*/
|
||||||
|
function AdminTools({
|
||||||
|
status,
|
||||||
|
installOpen,
|
||||||
|
setInstallOpen,
|
||||||
|
}: {
|
||||||
|
status: RepairStatus;
|
||||||
|
installOpen: boolean;
|
||||||
|
setInstallOpen: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const manager = usePluginsManager();
|
||||||
|
const entry = status.pluginId
|
||||||
|
? manager.plugins.find((plugin) => plugin.id === status.pluginId)
|
||||||
|
: undefined;
|
||||||
|
const installable = manager.plugins.filter(
|
||||||
|
(plugin) =>
|
||||||
|
!plugin.installed &&
|
||||||
|
plugin.versions[0]?.capabilities?.some((capability) =>
|
||||||
|
LOOK_CAPABILITIES.includes(capability),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{entry?.installed && entry.updateAvailable && entry.latestVersion && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void manager.requestUpdate(entry)}
|
||||||
|
disabled={manager.busy.has(entry.id)}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{t("looks.repair.update", {
|
||||||
|
name: entry.name,
|
||||||
|
version: entry.latestVersion,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{entry?.installed && entry.enabled && !entry.managedByServer && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void manager.toggle(entry)}
|
||||||
|
disabled={manager.busy.has(entry.id)}
|
||||||
|
>
|
||||||
|
<Power />
|
||||||
|
{t("looks.repair.disable", { name: entry.name })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
|
||||||
|
<Download />
|
||||||
|
{t("looks.repair.install")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<InlineView
|
||||||
|
open={installOpen}
|
||||||
|
onOpenChange={setInstallOpen}
|
||||||
|
title={t("looks.repair.installTitle")}
|
||||||
|
icon={<LayoutTemplate className="size-3.5" />}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{manager.loading && (
|
||||||
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
|
{t("common.loading")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!manager.loading && installable.length === 0 && (
|
||||||
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
|
{manager.registryError
|
||||||
|
? t("looks.repair.registryUnreachable")
|
||||||
|
: t("looks.repair.nothingToInstall")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{installable.map((plugin, index) => (
|
||||||
|
<ListRow
|
||||||
|
key={`${plugin.registryId ?? ""}:${plugin.id}`}
|
||||||
|
stripe={index}
|
||||||
|
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||||
|
title={plugin.name}
|
||||||
|
meta={plugin.description}
|
||||||
|
trailing={
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
disabled={manager.busy.has(plugin.id)}
|
||||||
|
onClick={() => manager.requestInstall(plugin)}
|
||||||
|
>
|
||||||
|
{t("looks.repair.installButton")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</InlineView>
|
||||||
|
|
||||||
|
<PluginConsentPrompt
|
||||||
|
request={manager.consent}
|
||||||
|
onCancel={() => manager.setConsent(null)}
|
||||||
|
onConfirm={() => void manager.confirmConsent()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown instead of a layout that cannot be drawn. Small on purpose: it has
|
||||||
|
* to work when everything else is broken, and gets people back to a working
|
||||||
|
* look in one click.
|
||||||
|
*/
|
||||||
|
export function RepairView({
|
||||||
|
status,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
status: RepairStatus;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const shell = useShellController();
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chooseTheme = useChooseTheme();
|
||||||
|
const layouts = useLayouts();
|
||||||
|
const themes = useThemes();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
|
||||||
|
const layoutName =
|
||||||
|
layouts.find((layout) => layout.id === status.layoutId) ?? null;
|
||||||
|
const name = layoutName ? layoutLabel(layoutName, t) : status.layoutId;
|
||||||
|
const details = [
|
||||||
|
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
|
||||||
|
status.pluginId
|
||||||
|
? `${t("looks.repair.plugin")}: ${record?.summary.name ?? status.pluginId} ${status.version ?? ""}`.trim()
|
||||||
|
: null,
|
||||||
|
`${t("looks.repair.reason")}: ${t(`looks.repair.reasons.${status.reason}`)}`,
|
||||||
|
status.error ? `${t("looks.repair.error")}: ${status.error}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(details);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("looks.repair.copyFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (action: () => Promise<void>) => () =>
|
||||||
|
void action().catch(() => toast.error(t("looks.repair.saveFailed")));
|
||||||
|
|
||||||
|
const resetOwn = run(async () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("vite-ui-theme");
|
||||||
|
} catch {
|
||||||
|
// Private mode.
|
||||||
|
}
|
||||||
|
await chooseThemeForAccount(null);
|
||||||
|
await chooseLayout(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForEveryone = run(() =>
|
||||||
|
chooseDefaultLook({ layout: null, theme: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settingsOpen) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col w-screen bg-background"
|
||||||
|
style={{ height: "100dvh" }}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
{t("looks.repair.back")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SurfaceScope kind="tab" className="overflow-hidden">
|
||||||
|
<ShellConfirmCapture />
|
||||||
|
<TabStage />
|
||||||
|
</SurfaceScope>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = status.reason === "safe-url" || status.reason === "safe-env";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SurfaceScope kind="tab" className="h-dvh w-screen bg-background">
|
||||||
|
<ShellConfirmCapture />
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-4 py-10">
|
||||||
|
<div className="flex w-full max-w-xl flex-col gap-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center border border-border bg-muted">
|
||||||
|
<TriangleAlert className="size-4 text-warning" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<h1 className="text-sm font-semibold text-foreground">
|
||||||
|
{safe ? t("looks.repair.safeTitle") : t("looks.repair.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`looks.repair.reasons.${status.reason}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col border border-border">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2">
|
||||||
|
<span className="truncate text-xs text-foreground">
|
||||||
|
{name}
|
||||||
|
{status.pluginId && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" | "}
|
||||||
|
{record?.summary.name ?? status.pluginId}
|
||||||
|
{status.version ? ` ${status.version}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button size="xs" variant="ghost" onClick={() => void copy()}>
|
||||||
|
{copied ? <Check /> : <Copy />}
|
||||||
|
{copied ? t("looks.repair.copied") : t("looks.repair.copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||||
|
{status.error ?? t("looks.repair.noError")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{!safe && (
|
||||||
|
<Button size="sm" onClick={onRetry}>
|
||||||
|
<RefreshCw />
|
||||||
|
{t("looks.repair.retry")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={resetOwn}>
|
||||||
|
<RotateCcw />
|
||||||
|
{t("looks.repair.reset")}
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button size="sm" variant="outline" onClick={resetForEveryone}>
|
||||||
|
<RotateCcw />
|
||||||
|
{t("looks.repair.resetEveryone")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
shell.tabs.openSingleton("settings", undefined, undefined, {
|
||||||
|
section: "appearance",
|
||||||
|
});
|
||||||
|
setSettingsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings />
|
||||||
|
{t("looks.repair.settings")}
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<AdminTools
|
||||||
|
status={status}
|
||||||
|
installOpen={installOpen}
|
||||||
|
setInstallOpen={setInstallOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<p className="border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{t("looks.repair.askAdmin")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1.5">
|
||||||
|
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
<LayoutTemplate className="size-3" />
|
||||||
|
{t("looks.repair.switchLayout")}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col border border-border">
|
||||||
|
{layouts.map((layout, index) => (
|
||||||
|
<ListRow
|
||||||
|
key={layout.id}
|
||||||
|
stripe={index}
|
||||||
|
title={layoutLabel(layout, t)}
|
||||||
|
meta={layout.id}
|
||||||
|
selected={layout.id === status.layoutId}
|
||||||
|
onClick={run(() => chooseLayout(layout.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1.5">
|
||||||
|
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
<Palette className="size-3" />
|
||||||
|
{t("looks.repair.switchTheme")}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{themes.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
title={themeLabel(entry, t)}
|
||||||
|
aria-pressed={theme === entry.id}
|
||||||
|
onClick={() => chooseTheme(entry.id)}
|
||||||
|
className={`flex items-center gap-1.5 border px-2 py-1 text-[11px] ${theme === entry.id ? "border-accent-brand text-foreground" : "border-border text-muted-foreground hover:border-foreground/40"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-3 border border-border/60"
|
||||||
|
style={{ background: entry.swatch.background }}
|
||||||
|
/>
|
||||||
|
{themeLabel(entry, t)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SurfaceScope>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useSyncExternalStore, type ComponentType } from "react";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { createRegistry } from "@/lib/registry";
|
||||||
|
|
||||||
|
/** The layout core ships with, used when nothing else is chosen or installed. */
|
||||||
|
export const BUILTIN_LAYOUT_ID = "default";
|
||||||
|
|
||||||
|
/** A layout someone can choose, known from a manifest before its code loads. */
|
||||||
|
export interface LayoutDescriptor {
|
||||||
|
id: string;
|
||||||
|
/** i18n key: core's for the built-in, the plugin namespace's for plugins. */
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
defaultTheme?: string;
|
||||||
|
/** Set for a plugin layout. */
|
||||||
|
pluginId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A layout whose code is running, from app.registerLayout or core. */
|
||||||
|
export interface RegisteredLayout {
|
||||||
|
id: string;
|
||||||
|
pluginId?: string;
|
||||||
|
component: ComponentType;
|
||||||
|
/** Shown on phones and narrow windows. Never in the desktop app. */
|
||||||
|
mobileComponent?: ComponentType;
|
||||||
|
defaultTheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_DESCRIPTOR: LayoutDescriptor = {
|
||||||
|
id: BUILTIN_LAYOUT_ID,
|
||||||
|
label: "looks.builtinLayout",
|
||||||
|
};
|
||||||
|
|
||||||
|
let pluginLayouts: LayoutDescriptor[] = [];
|
||||||
|
let descriptorSnapshot: LayoutDescriptor[] = [BUILTIN_DESCRIPTOR];
|
||||||
|
const descriptorListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const registered = createRegistry<RegisteredLayout>();
|
||||||
|
|
||||||
|
export const registerLayout = registered.register;
|
||||||
|
export const getRegisteredLayout = registered.get;
|
||||||
|
export const useRegisteredLayouts = registered.useList;
|
||||||
|
export const resetRegisteredLayouts = registered.reset;
|
||||||
|
|
||||||
|
/** Every layout that can be chosen: the built-in, then plugin layouts. */
|
||||||
|
export function listLayouts(): LayoutDescriptor[] {
|
||||||
|
return descriptorSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayoutDescriptor(id: string): LayoutDescriptor | undefined {
|
||||||
|
return descriptorSnapshot.find((layout) => layout.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the plugin layouts at once, from the plugin list. */
|
||||||
|
export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
|
||||||
|
const key = (list: LayoutDescriptor[]) =>
|
||||||
|
JSON.stringify(
|
||||||
|
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
|
||||||
|
);
|
||||||
|
if (key(layouts) === key(pluginLayouts)) return;
|
||||||
|
pluginLayouts = layouts;
|
||||||
|
descriptorSnapshot = [
|
||||||
|
BUILTIN_DESCRIPTOR,
|
||||||
|
...layouts.filter((layout) => layout.id !== BUILTIN_LAYOUT_ID),
|
||||||
|
];
|
||||||
|
for (const listener of descriptorListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeDescriptors(listener: () => void): () => void {
|
||||||
|
descriptorListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
descriptorListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLayouts(): LayoutDescriptor[] {
|
||||||
|
return useSyncExternalStore(subscribeDescriptors, listLayouts, listLayouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
|
||||||
|
if (!layout.pluginId) return t(layout.label);
|
||||||
|
return t(`${layout.pluginId}:${layout.label}`, {
|
||||||
|
defaultValue: layout.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function resetPluginLayouts(): void {
|
||||||
|
setPluginLayouts([]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { UnavailableLook } from "@/api/look-api";
|
||||||
|
import type { PluginRecord } from "@/plugin-host/plugin-store";
|
||||||
|
import type { RegisteredLayout } from "./layout-registry";
|
||||||
|
|
||||||
|
/** Why the repair view shows instead of the chosen layout. */
|
||||||
|
export type RepairReason =
|
||||||
|
/** ?safe-layout=1 in the address. */
|
||||||
|
| "safe-url"
|
||||||
|
/** TERMIX_SAFE_LAYOUT on the server. */
|
||||||
|
| "safe-env"
|
||||||
|
/** No installed plugin provides the layout. */
|
||||||
|
| "missing"
|
||||||
|
/** Its plugin is switched off. */
|
||||||
|
| "disabled"
|
||||||
|
/** Its plugin waits on a missing dependency or was refused. */
|
||||||
|
| "blocked"
|
||||||
|
/** Its plugin does not fit this Termix or plugin API. */
|
||||||
|
| "incompatible"
|
||||||
|
/** Its code threw while starting. */
|
||||||
|
| "failed"
|
||||||
|
/** It crashed while drawing, twice. */
|
||||||
|
| "crashed";
|
||||||
|
|
||||||
|
export type LayoutStatus =
|
||||||
|
| { kind: "ok"; layout: RegisteredLayout }
|
||||||
|
| { kind: "loading" }
|
||||||
|
| {
|
||||||
|
kind: "repair";
|
||||||
|
reason: RepairReason;
|
||||||
|
layoutId: string;
|
||||||
|
pluginId?: string;
|
||||||
|
version?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A crash this many times in a row hands over to the repair view. */
|
||||||
|
export const MAX_LAYOUT_CRASHES = 2;
|
||||||
|
|
||||||
|
export interface LayoutStatusInput {
|
||||||
|
layoutId: string;
|
||||||
|
safeFromUrl: boolean;
|
||||||
|
safeFromEnv: boolean;
|
||||||
|
/** The look and the plugin list have both been read. */
|
||||||
|
ready: boolean;
|
||||||
|
registered: RegisteredLayout | undefined;
|
||||||
|
/** The installed plugin whose manifest declares the layout, if any. */
|
||||||
|
owner: PluginRecord | undefined;
|
||||||
|
/** A plugin the server skipped that declares the layout, if any. */
|
||||||
|
unavailable: UnavailableLook | undefined;
|
||||||
|
crashes: number;
|
||||||
|
lastCrash: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the chosen layout can be drawn, and why not when it cannot. */
|
||||||
|
export function layoutStatus(input: LayoutStatusInput): LayoutStatus {
|
||||||
|
const repair = (
|
||||||
|
reason: RepairReason,
|
||||||
|
extra: { error?: string | null } = {},
|
||||||
|
): LayoutStatus => ({
|
||||||
|
kind: "repair",
|
||||||
|
reason,
|
||||||
|
layoutId: input.layoutId,
|
||||||
|
pluginId: input.owner?.summary.id ?? input.unavailable?.id,
|
||||||
|
version: input.owner?.summary.version ?? input.unavailable?.version ?? null,
|
||||||
|
error: extra.error ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.safeFromUrl) return repair("safe-url");
|
||||||
|
if (input.safeFromEnv) return repair("safe-env");
|
||||||
|
if (input.crashes >= MAX_LAYOUT_CRASHES) {
|
||||||
|
return repair("crashed", { error: input.lastCrash });
|
||||||
|
}
|
||||||
|
if (input.registered) return { kind: "ok", layout: input.registered };
|
||||||
|
if (!input.ready) return { kind: "loading" };
|
||||||
|
|
||||||
|
if (input.unavailable && !input.owner) {
|
||||||
|
return repair("incompatible", { error: input.unavailable.error });
|
||||||
|
}
|
||||||
|
const owner = input.owner;
|
||||||
|
if (!owner) return repair("missing");
|
||||||
|
const summary = owner.summary;
|
||||||
|
if (!summary.enabled) return repair("disabled");
|
||||||
|
if (owner.frontend === "blocked" || summary.state === "blocked") {
|
||||||
|
return repair("blocked", { error: owner.error ?? summary.lastError });
|
||||||
|
}
|
||||||
|
if (owner.frontend === "failed" || summary.state === "failed") {
|
||||||
|
return repair("failed", { error: owner.error ?? summary.lastError });
|
||||||
|
}
|
||||||
|
if (owner.frontend === "loading") return { kind: "loading" };
|
||||||
|
// Just chosen: the runtime is about to load its code.
|
||||||
|
if (
|
||||||
|
summary.frontend &&
|
||||||
|
(owner.frontend === "inactive" || owner.frontend === "none")
|
||||||
|
) {
|
||||||
|
return { kind: "loading" };
|
||||||
|
}
|
||||||
|
// Running, but it never registered the layout it declares.
|
||||||
|
return repair("missing");
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import {
|
||||||
|
getLook,
|
||||||
|
saveDefaultLook,
|
||||||
|
saveUserLook,
|
||||||
|
type LookResponse,
|
||||||
|
} from "@/api/look-api";
|
||||||
|
import { resolveLayoutId, safeLayoutInUrl } from "./resolve-look";
|
||||||
|
|
||||||
|
const LAST_LAYOUT_KEY = "termix-layout";
|
||||||
|
|
||||||
|
interface LookState {
|
||||||
|
/** The server answered, or failed to; the look is decided either way. */
|
||||||
|
loaded: boolean;
|
||||||
|
response: LookResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: LookState = { loaded: false, response: null };
|
||||||
|
let request: Promise<LookResponse | null> | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function setState(next: LookState) {
|
||||||
|
state = next;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastLayout(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_LAYOUT_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the look once per session. Never rejects: no answer means defaults. */
|
||||||
|
export function loadLook(): Promise<LookResponse | null> {
|
||||||
|
request ??= getLook()
|
||||||
|
.then((response) => {
|
||||||
|
setState({ loaded: true, response });
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setState({ loaded: true, response: null });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches again, e.g. after an admin changed the defaults. */
|
||||||
|
export function reloadLook(): Promise<LookResponse | null> {
|
||||||
|
request = null;
|
||||||
|
return loadLook();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On logout: the next user has their own look. */
|
||||||
|
export function resetLook(): void {
|
||||||
|
request = null;
|
||||||
|
setState({ loaded: false, response: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLookState(): LookState {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeLook(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLookState(): LookState {
|
||||||
|
return useSyncExternalStore(subscribeLook, getLookState, getLookState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layout to show now. Remembered so a failed fetch next time still knows. */
|
||||||
|
export function chosenLayoutId(): string {
|
||||||
|
const id = resolveLayoutId(state.response, readLastLayout());
|
||||||
|
if (state.response) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_LAYOUT_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// Private mode; the server is asked every time anyway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The URL or the server asks for the repair view. */
|
||||||
|
export function safeLayoutRequested(): boolean {
|
||||||
|
return (
|
||||||
|
(typeof window !== "undefined" &&
|
||||||
|
safeLayoutInUrl(window.location.search)) ||
|
||||||
|
state.response?.safeLayout === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchResponse(patch: Partial<LookResponse>) {
|
||||||
|
if (!state.response) return;
|
||||||
|
setState({ ...state, response: { ...state.response, ...patch } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tells the plugin runtime to load the chosen layout's code. */
|
||||||
|
function announceLayoutChange() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new Event("termix:plugins-changed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's own layout; null goes back to the default. */
|
||||||
|
export async function chooseLayout(layoutId: string | null): Promise<void> {
|
||||||
|
await saveUserLook({ layoutId });
|
||||||
|
patchResponse({ layoutId });
|
||||||
|
announceLayoutChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's own theme; null goes back to the default. */
|
||||||
|
export async function chooseThemeForAccount(
|
||||||
|
themeId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await saveUserLook({ themeId });
|
||||||
|
patchResponse({ themeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin only: what everyone sees until they choose their own. */
|
||||||
|
export async function chooseDefaultLook(defaults: {
|
||||||
|
layout?: string | null;
|
||||||
|
theme?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const saved = await saveDefaultLook(defaults);
|
||||||
|
patchResponse({ defaults: saved });
|
||||||
|
announceLayoutChange();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { LookResponse } from "@/api/look-api";
|
||||||
|
import { BUILTIN_LAYOUT_ID } from "./layout-registry";
|
||||||
|
|
||||||
|
/** The theme a fresh install starts in when nothing else says otherwise. */
|
||||||
|
export const FALLBACK_THEME = "dark";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout to show: the user's choice, else the admin default, else the
|
||||||
|
* default look, else core's built-in. Without an answer from the server the
|
||||||
|
* last layout this browser showed stands in.
|
||||||
|
*/
|
||||||
|
export function resolveLayoutId(
|
||||||
|
look: LookResponse | null,
|
||||||
|
lastShown: string | null = null,
|
||||||
|
): string {
|
||||||
|
if (!look) return lastShown ?? BUILTIN_LAYOUT_ID;
|
||||||
|
return (
|
||||||
|
look.layoutId ??
|
||||||
|
look.defaults.layout ??
|
||||||
|
look.defaultLook.layout ??
|
||||||
|
BUILTIN_LAYOUT_ID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme to show: the user's choice, else the one this browser kept
|
||||||
|
* (today's per-device setting, so existing users see no change), else the
|
||||||
|
* admin default, else the layout's own, else the default look's.
|
||||||
|
*/
|
||||||
|
export function resolveThemeId(
|
||||||
|
look: LookResponse | null,
|
||||||
|
localTheme: string | null,
|
||||||
|
layoutDefaultTheme?: string,
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
look?.themeId ??
|
||||||
|
localTheme ??
|
||||||
|
look?.defaults.theme ??
|
||||||
|
layoutDefaultTheme ??
|
||||||
|
look?.defaultLook.theme ??
|
||||||
|
FALLBACK_THEME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ?safe-layout=1 in the address bar opens the repair view. */
|
||||||
|
export function safeLayoutInUrl(search: string): boolean {
|
||||||
|
const value = new URLSearchParams(search).get("safe-layout");
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { arePluginThemesKnown, type ThemeDef } from "./theme-registry";
|
||||||
|
|
||||||
|
const LINK_ID = "termix-theme-css";
|
||||||
|
/** The last plugin theme's stylesheet, so a reload shows it before login too. */
|
||||||
|
const CACHE_KEY = "termix-theme-css";
|
||||||
|
|
||||||
|
function readCache(): { id: string; href: string } | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY);
|
||||||
|
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof (parsed as { id?: unknown }).id === "string" &&
|
||||||
|
typeof (parsed as { href?: unknown }).href === "string"
|
||||||
|
) {
|
||||||
|
return parsed as { id: string; href: string };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unreadable or private mode.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(value: { id: string; href: string } | null) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(CACHE_KEY, JSON.stringify(value));
|
||||||
|
else localStorage.removeItem(CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Private mode; the theme still applies once plugins load.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the one plugin theme stylesheet the shown theme needs, and only that
|
||||||
|
* one. Built-in themes need none. A theme whose plugin has not loaded yet
|
||||||
|
* keeps the stylesheet it had last time.
|
||||||
|
*/
|
||||||
|
export function applyThemeCss(id: string, theme: ThemeDef | undefined): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const cached = readCache();
|
||||||
|
let href: string | null = null;
|
||||||
|
if (theme?.cssHref) {
|
||||||
|
href = theme.cssHref;
|
||||||
|
writeCache({ id, href });
|
||||||
|
} else if (cached?.id === id && !arePluginThemesKnown()) {
|
||||||
|
// Its plugin has not said yet whether it still provides this theme.
|
||||||
|
href = cached.href;
|
||||||
|
} else if (cached && arePluginThemesKnown()) {
|
||||||
|
writeCache(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
|
||||||
|
if (!href) {
|
||||||
|
link?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.id = LINK_ID;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
const absolute = new URL(href, window.location.href).href;
|
||||||
|
if (link.href !== absolute) link.href = absolute;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
|
/** Follows the OS between the light and dark theme. Not a real theme. */
|
||||||
|
export const SYSTEM_THEME = "system";
|
||||||
|
|
||||||
|
export interface ThemeSwatch {
|
||||||
|
background: string;
|
||||||
|
panel: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeDef {
|
||||||
|
/** Also the class put on <html> while the theme is active. */
|
||||||
|
id: string;
|
||||||
|
/** i18n key: core's for built-ins, the plugin namespace's for plugins. */
|
||||||
|
label: string;
|
||||||
|
mode: "light" | "dark";
|
||||||
|
swatch: ThemeSwatch;
|
||||||
|
/** Set for a plugin theme. */
|
||||||
|
pluginId?: string;
|
||||||
|
/** Where a plugin theme's CSS is served. Built-ins live in index.css. */
|
||||||
|
cssHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themes core ships, with their tokens in index.css. A plugin theme with the
|
||||||
|
* same id takes the built-in's place while it is installed.
|
||||||
|
*/
|
||||||
|
export const BUILTIN_THEMES: readonly ThemeDef[] = [
|
||||||
|
{
|
||||||
|
id: "light",
|
||||||
|
label: "newUi.sidebar.userProfile.themeLight",
|
||||||
|
mode: "light",
|
||||||
|
swatch: { background: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dark",
|
||||||
|
label: "newUi.sidebar.userProfile.themeDark",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dracula",
|
||||||
|
label: "newUi.sidebar.userProfile.themeDracula",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#282a36", panel: "#44475a", text: "#f8f8f2" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "catppuccin",
|
||||||
|
label: "newUi.sidebar.userProfile.themeCatppuccin",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#1e1e2e", panel: "#313244", text: "#cdd6f4" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nord",
|
||||||
|
label: "newUi.sidebar.userProfile.themeNord",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#2e3440", panel: "#3b4252", text: "#eceff4" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "solarized",
|
||||||
|
label: "newUi.sidebar.userProfile.themeSolarized",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#002b36", panel: "#073642", text: "#93a1a1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tokyo-night",
|
||||||
|
label: "newUi.sidebar.userProfile.themeTokyoNight",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#1a1b26", panel: "#24283b", text: "#c0caf5" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "one-dark",
|
||||||
|
label: "newUi.sidebar.userProfile.themeOneDark",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#282c34", panel: "#21252b", text: "#abb2bf" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gruvbox",
|
||||||
|
label: "newUi.sidebar.userProfile.themeGruvbox",
|
||||||
|
mode: "dark",
|
||||||
|
swatch: { background: "#282828", panel: "#3c3836", text: "#ebdbb2" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||||
|
let pluginThemes = new Map<string, ThemeDef>();
|
||||||
|
/** False until the plugin list has been read once this session. */
|
||||||
|
let pluginThemesKnown = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let snapshot: ThemeDef[] | null = null;
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
snapshot = null;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest. */
|
||||||
|
export function listThemes(): ThemeDef[] {
|
||||||
|
if (!snapshot) {
|
||||||
|
const replaced = BUILTIN_THEMES.map(
|
||||||
|
(theme) => pluginThemes.get(theme.id) ?? theme,
|
||||||
|
);
|
||||||
|
const extra = [...pluginThemes.values()]
|
||||||
|
.filter((theme) => !builtins.has(theme.id))
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
snapshot = [...replaced, ...extra];
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTheme(id: string): ThemeDef | undefined {
|
||||||
|
return pluginThemes.get(id) ?? builtins.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces every plugin theme at once, from the plugin list. */
|
||||||
|
export function setPluginThemes(themes: ThemeDef[]): void {
|
||||||
|
const first = !pluginThemesKnown;
|
||||||
|
pluginThemesKnown = true;
|
||||||
|
const next = new Map(themes.map((theme) => [theme.id, theme]));
|
||||||
|
const same =
|
||||||
|
next.size === pluginThemes.size &&
|
||||||
|
[...next].every(([id, theme]) => {
|
||||||
|
const current = pluginThemes.get(id);
|
||||||
|
return (
|
||||||
|
current?.cssHref === theme.cssHref &&
|
||||||
|
current?.pluginId === theme.pluginId &&
|
||||||
|
current?.label === theme.label &&
|
||||||
|
current?.mode === theme.mode
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (same && !first) return;
|
||||||
|
pluginThemes = next;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeThemes(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemes(): ThemeDef[] {
|
||||||
|
return useSyncExternalStore(subscribeThemes, listThemes, listThemes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The light or dark mode a theme id stands for, following the OS for system. */
|
||||||
|
export function themeMode(id: string): "light" | "dark" {
|
||||||
|
if (id === SYSTEM_THEME) {
|
||||||
|
return typeof window !== "undefined" &&
|
||||||
|
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
return getTheme(id)?.mode ?? "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function themeLabel(theme: ThemeDef, t: TFunction): string {
|
||||||
|
if (!theme.pluginId) return t(theme.label);
|
||||||
|
return t(`${theme.pluginId}:${theme.label}`, { defaultValue: theme.label });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arePluginThemesKnown(): boolean {
|
||||||
|
return pluginThemesKnown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On logout, and a test seam. */
|
||||||
|
export function resetPluginThemes(): void {
|
||||||
|
pluginThemes = new Map();
|
||||||
|
pluginThemesKnown = false;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useLayoutEffect } from "react";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||||
|
import { chooseThemeForAccount, useLookState } from "./look-store";
|
||||||
|
import { getLayoutDescriptor, getRegisteredLayout } from "./layout-registry";
|
||||||
|
import { resolveThemeId } from "./resolve-look";
|
||||||
|
|
||||||
|
const LOCAL_THEME_KEY = "vite-ui-theme";
|
||||||
|
|
||||||
|
function readLocalTheme(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LOCAL_THEME_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the theme the look settles on once it is known. A theme someone
|
||||||
|
* picked themselves is kept in this browser too; a default is only shown,
|
||||||
|
* so a later default change still reaches them.
|
||||||
|
*/
|
||||||
|
export function useLookTheme(layoutId: string): void {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const look = useLookState();
|
||||||
|
const layoutDefault =
|
||||||
|
getRegisteredLayout(layoutId)?.defaultTheme ??
|
||||||
|
getLayoutDescriptor(layoutId)?.defaultTheme;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!look.loaded) return;
|
||||||
|
const next = resolveThemeId(look.response, readLocalTheme(), layoutDefault);
|
||||||
|
if (next === theme) return;
|
||||||
|
setTheme(next, !!look.response?.themeId);
|
||||||
|
// Only when the look itself changes, not on every theme switch.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [look, layoutDefault]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a theme for this user: shown now and kept in this browser. Unless
|
||||||
|
* they keep settings on this device only, it is saved to their account too,
|
||||||
|
* which is what other devices follow.
|
||||||
|
*/
|
||||||
|
export function useChooseTheme(): (themeId: string) => void {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
return useCallback(
|
||||||
|
(themeId: string) => {
|
||||||
|
setTheme(themeId);
|
||||||
|
void syncThemeChoice(themeId);
|
||||||
|
},
|
||||||
|
[setTheme],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncThemeChoice(themeId: string): Promise<void> {
|
||||||
|
const prefs = await getUserPreferences().catch(() => null);
|
||||||
|
// A device-only profile must not be overridden by an account choice.
|
||||||
|
const local = prefs?.storageMode === "local";
|
||||||
|
await chooseThemeForAccount(local ? null : themeId).catch(() => {});
|
||||||
|
// Older clients still read the profile field.
|
||||||
|
if (prefs?.storageMode === "cloud") {
|
||||||
|
await saveUserPreferences({ theme: themeId }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,13 @@ import {
|
|||||||
import { shell, shellHost, tabsApi } from "./shell-bridge";
|
import { shell, shellHost, tabsApi } from "./shell-bridge";
|
||||||
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
||||||
import { isElectron } from "@/lib/electron";
|
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 {
|
import {
|
||||||
scopeHostArgs,
|
scopeHostArgs,
|
||||||
scopeHostCallback,
|
scopeHostCallback,
|
||||||
@@ -157,7 +163,9 @@ export function createPluginApp(
|
|||||||
? "contributes.tabs"
|
? "contributes.tabs"
|
||||||
: kind === "panel"
|
: kind === "panel"
|
||||||
? "contributes.panels or contributes.tabs"
|
? "contributes.panels or contributes.tabs"
|
||||||
: "contributes.dashboardCards";
|
: kind === "layout"
|
||||||
|
? "contributes.layouts"
|
||||||
|
: "contributes.dashboardCards";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${pluginId}: ${what} "${id}" is not declared in manifest ${field}`,
|
`${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) =>
|
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||||
i18n.t(
|
i18n.t(
|
||||||
pluginKey(pluginId, key),
|
pluginKey(pluginId, key),
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import { invokeAction } from "@/shell/action-registry";
|
|||||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||||
import { usePluginComponent } from "./component-registry";
|
import { usePluginComponent } from "./component-registry";
|
||||||
|
import { themeMode, useThemes } from "@/look/theme-registry";
|
||||||
|
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
||||||
import {
|
import {
|
||||||
getLiveHostStatus,
|
getLiveHostStatus,
|
||||||
useOptionalHostStatusEntry,
|
useOptionalHostStatusEntry,
|
||||||
@@ -337,15 +339,13 @@ export const pluginHostBridge: PluginHostBridge = {
|
|||||||
|
|
||||||
useTheme() {
|
useTheme() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
if (theme === "light") return { theme: "light" };
|
// Re-render when plugin themes arrive, since they carry their mode.
|
||||||
if ((theme as string) === "system" && typeof window !== "undefined") {
|
useThemes();
|
||||||
return {
|
return { theme: themeMode(theme) };
|
||||||
theme: window.matchMedia?.("(prefers-color-scheme: light)").matches
|
},
|
||||||
? "light"
|
|
||||||
: "dark",
|
useShell() {
|
||||||
};
|
return usePluginShell(usePluginScope());
|
||||||
}
|
|
||||||
return { theme: "dark" };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toast: {
|
toast: {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import {
|
|||||||
setPluginSummaries,
|
setPluginSummaries,
|
||||||
} from "./plugin-store";
|
} from "./plugin-store";
|
||||||
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
|
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.
|
* Loads plugin frontends into the running shell.
|
||||||
@@ -55,14 +62,22 @@ let queue: Promise<void> = Promise.resolve();
|
|||||||
let deps: PluginLoaderDeps | null = null;
|
let deps: PluginLoaderDeps | null = null;
|
||||||
let started = false;
|
let started = false;
|
||||||
let guestMode = 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 {
|
function assetUrl(summary: PluginSummary, file: string): string {
|
||||||
const version = summary.assetVersion
|
return assetUrlWithVersion(summary.id, file, summary.assetVersion);
|
||||||
? `?v=${encodeURIComponent(summary.assetVersion)}`
|
|
||||||
: "";
|
|
||||||
return getBackendUrl(
|
|
||||||
`/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Absolute URL, so import() resolves it against the page, not this module. */
|
/** 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 ?? ""}`;
|
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> {
|
async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||||
setPluginSummaries(summaries);
|
setPluginSummaries(summaries);
|
||||||
|
syncLooks(summaries);
|
||||||
await loadNamespaces(summaries.filter(serverServesAssets));
|
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]));
|
const wanted = new Map(order.map((summary) => [summary.id, summary]));
|
||||||
|
|
||||||
// Dependents go first, so nothing outlives what it depends on.
|
// 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. */
|
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
|
||||||
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||||
|
|
||||||
@@ -503,6 +588,7 @@ export function startPluginRuntime(
|
|||||||
return syncPlugins();
|
return syncPlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gateLayouts = true;
|
||||||
if (!started && typeof window !== "undefined") {
|
if (!started && typeof window !== "undefined") {
|
||||||
started = true;
|
started = true;
|
||||||
lastFocusSync = Date.now();
|
lastFocusSync = Date.now();
|
||||||
@@ -514,6 +600,7 @@ export function startPluginRuntime(
|
|||||||
|
|
||||||
/** Deactivates every plugin, on logout. */
|
/** Deactivates every plugin, on logout. */
|
||||||
export function stopPluginRuntime(): Promise<void> {
|
export function stopPluginRuntime(): Promise<void> {
|
||||||
|
gateLayouts = false;
|
||||||
if (started && typeof window !== "undefined") {
|
if (started && typeof window !== "undefined") {
|
||||||
started = false;
|
started = false;
|
||||||
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
||||||
|
|||||||
@@ -114,6 +114,28 @@ export function withPluginScope<P extends object>(
|
|||||||
return Scoped;
|
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. */
|
/** Renders nothing when a plugin's icon throws, instead of a crash notice. */
|
||||||
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
|
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
|
||||||
state = { error: false };
|
state = { error: false };
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
|||||||
|
|
||||||
// Slots: a plugin can offer places for other plugins to fill.
|
// Slots: a plugin can offer places for other plugins to fill.
|
||||||
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
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.
|
// A plugin's manifest host settings, drawn inside its own host editor section.
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getPluginStoreState, type PluginRecord } from "./plugin-store";
|
|||||||
* because a disabled plugin never runs its frontend and so never registers
|
* 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.
|
* 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 =
|
export type ViewStatus =
|
||||||
/** Registered by a running plugin. */
|
/** 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)
|
!!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);
|
return !!contributes.dashboardCards?.some((card) => card.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,18 +184,20 @@ export function PluginDetail({
|
|||||||
? t("plugins.manager.disable")
|
? t("plugins.manager.disable")
|
||||||
: t("plugins.manager.enable")}
|
: t("plugins.manager.enable")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!plugin.defaultLook && (
|
||||||
variant="destructive"
|
<Button
|
||||||
size="sm"
|
variant="destructive"
|
||||||
className="gap-1.5"
|
size="sm"
|
||||||
disabled={busy || locked}
|
className="gap-1.5"
|
||||||
onClick={async () => {
|
disabled={busy || locked}
|
||||||
if (await manager.uninstall(plugin)) onUninstalled();
|
onClick={async () => {
|
||||||
}}
|
if (await manager.uninstall(plugin)) onUninstalled();
|
||||||
>
|
}}
|
||||||
<Trash2 className="size-3.5" />
|
>
|
||||||
{t("plugins.manager.uninstall")}
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
{t("plugins.manager.uninstall")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -806,20 +806,22 @@ function InstalledCard({
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
className="ml-auto size-6"
|
className="ml-auto size-6"
|
||||||
/>
|
/>
|
||||||
<Button
|
{!plugin.defaultLook && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-xs"
|
variant="ghost"
|
||||||
disabled={busy || locked}
|
size="icon-xs"
|
||||||
title={t("plugins.manager.uninstall")}
|
disabled={busy || locked}
|
||||||
aria-label={t("plugins.manager.uninstall")}
|
title={t("plugins.manager.uninstall")}
|
||||||
className={cn(
|
aria-label={t("plugins.manager.uninstall")}
|
||||||
"text-muted-foreground hover:text-destructive",
|
className={cn(
|
||||||
!plugin.docs && "ml-auto",
|
"text-muted-foreground hover:text-destructive",
|
||||||
)}
|
!plugin.docs && "ml-auto",
|
||||||
onClick={() => void manager.uninstall(plugin)}
|
)}
|
||||||
>
|
onClick={() => void manager.uninstall(plugin)}
|
||||||
<Trash2 className="size-3" />
|
>
|
||||||
</Button>
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -916,17 +918,19 @@ function InstalledRowActions({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
||||||
<Button
|
{!plugin.defaultLook && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-xs"
|
variant="ghost"
|
||||||
disabled={busy || locked}
|
size="icon-xs"
|
||||||
title={t("plugins.manager.uninstall")}
|
disabled={busy || locked}
|
||||||
aria-label={t("plugins.manager.uninstall")}
|
title={t("plugins.manager.uninstall")}
|
||||||
className="text-muted-foreground hover:text-destructive"
|
aria-label={t("plugins.manager.uninstall")}
|
||||||
onClick={() => void manager.uninstall(plugin)}
|
className="text-muted-foreground hover:text-destructive"
|
||||||
>
|
onClick={() => void manager.uninstall(plugin)}
|
||||||
<Trash2 className="size-3" />
|
>
|
||||||
</Button>
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export interface PluginEntry {
|
|||||||
registryName: string | null;
|
registryName: string | null;
|
||||||
/** A linked server decides whether it runs here. */
|
/** A linked server decides whether it runs here. */
|
||||||
managedByServer: boolean;
|
managedByServer: boolean;
|
||||||
|
/** The app is drawn with it by default, so it can be swapped but not uninstalled. */
|
||||||
|
defaultLook: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginSource =
|
export type PluginSource =
|
||||||
@@ -168,6 +170,7 @@ export function mergePlugins(
|
|||||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||||
registryName: entry?.registryName ?? null,
|
registryName: entry?.registryName ?? null,
|
||||||
managedByServer: summary.managedByServer ?? false,
|
managedByServer: summary.managedByServer ?? false,
|
||||||
|
defaultLook: summary.defaultLook ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +215,7 @@ export function mergePlugins(
|
|||||||
registryId: entry.registryId ?? "official",
|
registryId: entry.registryId ?? "official",
|
||||||
registryName: entry.registryName ?? null,
|
registryName: entry.registryName ?? null,
|
||||||
managedByServer: false,
|
managedByServer: false,
|
||||||
|
defaultLook: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +257,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
|||||||
registryId: null,
|
registryId: null,
|
||||||
registryName: null,
|
registryName: null,
|
||||||
managedByServer: false,
|
managedByServer: false,
|
||||||
|
defaultLook: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ export function usePluginsManager() {
|
|||||||
const info = readError(error, t("plugins.manager.errors.generic"));
|
const info = readError(error, t("plugins.manager.errors.generic"));
|
||||||
if (info.code === "MANAGED_BY_SERVER") {
|
if (info.code === "MANAGED_BY_SERVER") {
|
||||||
toast.error(t("plugins.manager.errors.managedByServer"));
|
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") {
|
} else if (info.code === "HAS_DEPENDENTS") {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("plugins.manager.errors.hasDependents", {
|
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 { LANGUAGES } from "@/i18n/languages";
|
||||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||||
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
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";
|
export const DEFAULT_ACCENT = "#f59145";
|
||||||
|
|
||||||
|
/** The built-in themes, with System first. Plugin themes come from useThemeOptions. */
|
||||||
export const THEMES: { id: ThemeId; preview: string }[] = [
|
export const THEMES: { id: ThemeId; preview: string }[] = [
|
||||||
{ id: "system", preview: "auto" },
|
{ id: SYSTEM_THEME, preview: "auto" },
|
||||||
{ id: "light", preview: "#ffffff" },
|
...BUILTIN_THEMES.map((theme) => ({
|
||||||
{ id: "dark", preview: "#1a1c22" },
|
id: theme.id,
|
||||||
{ id: "dracula", preview: "#282a36" },
|
preview: theme.swatch.background,
|
||||||
{ 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" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Background, panel and text colours for the little theme previews. */
|
export interface ThemeOption {
|
||||||
const THEME_PALETTES: Record<
|
id: ThemeId;
|
||||||
Exclude<ThemeId, "system">,
|
label: string;
|
||||||
{ bg: string; panel: string; text: string }
|
/** Null for System, which previews light and dark side by side. */
|
||||||
> = {
|
swatch: ThemeSwatch | null;
|
||||||
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 const THEME_LABEL_KEYS: Record<ThemeId, string> = {
|
/** Every theme to pick from, System first, plugin themes included. */
|
||||||
system: "newUi.sidebar.userProfile.themeSystem",
|
export function useThemeOptions(): ThemeOption[] {
|
||||||
light: "newUi.sidebar.userProfile.themeLight",
|
const { t } = useTranslation();
|
||||||
dark: "newUi.sidebar.userProfile.themeDark",
|
const themes = useThemes();
|
||||||
dracula: "newUi.sidebar.userProfile.themeDracula",
|
return [
|
||||||
catppuccin: "newUi.sidebar.userProfile.themeCatppuccin",
|
{
|
||||||
nord: "newUi.sidebar.userProfile.themeNord",
|
id: SYSTEM_THEME,
|
||||||
solarized: "newUi.sidebar.userProfile.themeSolarized",
|
label: t("newUi.sidebar.userProfile.themeSystem"),
|
||||||
"tokyo-night": "newUi.sidebar.userProfile.themeTokyoNight",
|
swatch: null,
|
||||||
"one-dark": "newUi.sidebar.userProfile.themeOneDark",
|
},
|
||||||
gruvbox: "newUi.sidebar.userProfile.themeGruvbox",
|
...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 {
|
export function isHexColor(value: string): boolean {
|
||||||
return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value);
|
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.
|
* way the profile settings store them.
|
||||||
*/
|
*/
|
||||||
export function useAppearanceSettings() {
|
export function useAppearanceSettings() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const chooseTheme = useChooseTheme();
|
||||||
const [accent, setAccentState] = useState(
|
const [accent, setAccentState] = useState(
|
||||||
() => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT,
|
() => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT,
|
||||||
);
|
);
|
||||||
@@ -105,8 +111,7 @@ export function useAppearanceSettings() {
|
|||||||
uiFont,
|
uiFont,
|
||||||
language,
|
language,
|
||||||
setTheme(id: ThemeId) {
|
setTheme(id: ThemeId) {
|
||||||
setTheme(id);
|
chooseTheme(id);
|
||||||
void saveIfCloud({ theme: id });
|
|
||||||
},
|
},
|
||||||
setAccent(value: string) {
|
setAccent(value: string) {
|
||||||
setAccentState(value);
|
setAccentState(value);
|
||||||
@@ -134,37 +139,29 @@ export function useAppearanceSettings() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemePreview({ id }: { id: ThemeId }) {
|
function ThemePreview({ swatch }: { swatch: ThemeSwatch | null }) {
|
||||||
if (id === "system") {
|
if (!swatch) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-14 w-full">
|
<div className="flex h-14 w-full">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<MiniWindow {...THEME_PALETTES.light} />
|
<MiniWindow {...LIGHT_SWATCH} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<MiniWindow {...THEME_PALETTES.dark} />
|
<MiniWindow {...DARK_SWATCH} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="h-14 w-full">
|
<div className="h-14 w-full">
|
||||||
<MiniWindow {...THEME_PALETTES[id]} />
|
<MiniWindow {...swatch} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniWindow({
|
function MiniWindow({ background, panel, text }: ThemeSwatch) {
|
||||||
bg,
|
|
||||||
panel,
|
|
||||||
text,
|
|
||||||
}: {
|
|
||||||
bg: string;
|
|
||||||
panel: string;
|
|
||||||
text: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full" style={{ background: bg }}>
|
<div className="flex h-full w-full" style={{ background }}>
|
||||||
<div
|
<div
|
||||||
className="flex w-1/4 flex-col gap-1 p-1"
|
className="flex w-1/4 flex-col gap-1 p-1"
|
||||||
style={{ background: panel }}
|
style={{ background: panel }}
|
||||||
@@ -198,26 +195,34 @@ export function ThemeGrid({
|
|||||||
value: ThemeId;
|
value: ThemeId;
|
||||||
onChange: (id: ThemeId) => void;
|
onChange: (id: ThemeId) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { setThemePreview } = useTheme();
|
||||||
|
const options = useThemeOptions();
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
<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;
|
const active = value === id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active}
|
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 ${
|
className={`flex flex-col overflow-hidden border text-left transition-colors ${
|
||||||
active
|
active
|
||||||
? "border-accent-brand ring-1 ring-accent-brand"
|
? "border-accent-brand ring-1 ring-accent-brand"
|
||||||
: "border-border hover:border-foreground/40"
|
: "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]">
|
<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" />}
|
{active && <Check size={11} className="text-accent-brand" />}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
|||||||
"newUi.sidebar.userProfile.sectionApiKeys",
|
"newUi.sidebar.userProfile.sectionApiKeys",
|
||||||
],
|
],
|
||||||
"appearance": [
|
"appearance": [
|
||||||
|
"looks.layoutLabel",
|
||||||
"newUi.sidebar.userProfile.accentColorLabel",
|
"newUi.sidebar.userProfile.accentColorLabel",
|
||||||
"newUi.sidebar.userProfile.appRailHoverPushesContent",
|
"newUi.sidebar.userProfile.appRailHoverPushesContent",
|
||||||
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
|
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||||
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||||
|
import { OnboardingHost } from "@/onboarding/OnboardingHost";
|
||||||
|
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
|
||||||
|
import { getTabType } from "@/shell/tab-registry";
|
||||||
|
import { TabPortals } from "@/shell/stage/TabPortals";
|
||||||
|
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
||||||
|
import {
|
||||||
|
ShellControllerProvider,
|
||||||
|
useShellController,
|
||||||
|
} from "@/shell/controller/ShellControllerProvider";
|
||||||
|
|
||||||
|
// Not needed for first paint.
|
||||||
|
const CommandPalette = lazy(() =>
|
||||||
|
import("@/shell/CommandPalette").then((m) => ({
|
||||||
|
default: m.CommandPalette,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Core's palette, opened by any layout through the shell's palette.open(). */
|
||||||
|
function ShellPalette() {
|
||||||
|
const shell = useShellController();
|
||||||
|
const { tabs, hosts, palette } = shell;
|
||||||
|
if (!palette.isOpen) return null;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={palette.isOpen}
|
||||||
|
setIsOpen={palette.setOpen}
|
||||||
|
hosts={hosts.list}
|
||||||
|
terminalTabs={tabs.terminalTabs}
|
||||||
|
activeTabId={tabs.activeId}
|
||||||
|
onOpenPanel={(view) => shell.regions.railClick(view)}
|
||||||
|
onEditHost={hosts.edit}
|
||||||
|
onOpenSettings={(section) =>
|
||||||
|
tabs.openSingleton(
|
||||||
|
"settings",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
section ? { section } : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onOpenTab={(type, label, pendingEvent) => {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"dashboard",
|
||||||
|
"host-manager",
|
||||||
|
"user-profile",
|
||||||
|
"admin-settings",
|
||||||
|
"plugins",
|
||||||
|
].includes(type)
|
||||||
|
) {
|
||||||
|
tabs.openSingleton(type, pendingEvent);
|
||||||
|
} else if (getTabType(type)?.multiInstance) {
|
||||||
|
tabs.openMultiInstance(type);
|
||||||
|
} else if (getTabType(type)?.singleton) {
|
||||||
|
// A singleton plugin tab, optionally preselecting a host.
|
||||||
|
tabs.openSingleton(
|
||||||
|
type,
|
||||||
|
undefined,
|
||||||
|
label ? hosts.list.find((h) => h.name === label) : undefined,
|
||||||
|
);
|
||||||
|
} else if (label) {
|
||||||
|
const host = hosts.list.find((h) => h.name === label);
|
||||||
|
if (host) tabs.open(host, type);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in app: the shell's state, the chosen layout, every tab's
|
||||||
|
* content and the pieces core always owns (palette, overlays, onboarding).
|
||||||
|
*/
|
||||||
|
export function ShellRoot({
|
||||||
|
username,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
onLogout: (options?: { manual?: boolean }) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ServerStatusProvider isAuthenticated={!!username}>
|
||||||
|
<ShellControllerProvider username={username} onLogout={onLogout}>
|
||||||
|
<LayoutHost />
|
||||||
|
<TabPortals />
|
||||||
|
<ComponentSlot slotId="shell.overlay" />
|
||||||
|
<ShellPalette />
|
||||||
|
<OnboardingHost />
|
||||||
|
{isElectron() && <DesktopUpdateToasts />}
|
||||||
|
</ShellControllerProvider>
|
||||||
|
</ServerStatusProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import {
|
||||||
|
useConfirm,
|
||||||
|
type ConfirmOptions,
|
||||||
|
} from "@/components/surface/surface-scope";
|
||||||
|
import type { Host, TabType } from "@/types/ui-types";
|
||||||
|
import { requestManage } from "@/manage/manage-requests";
|
||||||
|
import { useRailItems } from "@/sidebar/rail-items";
|
||||||
|
import { usePanels } from "@/shell/panel-registry";
|
||||||
|
import { getTabType, type TabShellCallbacks } from "@/shell/tab-registry";
|
||||||
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
|
import { setShellCallbacks } from "@/plugin-host/shell-bridge";
|
||||||
|
import {
|
||||||
|
createTabStageStore,
|
||||||
|
type TabStageStore,
|
||||||
|
} from "@/shell/stage/tab-stage-store";
|
||||||
|
import { useRegions } from "./use-regions";
|
||||||
|
import { useHostList } from "./use-hosts";
|
||||||
|
import { useTabs } from "./use-tabs";
|
||||||
|
import { useSplit } from "./use-split";
|
||||||
|
import { useWorkspaceLayout } from "./use-workspace-layout";
|
||||||
|
import { useShellKeybindings } from "./use-shell-keybindings";
|
||||||
|
import { useShellLifecycle } from "./use-shell-lifecycle";
|
||||||
|
import { publishShellController } from "./shell-store";
|
||||||
|
import type { ShellController, ShellPalette } from "./types";
|
||||||
|
|
||||||
|
const ShellControllerContext = createContext<ShellController | null>(null);
|
||||||
|
const TabStageContext = createContext<TabStageStore | null>(null);
|
||||||
|
|
||||||
|
/** The live shell, for core layout code. Throws outside the shell. */
|
||||||
|
export function useShellController(): ShellController {
|
||||||
|
const controller = useContext(ShellControllerContext);
|
||||||
|
if (!controller) throw new Error("useShellController outside the shell");
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live shell, or null outside it. */
|
||||||
|
export function useOptionalShellController(): ShellController | null {
|
||||||
|
return useContext(ShellControllerContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTabStageStore(): TabStageStore {
|
||||||
|
const store = useContext(TabStageContext);
|
||||||
|
if (!store) throw new Error("useTabStageStore outside the shell");
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the main area's confirm to code outside it, like closeTab. A layout
|
||||||
|
* renders it inside the scope its tab-close questions should show in.
|
||||||
|
*/
|
||||||
|
export function ShellConfirmCapture(): null {
|
||||||
|
const { confirmRef } = useShellController();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
confirmRef.current = confirm;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns every bit of shell state and behaviour a layout draws. */
|
||||||
|
export function ShellControllerProvider({
|
||||||
|
username,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
onLogout: (options?: { manual?: boolean }) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// Re-render when plugins add or remove rail items, panels or tabs.
|
||||||
|
useRailItems();
|
||||||
|
usePanels();
|
||||||
|
const { settled: pluginsSettled } = usePluginStore();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [stageStore] = useState(createTabStageStore);
|
||||||
|
// The root confirm, for questions the shell itself asks (closing a tab).
|
||||||
|
const confirmRef = useRef<
|
||||||
|
((options: ConfirmOptions) => Promise<boolean>) | null
|
||||||
|
>(null);
|
||||||
|
const openSingletonRef = useRef<(type: TabType) => string | undefined>(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lifecycle = useShellLifecycle({
|
||||||
|
onLogout,
|
||||||
|
openSingletonTabRef: openSingletonRef,
|
||||||
|
});
|
||||||
|
const regions = useRegions(isMobile);
|
||||||
|
const hosts = useHostList();
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
const tabsState = useTabs({
|
||||||
|
hosts: hosts.list,
|
||||||
|
hostsRef: hosts.listRef,
|
||||||
|
hostsLoaded: hosts.loaded,
|
||||||
|
reloadHosts: hosts.reload,
|
||||||
|
userPrefs: lifecycle.userPrefs,
|
||||||
|
userPrefsLoaded: lifecycle.userPrefsLoaded,
|
||||||
|
pluginsSettled,
|
||||||
|
confirmRef,
|
||||||
|
layoutDeps: [
|
||||||
|
regions.left.view,
|
||||||
|
regions.left.open,
|
||||||
|
regions.left.width,
|
||||||
|
regions.right.view,
|
||||||
|
regions.right.width,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
openSingletonRef.current = tabsState.openSingletonTab;
|
||||||
|
const { tabs, activeTabId } = tabsState;
|
||||||
|
|
||||||
|
// Keeps the desktop's sync status polled while the app is open; a pass
|
||||||
|
// that changed data tells the panels to reload.
|
||||||
|
useSyncStatus();
|
||||||
|
|
||||||
|
const palette: ShellPalette = {
|
||||||
|
isOpen: paletteOpen,
|
||||||
|
open: () => setPaletteOpen(true),
|
||||||
|
close: () => setPaletteOpen(false),
|
||||||
|
setOpen: setPaletteOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
function editHostInManager(host: Host) {
|
||||||
|
requestManage({ kind: "host", hostId: host.id });
|
||||||
|
if (isMobile) regions.left.setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What plugins may ask of the shell. Rebuilt every render so it always
|
||||||
|
* closes over current state, and published to the plugin runtime.
|
||||||
|
*/
|
||||||
|
const shellCallbacksImpl: TabShellCallbacks = {
|
||||||
|
openTab: (host, type, options) => {
|
||||||
|
if (!host || getTabType(type)?.singleton) {
|
||||||
|
tabsState.openSingletonTab(
|
||||||
|
type,
|
||||||
|
undefined,
|
||||||
|
host ?? undefined,
|
||||||
|
options?.data,
|
||||||
|
options?.label,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabsState.openTab(host, type, undefined, options);
|
||||||
|
},
|
||||||
|
openSingletonTab: (type, options) =>
|
||||||
|
tabsState.openSingletonTab(type, undefined, undefined, options?.data),
|
||||||
|
connectHost: (host, type) => tabsState.connectHost(host, type as TabType),
|
||||||
|
closeTab: (tabId) => tabsState.closeTab(tabId),
|
||||||
|
renameTab: (tabId, label) => tabsState.renameTab(tabId, label),
|
||||||
|
|
||||||
|
openRailView: (id) => {
|
||||||
|
regions.left.setView(id);
|
||||||
|
regions.left.setOpen(true);
|
||||||
|
},
|
||||||
|
closeRailView: (id) => {
|
||||||
|
regions.left.setView((prev) => (prev === id ? "hosts" : prev));
|
||||||
|
regions.right.setView((prev) => (prev === id ? null : prev));
|
||||||
|
},
|
||||||
|
openHostEditor: (draft) =>
|
||||||
|
requestManage({ kind: "host", hostId: null, draft }),
|
||||||
|
saveQuickConnect: tabsState.saveQuickConnectHost,
|
||||||
|
confirm: (options) =>
|
||||||
|
confirmRef.current ? confirmRef.current(options) : Promise.resolve(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tabs get one stable bag that forwards to the latest callbacks, so a shell
|
||||||
|
// render does not re-render every open tab.
|
||||||
|
const shellImplRef = useRef(shellCallbacksImpl);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
shellImplRef.current = shellCallbacksImpl;
|
||||||
|
});
|
||||||
|
const callbacks = useMemo<TabShellCallbacks>(
|
||||||
|
() => ({
|
||||||
|
openTab: (...args) => shellImplRef.current.openTab(...args),
|
||||||
|
openSingletonTab: (...args) =>
|
||||||
|
shellImplRef.current.openSingletonTab(...args),
|
||||||
|
connectHost: (...args) => shellImplRef.current.connectHost(...args),
|
||||||
|
closeTab: (...args) => shellImplRef.current.closeTab(...args),
|
||||||
|
renameTab: (...args) => shellImplRef.current.renameTab(...args),
|
||||||
|
openRailView: (...args) => shellImplRef.current.openRailView(...args),
|
||||||
|
closeRailView: (...args) => shellImplRef.current.closeRailView(...args),
|
||||||
|
openHostEditor: (...args) =>
|
||||||
|
shellImplRef.current.openHostEditor?.(...args),
|
||||||
|
saveQuickConnect: (...args) =>
|
||||||
|
shellImplRef.current.saveQuickConnect!(...args),
|
||||||
|
confirm: (...args) => shellImplRef.current.confirm!(...args),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Panels close the sidebar on mobile after opening something, the same as
|
||||||
|
// the hosts panel does.
|
||||||
|
const panelCallbacks = useMemo<TabShellCallbacks>(
|
||||||
|
() => ({
|
||||||
|
...callbacks,
|
||||||
|
openTab: (...args) => {
|
||||||
|
callbacks.openTab(...args);
|
||||||
|
if (isMobile) regions.left.setOpen(false);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[callbacks, isMobile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resize all terminals in panes + active terminal when split mode or sidebar changes
|
||||||
|
const resizeAllTerminals = useCallback(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
if (!tab.terminalRef) return;
|
||||||
|
const ref = tab.terminalRef.current;
|
||||||
|
ref?.fit?.();
|
||||||
|
ref?.notifyResize?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
const { split, splitShapeKey, splitActionsRef } = useSplit({
|
||||||
|
tabsState,
|
||||||
|
isMobile,
|
||||||
|
paneEls: stageStore.paneEls,
|
||||||
|
callbacks,
|
||||||
|
resizeAllTerminals,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Refitting every terminal on each drag frame is what made sidebar drags
|
||||||
|
// stutter; fit once when the drag ends instead.
|
||||||
|
if (regions.left.dragging || regions.right.dragging) return;
|
||||||
|
const id = resizeAllTerminals();
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [
|
||||||
|
splitShapeKey,
|
||||||
|
regions.left.width,
|
||||||
|
regions.left.open,
|
||||||
|
regions.right.width,
|
||||||
|
regions.right.view,
|
||||||
|
regions.left.dragging,
|
||||||
|
regions.right.dragging,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useWorkspaceLayout(tabsState, regions);
|
||||||
|
useShellKeybindings({
|
||||||
|
tabsState,
|
||||||
|
regions,
|
||||||
|
palette,
|
||||||
|
splitActionsRef,
|
||||||
|
toggleFullscreen: lifecycle.toggleAppFullscreen,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShellCallbacks(callbacks);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||||
|
const controller: ShellController = {
|
||||||
|
username,
|
||||||
|
isAdmin: lifecycle.isAdmin,
|
||||||
|
isMobile,
|
||||||
|
isElectron: isElectron(),
|
||||||
|
pluginsSettled,
|
||||||
|
logout: onLogout,
|
||||||
|
tabs: {
|
||||||
|
list: tabs,
|
||||||
|
topLevel: tabs.filter((tab) => !tab.parentSplitTabId),
|
||||||
|
activeId: activeTabId,
|
||||||
|
active: activeTab,
|
||||||
|
ready: tabsState.tabsReady,
|
||||||
|
terminalTabs: tabsState.terminalTabs,
|
||||||
|
targetTerminal: tabsState.targetTerminalTab,
|
||||||
|
showNumbers: tabsState.showTabNumbers,
|
||||||
|
background: tabsState.backgroundTabRecords,
|
||||||
|
setBackground: tabsState.setBackgroundTabRecords,
|
||||||
|
setActive: tabsState.setActiveTabId,
|
||||||
|
setList: tabsState.setTabs,
|
||||||
|
reorder: tabsState.reorderTopLevelTabs,
|
||||||
|
open: tabsState.openTab,
|
||||||
|
openSingleton: tabsState.openSingletonTab,
|
||||||
|
openMultiInstance: tabsState.openMultiInstanceTab,
|
||||||
|
connectHost: tabsState.connectHost,
|
||||||
|
close: tabsState.closeTab,
|
||||||
|
rename: tabsState.renameTab,
|
||||||
|
refresh: tabsState.refreshTab,
|
||||||
|
reconnectAll: tabsState.reconnectAllDisconnected,
|
||||||
|
handleFor: tabsState.handleFor,
|
||||||
|
},
|
||||||
|
split,
|
||||||
|
hosts: {
|
||||||
|
list: hosts.list,
|
||||||
|
tree: hosts.tree,
|
||||||
|
loading: hosts.loading,
|
||||||
|
loaded: hosts.loaded,
|
||||||
|
reload: hosts.reload,
|
||||||
|
edit: editHostInManager,
|
||||||
|
},
|
||||||
|
regions,
|
||||||
|
palette,
|
||||||
|
fullscreen: {
|
||||||
|
active: lifecycle.isAppFullscreen,
|
||||||
|
toggle: lifecycle.toggleAppFullscreen,
|
||||||
|
},
|
||||||
|
callbacks,
|
||||||
|
panelCallbacks,
|
||||||
|
settings: {
|
||||||
|
username,
|
||||||
|
isAdmin: lifecycle.isAdmin,
|
||||||
|
onLogout,
|
||||||
|
userPrefs: lifecycle.userPrefs,
|
||||||
|
onPrefsChange: (updates) =>
|
||||||
|
lifecycle.setUserPrefs((current) => ({ ...current, ...updates })),
|
||||||
|
onOpenHostTab: (host: Host) => tabsState.connectHost(host),
|
||||||
|
},
|
||||||
|
confirmRef,
|
||||||
|
resizeAllTerminals,
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
publishShellController(controller);
|
||||||
|
});
|
||||||
|
useEffect(() => () => publishShellController(null), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabStageContext.Provider value={stageStore}>
|
||||||
|
<ShellControllerContext.Provider value={controller}>
|
||||||
|
{children}
|
||||||
|
</ShellControllerContext.Provider>
|
||||||
|
</TabStageContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type {
|
||||||
|
PluginHostRecord,
|
||||||
|
ShellController as PluginShellController,
|
||||||
|
ShellTabState,
|
||||||
|
} from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import { useBranding } from "@/contexts/BrandingContext";
|
||||||
|
import { useRailItems } from "@/sidebar/rail-items";
|
||||||
|
import { usePanels } from "@/shell/panel-registry";
|
||||||
|
import { useTabTypes } from "@/shell/tab-registry";
|
||||||
|
import { useRegisteredDashboardCards as useDashboardCards } from "@/dashboard/dashboard-cards-registry";
|
||||||
|
import type { TabSplitAction } from "@/shell/split/split-tabs";
|
||||||
|
import { toPluginHostRecord } from "@/plugin-host/bridge";
|
||||||
|
import { useOptionalShellController } from "./ShellControllerProvider";
|
||||||
|
import { useShellSnapshot } from "./shell-store";
|
||||||
|
|
||||||
|
const registries: PluginShellController["registries"] = {
|
||||||
|
useRailItems: () =>
|
||||||
|
useRailItems() as unknown as ReturnType<
|
||||||
|
PluginShellController["registries"]["useRailItems"]
|
||||||
|
>,
|
||||||
|
usePanels: () =>
|
||||||
|
usePanels() as unknown as ReturnType<
|
||||||
|
PluginShellController["registries"]["usePanels"]
|
||||||
|
>,
|
||||||
|
useTabTypes: () =>
|
||||||
|
useTabTypes() as unknown as ReturnType<
|
||||||
|
PluginShellController["registries"]["useTabTypes"]
|
||||||
|
>,
|
||||||
|
useDashboardCards: () =>
|
||||||
|
useDashboardCards() as unknown as ReturnType<
|
||||||
|
PluginShellController["registries"]["useDashboardCards"]
|
||||||
|
>,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell as a plugin layout sees it, through useShell() in the SDK. Inside
|
||||||
|
* the shell this reads the controller's context; outside (an overlay a
|
||||||
|
* plugin mounted elsewhere) the last published one.
|
||||||
|
*/
|
||||||
|
export function usePluginShell(pluginId: string): PluginShellController | null {
|
||||||
|
const fromContext = useOptionalShellController();
|
||||||
|
const published = useShellSnapshot();
|
||||||
|
const shell = fromContext ?? published;
|
||||||
|
const { permissions } = usePermissions();
|
||||||
|
const branding = useBranding();
|
||||||
|
|
||||||
|
const hosts = shell?.hosts.list;
|
||||||
|
const hostRecords = useMemo<PluginHostRecord[]>(
|
||||||
|
() => (hosts ?? []).map((host) => toPluginHostRecord(host, pluginId)),
|
||||||
|
[hosts, pluginId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shell) return null;
|
||||||
|
const { tabs, split, regions } = shell;
|
||||||
|
return {
|
||||||
|
tabs: {
|
||||||
|
list: tabs.list as unknown as ShellTabState[],
|
||||||
|
topLevel: tabs.topLevel as unknown as ShellTabState[],
|
||||||
|
activeId: tabs.activeId,
|
||||||
|
ready: tabs.ready,
|
||||||
|
showNumbers: tabs.showNumbers,
|
||||||
|
setActive: tabs.setActive,
|
||||||
|
reorder: (next) => tabs.reorder(next as unknown as typeof tabs.topLevel),
|
||||||
|
close: tabs.close,
|
||||||
|
rename: tabs.rename,
|
||||||
|
refresh: tabs.refresh,
|
||||||
|
reconnectAll: tabs.reconnectAll,
|
||||||
|
openSingleton: (type) => tabs.openSingleton(type),
|
||||||
|
},
|
||||||
|
split: {
|
||||||
|
activeId: split.active?.id ?? null,
|
||||||
|
focusedPaneId: split.focusedPaneId,
|
||||||
|
summaries: split.summaries,
|
||||||
|
activeFull: split.activeFull,
|
||||||
|
handleAction: (action) =>
|
||||||
|
split.handleAction(action as unknown as TabSplitAction),
|
||||||
|
},
|
||||||
|
hosts: {
|
||||||
|
list: hostRecords,
|
||||||
|
loading: shell.hosts.loading,
|
||||||
|
loaded: shell.hosts.loaded,
|
||||||
|
reload: shell.hosts.reload,
|
||||||
|
},
|
||||||
|
regions: {
|
||||||
|
left: {
|
||||||
|
...regions.left,
|
||||||
|
setView: (view) => regions.left.setView(view ?? "hosts"),
|
||||||
|
startResize: (event) =>
|
||||||
|
regions.left.startResize(event as unknown as React.MouseEvent),
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
...regions.right,
|
||||||
|
editing: false,
|
||||||
|
setView: (view) => regions.right.setView(view),
|
||||||
|
startResize: (event) =>
|
||||||
|
regions.right.startResize(event as unknown as React.MouseEvent),
|
||||||
|
} as PluginShellController["regions"]["right"],
|
||||||
|
railClick: regions.railClick,
|
||||||
|
openInRightDock: regions.openInRightDock,
|
||||||
|
toggleRightDock: regions.toggleRightDock,
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
isOpen: shell.palette.isOpen,
|
||||||
|
open: shell.palette.open,
|
||||||
|
close: shell.palette.close,
|
||||||
|
},
|
||||||
|
fullscreen: shell.fullscreen,
|
||||||
|
actions: shell.callbacks as unknown as PluginShellController["actions"],
|
||||||
|
env: {
|
||||||
|
isMobile: shell.isMobile,
|
||||||
|
isElectron: shell.isElectron,
|
||||||
|
user: { username: shell.username, isAdmin: shell.isAdmin },
|
||||||
|
permissions,
|
||||||
|
branding: {
|
||||||
|
appName: branding.appName || null,
|
||||||
|
tagline: branding.tagline || null,
|
||||||
|
logo: branding.logo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registries,
|
||||||
|
logout: shell.logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import type { ShellController } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last rendered shell, for code that renders outside the shell's React
|
||||||
|
* tree (a plugin component in an overlay, the plugin bridge). Inside the
|
||||||
|
* shell, the context in ShellControllerProvider is the same value without the
|
||||||
|
* one render of lag.
|
||||||
|
*/
|
||||||
|
let current: ShellController | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function publishShellController(next: ShellController | null): void {
|
||||||
|
if (next === current) return;
|
||||||
|
current = next;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShellController(): ShellController | null {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShellSnapshot(): ShellController | null {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
() => current,
|
||||||
|
() => current,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import type {
|
||||||
|
ConfirmOptions,
|
||||||
|
EditingWidth,
|
||||||
|
} from "@/components/surface/surface-scope";
|
||||||
|
import type { Host, HostFolder, Tab, TabType } from "@/types/ui-types";
|
||||||
|
import type { OpenTabRecord, UserPreferences } from "@/main-axios";
|
||||||
|
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||||
|
import type { SplitViewActions } from "@/shell/split/SplitView";
|
||||||
|
import type {
|
||||||
|
NewTabOption,
|
||||||
|
PickHostTarget,
|
||||||
|
} from "@/shell/split/EmptyPanePicker";
|
||||||
|
import type {
|
||||||
|
SplitSummary,
|
||||||
|
SplitTab,
|
||||||
|
TabSplitAction,
|
||||||
|
} from "@/shell/split/split-tabs";
|
||||||
|
|
||||||
|
export interface DockRegion {
|
||||||
|
view: string;
|
||||||
|
open: boolean;
|
||||||
|
width: number;
|
||||||
|
dragging: boolean;
|
||||||
|
editing: EditingWidth;
|
||||||
|
setView: (view: string | ((prev: string) => string)) => void;
|
||||||
|
setOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
setWidth: (width: number) => void;
|
||||||
|
startResize: (e: React.MouseEvent) => void;
|
||||||
|
resetWidth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RightDockRegion {
|
||||||
|
view: string | null;
|
||||||
|
open: boolean;
|
||||||
|
width: number;
|
||||||
|
dragging: boolean;
|
||||||
|
setView: (
|
||||||
|
view: string | null | ((prev: string | null) => string | null),
|
||||||
|
) => void;
|
||||||
|
setWidth: (width: number) => void;
|
||||||
|
startResize: (e: React.MouseEvent) => void;
|
||||||
|
resetWidth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellRegions {
|
||||||
|
left: DockRegion;
|
||||||
|
right: RightDockRegion;
|
||||||
|
/** What clicking a rail item does: toggles or switches the left dock. */
|
||||||
|
railClick: (view: string) => void;
|
||||||
|
openInRightDock: (view: string) => void;
|
||||||
|
toggleRightDock: () => void;
|
||||||
|
previousView: () => string | null;
|
||||||
|
/** Stable setters a panel reports its wanted sidebar width with. */
|
||||||
|
panelEditingSetter: (view: string) => (editing: EditingWidth) => void;
|
||||||
|
scopeEditingSetter: (view: string) => (editing: EditingWidth) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenTabOptions {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
label?: string;
|
||||||
|
forceNewTab?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreTarget {
|
||||||
|
instanceId: string;
|
||||||
|
restoredSessionId: string | null;
|
||||||
|
savedLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellTabs {
|
||||||
|
list: Tab[];
|
||||||
|
/** Tabs shown in a tab bar: everything not sitting inside a split. */
|
||||||
|
topLevel: Tab[];
|
||||||
|
activeId: string;
|
||||||
|
active: Tab | undefined;
|
||||||
|
ready: boolean;
|
||||||
|
/** Tabs panels can type into. */
|
||||||
|
terminalTabs: Tab[];
|
||||||
|
/** The terminal command panels act on right now. */
|
||||||
|
targetTerminal: Tab | undefined;
|
||||||
|
showNumbers: boolean;
|
||||||
|
background: OpenTabRecord[];
|
||||||
|
setBackground: (
|
||||||
|
next: OpenTabRecord[] | ((prev: OpenTabRecord[]) => OpenTabRecord[]),
|
||||||
|
) => void;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
setList: (next: Tab[] | ((prev: Tab[]) => Tab[])) => void;
|
||||||
|
reorder: (topLevel: Tab[]) => void;
|
||||||
|
open: (
|
||||||
|
host: Host,
|
||||||
|
type: TabType,
|
||||||
|
restore?: RestoreTarget,
|
||||||
|
options?: OpenTabOptions,
|
||||||
|
) => string;
|
||||||
|
openSingleton: (
|
||||||
|
type: TabType,
|
||||||
|
pendingEvent?: string,
|
||||||
|
host?: Host,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
label?: string,
|
||||||
|
) => string | undefined;
|
||||||
|
openMultiInstance: (
|
||||||
|
type: TabType,
|
||||||
|
options?: { host?: Host; data?: Record<string, unknown>; label?: string },
|
||||||
|
) => string;
|
||||||
|
connectHost: (host: Host, type?: TabType, options?: OpenTabOptions) => void;
|
||||||
|
close: (id: string) => Promise<void>;
|
||||||
|
rename: (id: string, label: string) => void;
|
||||||
|
refresh: (id: string) => void;
|
||||||
|
reconnectAll: () => void;
|
||||||
|
handleFor: (id: string) => TabHandle | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmptyPaneApi {
|
||||||
|
freeTabs: Tab[];
|
||||||
|
newTabOptions: NewTabOption[];
|
||||||
|
pickTab: (splitTabId: string, paneId: string, tabId: string) => void;
|
||||||
|
pickHost: (
|
||||||
|
splitTabId: string,
|
||||||
|
paneId: string,
|
||||||
|
host: Host,
|
||||||
|
pick: PickHostTarget,
|
||||||
|
) => void;
|
||||||
|
openType: (splitTabId: string, paneId: string, type: string) => void;
|
||||||
|
closePane: (splitTabId: string, paneId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellSplit {
|
||||||
|
active: SplitTab | null;
|
||||||
|
/** The pane each on-screen tab sits in, for the active split only. */
|
||||||
|
paneIdByTabId: Map<string, string>;
|
||||||
|
focusedPaneId: string | null;
|
||||||
|
summaries: SplitSummary[];
|
||||||
|
activeFull: boolean;
|
||||||
|
handleAction: (action: TabSplitAction) => void;
|
||||||
|
viewActions: (splitTabId: string) => SplitViewActions;
|
||||||
|
emptyPane: EmptyPaneApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellHosts {
|
||||||
|
list: Host[];
|
||||||
|
tree: HostFolder | null;
|
||||||
|
loading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
edit: (host: Host) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellPalette {
|
||||||
|
isOpen: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
setOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellSettingsProps {
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onLogout: (options?: { manual?: boolean }) => void;
|
||||||
|
userPrefs: UserPreferences;
|
||||||
|
onPrefsChange: (updates: Partial<UserPreferences>) => void;
|
||||||
|
onOpenHostTab: (host: Host) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the shell knows and can do, minus drawing it. */
|
||||||
|
export interface ShellController {
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
isElectron: boolean;
|
||||||
|
pluginsSettled: boolean;
|
||||||
|
logout: (options?: { manual?: boolean }) => void;
|
||||||
|
tabs: ShellTabs;
|
||||||
|
split: ShellSplit;
|
||||||
|
hosts: ShellHosts;
|
||||||
|
regions: ShellRegions;
|
||||||
|
palette: ShellPalette;
|
||||||
|
fullscreen: { active: boolean; toggle: () => void };
|
||||||
|
/** One stable bag that forwards to the latest shell, for tabs and plugins. */
|
||||||
|
callbacks: TabShellCallbacks;
|
||||||
|
/** Same, but closes the sidebar on mobile after opening a tab. */
|
||||||
|
panelCallbacks: TabShellCallbacks;
|
||||||
|
settings: ShellSettingsProps;
|
||||||
|
/** Where the main area's confirm is captured, see ShellConfirmCapture. */
|
||||||
|
confirmRef: MutableRefObject<
|
||||||
|
((options: ConfirmOptions) => Promise<boolean>) | null
|
||||||
|
>;
|
||||||
|
/** Refits every visible terminal, e.g. after a pane resize ends. */
|
||||||
|
resizeAllTerminals: () => number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { Host, HostFolder } from "@/types/ui-types";
|
||||||
|
import { getSSHFolders, getSSHHosts } from "@/main-axios";
|
||||||
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||||
|
import { buildHostTree } from "@/sidebar/build-host-tree";
|
||||||
|
import { setShellHosts } from "@/plugin-host/shell-bridge";
|
||||||
|
|
||||||
|
/** The saved hosts and their folder tree, kept fresh on change events. */
|
||||||
|
export function useHostList() {
|
||||||
|
const [tree, setTree] = useState<HostFolder | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [list, setList] = useState<Host[]>([]);
|
||||||
|
const listRef = useRef(list);
|
||||||
|
listRef.current = list;
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [raw, folders] = await Promise.all([
|
||||||
|
getSSHHosts(),
|
||||||
|
getSSHFolders().catch(() => []),
|
||||||
|
]);
|
||||||
|
const converted = raw.map(sshHostToHost);
|
||||||
|
setList(converted);
|
||||||
|
const folderMeta = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
localOnly?: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const f of folders) {
|
||||||
|
folderMeta.set(f.name, {
|
||||||
|
color: f.color ?? undefined,
|
||||||
|
icon: f.icon ?? undefined,
|
||||||
|
credentialId: f.credentialId ?? null,
|
||||||
|
sortOrder: f.sortOrder ?? null,
|
||||||
|
localOnly: !!f.localOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTree(buildHostTree(raw, folderMeta));
|
||||||
|
} catch {
|
||||||
|
// Keep empty state on error
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHostsChanged = () => {
|
||||||
|
void reload();
|
||||||
|
};
|
||||||
|
window.addEventListener("termix:hosts-changed", onHostsChanged);
|
||||||
|
window.addEventListener("ssh-hosts:changed", onHostsChanged);
|
||||||
|
window.addEventListener("hosts:refresh", onHostsChanged);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("termix:hosts-changed", onHostsChanged);
|
||||||
|
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
|
||||||
|
window.removeEventListener("hosts:refresh", onHostsChanged);
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShellHosts(list, loaded);
|
||||||
|
}, [list, loaded]);
|
||||||
|
|
||||||
|
return { list, listRef, tree, loading, loaded, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { EditingWidth } from "@/components/surface/surface-scope";
|
||||||
|
import { remScale } from "@/lib/rem";
|
||||||
|
import { rightDockableIds } from "@/sidebar/rail-items";
|
||||||
|
import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel";
|
||||||
|
import type { ShellRegions } from "./types";
|
||||||
|
|
||||||
|
const MIN_WIDTH = 160;
|
||||||
|
const MAX_WIDTH = 480;
|
||||||
|
|
||||||
|
function clampWidth(width: number): number {
|
||||||
|
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A drag on a dock edge, one width update per frame. `sign` is -1 for the right dock. */
|
||||||
|
function startDockDrag(
|
||||||
|
e: React.MouseEvent,
|
||||||
|
startW: number,
|
||||||
|
sign: 1 | -1,
|
||||||
|
setWidth: (width: number) => void,
|
||||||
|
setDragging: (dragging: boolean) => void,
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
const startX = e.clientX;
|
||||||
|
// Widths are kept in Normal-size pixels, so a drag is scaled back.
|
||||||
|
const scale = remScale();
|
||||||
|
let frame = 0;
|
||||||
|
let clientX = startX;
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
clientX = ev.clientX;
|
||||||
|
if (frame) return;
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = 0;
|
||||||
|
setWidth(clampWidth(startW + (sign * (clientX - startX)) / scale));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
setWidth(clampWidth(startW + (sign * (clientX - startX)) / scale));
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The left sidebar and the right dock: what they show, open state and size. */
|
||||||
|
export function useRegions(isMobile: boolean): ShellRegions {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [railView, setRailView] = useState<string>("hosts");
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
|
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||||
|
return saved ? parseInt(saved, 10) : 291;
|
||||||
|
});
|
||||||
|
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||||
|
// How far each panel wants the sidebar widened: what the panel says itself,
|
||||||
|
// and what its open inline views say.
|
||||||
|
const [panelEditing, setPanelEditing] = useState<
|
||||||
|
Record<string, EditingWidth>
|
||||||
|
>({});
|
||||||
|
const [scopeEditing, setScopeEditing] = useState<
|
||||||
|
Record<string, EditingWidth>
|
||||||
|
>({});
|
||||||
|
const editingSetters = useRef(
|
||||||
|
new Map<string, (editing: EditingWidth) => void>(),
|
||||||
|
);
|
||||||
|
const panelEditingSetter = useCallback((view: string) => {
|
||||||
|
const key = `panel:${view}`;
|
||||||
|
if (!editingSetters.current.has(key))
|
||||||
|
editingSetters.current.set(key, (editing) =>
|
||||||
|
setPanelEditing((prev) =>
|
||||||
|
prev[view] === editing ? prev : { ...prev, [view]: editing },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return editingSetters.current.get(key)!;
|
||||||
|
}, []);
|
||||||
|
const scopeEditingSetter = useCallback((view: string) => {
|
||||||
|
const key = `scope:${view}`;
|
||||||
|
if (!editingSetters.current.has(key))
|
||||||
|
editingSetters.current.set(key, (editing) =>
|
||||||
|
setScopeEditing((prev) =>
|
||||||
|
prev[view] === editing ? prev : { ...prev, [view]: editing },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return editingSetters.current.get(key)!;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Right dock: a second panel column so reference panels like history can
|
||||||
|
// stay visible while the left sidebar is used for something else.
|
||||||
|
const [rightRailView, setRightRailView] = useState<string | null>(() => {
|
||||||
|
const saved = localStorage.getItem("termix_rightRailView");
|
||||||
|
return saved && rightDockableIds().includes(saved) ? saved : null;
|
||||||
|
});
|
||||||
|
const [rightSidebarWidth, setRightSidebarWidth] = useState(() => {
|
||||||
|
const saved = localStorage.getItem("termix_rightSidebarWidth");
|
||||||
|
return saved ? parseInt(saved, 10) : 291;
|
||||||
|
});
|
||||||
|
const [rightSidebarDragging, setRightSidebarDragging] = useState(false);
|
||||||
|
// Remembers the last panel shown in the dock so the tab bar toggle can bring
|
||||||
|
// it back instead of always falling back to the same default.
|
||||||
|
const lastRightRailViewRef = useRef<string | null>(
|
||||||
|
localStorage.getItem("termix_lastRightRailView"),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
||||||
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth));
|
||||||
|
}, [rightSidebarWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rightRailView) {
|
||||||
|
localStorage.setItem("termix_rightRailView", rightRailView);
|
||||||
|
lastRightRailViewRef.current = rightRailView;
|
||||||
|
localStorage.setItem("termix_lastRightRailView", rightRailView);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("termix_rightRailView");
|
||||||
|
}
|
||||||
|
}, [rightRailView]);
|
||||||
|
|
||||||
|
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
sidebarOpenBeforeMobile.current = sidebarOpen;
|
||||||
|
setSidebarOpen(false);
|
||||||
|
} else {
|
||||||
|
setSidebarOpen(sidebarOpenBeforeMobile.current);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
// The panel shown before the current one, for Ctrl+Shift+E.
|
||||||
|
const previousRailViewRef = useRef<string | null>(null);
|
||||||
|
const currentRailViewRef = useRef(railView);
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentRailViewRef.current !== railView) {
|
||||||
|
previousRailViewRef.current = currentRailViewRef.current;
|
||||||
|
currentRailViewRef.current = railView;
|
||||||
|
}
|
||||||
|
}, [railView]);
|
||||||
|
|
||||||
|
// Moving a panel to the right dock rather than copying it: two live copies of
|
||||||
|
// the same panel would fight over the shared editing state.
|
||||||
|
function openInRightDock(view: string) {
|
||||||
|
setRightRailView(view);
|
||||||
|
if (railView === view) setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab bar toggle: reopens whatever was last in the dock, so it behaves like a
|
||||||
|
// show/hide rather than losing the user's choice each time.
|
||||||
|
function toggleRightDock() {
|
||||||
|
if (rightRailView) {
|
||||||
|
lastRightRailViewRef.current = rightRailView;
|
||||||
|
setRightRailView(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
|
||||||
|
if (fallback) setRightRailView(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function railClick(view: string) {
|
||||||
|
if (railView === view && sidebarOpen) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
} else {
|
||||||
|
// A panel lives in one dock at a time, so the left dock reclaims it.
|
||||||
|
if (rightRailView === view) setRightRailView(null);
|
||||||
|
setRailView(view);
|
||||||
|
setSidebarOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLeftResize = useCallback(
|
||||||
|
(e: React.MouseEvent) =>
|
||||||
|
startDockDrag(e, sidebarWidth, 1, setSidebarWidth, setSidebarDragging),
|
||||||
|
[sidebarWidth],
|
||||||
|
);
|
||||||
|
// Same drag, mirrored: the right dock grows as the pointer moves left.
|
||||||
|
const startRightResize = useCallback(
|
||||||
|
(e: React.MouseEvent) =>
|
||||||
|
startDockDrag(
|
||||||
|
e,
|
||||||
|
rightSidebarWidth,
|
||||||
|
-1,
|
||||||
|
setRightSidebarWidth,
|
||||||
|
setRightSidebarDragging,
|
||||||
|
),
|
||||||
|
[rightSidebarWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leftEditing: EditingWidth = (() => {
|
||||||
|
const values = [panelEditing[railView], scopeEditing[railView]];
|
||||||
|
if (values.includes("wide")) return "wide";
|
||||||
|
return values.some(Boolean);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: {
|
||||||
|
view: railView,
|
||||||
|
open: sidebarOpen,
|
||||||
|
width: sidebarWidth,
|
||||||
|
dragging: sidebarDragging,
|
||||||
|
editing: leftEditing,
|
||||||
|
setView: setRailView,
|
||||||
|
setOpen: setSidebarOpen,
|
||||||
|
setWidth: setSidebarWidth,
|
||||||
|
startResize: startLeftResize,
|
||||||
|
resetWidth: () => setSidebarWidth(DOCK_DEFAULT_WIDTH),
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
view: rightRailView,
|
||||||
|
open: rightRailView !== null,
|
||||||
|
width: rightSidebarWidth,
|
||||||
|
dragging: rightSidebarDragging,
|
||||||
|
setView: setRightRailView,
|
||||||
|
setWidth: setRightSidebarWidth,
|
||||||
|
startResize: startRightResize,
|
||||||
|
resetWidth: () => setRightSidebarWidth(DOCK_DEFAULT_WIDTH),
|
||||||
|
},
|
||||||
|
railClick,
|
||||||
|
openInRightDock,
|
||||||
|
toggleRightDock,
|
||||||
|
previousView: () => previousRailViewRef.current,
|
||||||
|
panelEditingSetter,
|
||||||
|
scopeEditingSetter,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||||
|
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import {
|
||||||
|
dispatchCtrlW,
|
||||||
|
createCommandPaletteShortcutMatcher,
|
||||||
|
isShiftKey,
|
||||||
|
} from "@/lib/app-keyboard-shortcuts";
|
||||||
|
import { getUserPreferences } from "@/main-axios";
|
||||||
|
import { parseCustomKeybindings } from "@/api/open-tabs-api";
|
||||||
|
import { findMatchingKeybinding } from "@/lib/keybinding-match";
|
||||||
|
import type {
|
||||||
|
CustomKeybinding,
|
||||||
|
KeybindingActionType,
|
||||||
|
} from "@/types/keybindings";
|
||||||
|
import {
|
||||||
|
GLOBAL_KEYBINDING_EVENT,
|
||||||
|
getKeybindingAction,
|
||||||
|
runKeybindingAction,
|
||||||
|
} from "@/shell/keybinding-registry";
|
||||||
|
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||||
|
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
|
||||||
|
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
||||||
|
import type { TabsState } from "./use-tabs";
|
||||||
|
import type { SplitActions } from "./use-split";
|
||||||
|
import type { ShellPalette, ShellRegions } from "./types";
|
||||||
|
|
||||||
|
/** Every shell-wide shortcut: custom bindings, palette, panes and tabs. */
|
||||||
|
export function useShellKeybindings({
|
||||||
|
tabsState,
|
||||||
|
regions,
|
||||||
|
palette,
|
||||||
|
splitActionsRef,
|
||||||
|
toggleFullscreen,
|
||||||
|
}: {
|
||||||
|
tabsState: TabsState;
|
||||||
|
regions: ShellRegions;
|
||||||
|
palette: ShellPalette;
|
||||||
|
splitActionsRef: MutableRefObject<SplitActions | null>;
|
||||||
|
toggleFullscreen: () => void;
|
||||||
|
}) {
|
||||||
|
const { tabsRef, activeTabIdRef, setActiveTabId, closeActiveTabRef } =
|
||||||
|
tabsState;
|
||||||
|
const globalKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||||
|
const toggleFullscreenRef = useRef(toggleFullscreen);
|
||||||
|
toggleFullscreenRef.current = toggleFullscreen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = () => {
|
||||||
|
getUserPreferences()
|
||||||
|
.then((prefs) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
globalKeybindingsRef.current = parseCustomKeybindings(
|
||||||
|
prefs.customKeybindings,
|
||||||
|
).filter((binding) => binding.enabled);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
window.addEventListener("customKeybindingsChanged", load);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener("customKeybindingsChanged", load);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const runAction = (type: KeybindingActionType) => {
|
||||||
|
if (type === "openCommandPalette") {
|
||||||
|
palette.setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === "reconnectSession") {
|
||||||
|
const tabId = tabsState.focusedSessionTabId();
|
||||||
|
if (!tabId) return;
|
||||||
|
const termRef = tabsState.terminalRefs.current.get(tabId);
|
||||||
|
(termRef?.current as TabHandle | null)?.reconnect?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentTabs = tabsRef.current.filter(
|
||||||
|
(tab) => !tab.parentSplitTabId,
|
||||||
|
);
|
||||||
|
if (currentTabs.length < 2) return;
|
||||||
|
const index = currentTabs.findIndex(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
const offset = type === "nextTab" ? 1 : -1;
|
||||||
|
const next = (index + offset + currentTabs.length) % currentTabs.length;
|
||||||
|
setActiveTabId(currentTabs[next].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target.closest("[data-keybinding-recorder]")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// Only actions that run anywhere; a terminal's own are its to handle.
|
||||||
|
const binding = findMatchingKeybinding(
|
||||||
|
event,
|
||||||
|
globalKeybindingsRef.current.filter(
|
||||||
|
(entry) => getKeybindingAction(entry.action.type)?.scope === "global",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!binding) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
runKeybindingAction(binding.action);
|
||||||
|
};
|
||||||
|
const handleAction = (event: Event) =>
|
||||||
|
runAction(
|
||||||
|
(event as CustomEvent<{ type: KeybindingActionType }>).detail.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.electronAPI?.onCloseActiveTab?.(() => {
|
||||||
|
if (dispatchCtrlW(document.activeElement)) return;
|
||||||
|
closeActiveTabRef.current();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
||||||
|
useState<boolean>(() => {
|
||||||
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||||
|
return v !== null ? v === "true" : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Double-shift or Ctrl+K opens the command palette. Double-shift alone was
|
||||||
|
// hard to discover.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!commandPaletteShortcutEnabled) return;
|
||||||
|
const shortcut = createCommandPaletteShortcutMatcher();
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!shortcut.matches(e)) return;
|
||||||
|
if (!isShiftKey(e)) e.preventDefault();
|
||||||
|
palette.setOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("blur", shortcut.reset);
|
||||||
|
window.addEventListener("compositionstart", shortcut.reset);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("blur", shortcut.reset);
|
||||||
|
window.removeEventListener("compositionstart", shortcut.reset);
|
||||||
|
};
|
||||||
|
}, [commandPaletteShortcutEnabled]);
|
||||||
|
|
||||||
|
// Ctrl+Shift+E toggles between the two most recent sidebar panels.
|
||||||
|
const railView = regions.left.view;
|
||||||
|
const sidebarOpen = regions.left.open;
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const previous = regions.previousView();
|
||||||
|
if (!sidebarOpen) {
|
||||||
|
regions.left.setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previous && previous !== railView) regions.railClick(previous);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [railView, sidebarOpen]);
|
||||||
|
|
||||||
|
// Split-screen and tab navigation hotkeys
|
||||||
|
// Also registered in globalShortcutHandler so xterm can invoke directly
|
||||||
|
// without going through synthetic DOM events (which are unreliable).
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
if (e.code === "KeyF") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFullscreenRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter
|
||||||
|
// zooms the focused pane.
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
if (e.code === "Backslash" || e.code === "Minus") {
|
||||||
|
e.preventDefault();
|
||||||
|
splitActionsRef.current?.splitActive(
|
||||||
|
e.code === "Backslash" ? "right" : "bottom",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
|
||||||
|
const paneDirection = getPaneNavDirection(e);
|
||||||
|
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
||||||
|
const tabDigit = getTabJumpDigit(e);
|
||||||
|
if (tabDigit !== null) {
|
||||||
|
const currentTabs = tabsRef.current.filter(
|
||||||
|
(tab) => !tab.parentSplitTabId,
|
||||||
|
);
|
||||||
|
const index = tabDigit - 1;
|
||||||
|
if (index < currentTabs.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabId(currentTabs[index].id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+] / Ctrl+Shift+[: cycle through open tabs (] = next, [ = previous)
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
if (e.code === "BracketRight" || e.code === "BracketLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTabs = tabsRef.current.filter(
|
||||||
|
(tab) => !tab.parentSplitTabId,
|
||||||
|
);
|
||||||
|
if (currentTabs.length < 2) return;
|
||||||
|
const currentId = activeTabIdRef.current;
|
||||||
|
const idx = currentTabs.findIndex((t) => t.id === currentId);
|
||||||
|
const next =
|
||||||
|
e.code === "BracketRight"
|
||||||
|
? (idx + 1) % currentTabs.length
|
||||||
|
: (idx - 1 + currentTabs.length) % currentTabs.length;
|
||||||
|
setActiveTabId(currentTabs[next].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
globalShortcutHandler.current = handleKeyDown;
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
globalShortcutHandler.current = null;
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||||
|
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
|
||||||
|
};
|
||||||
|
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
"commandPaletteShortcutEnabledChanged",
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import { useCallback, useEffect, useState, type MutableRefObject } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
||||||
|
import { resetPermissionsCache } from "@/hooks/use-permissions";
|
||||||
|
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import {
|
||||||
|
getUserInfo,
|
||||||
|
getUserPreferences,
|
||||||
|
saveUserPreferences,
|
||||||
|
type UserPreferences,
|
||||||
|
} from "@/main-axios";
|
||||||
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||||
|
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
|
||||||
|
import {
|
||||||
|
MANAGE_REQUEST_EVENT,
|
||||||
|
requestFromLegacyEvent,
|
||||||
|
requestManage,
|
||||||
|
} from "@/manage/manage-requests";
|
||||||
|
import type { TabType } from "@/types/ui-types";
|
||||||
|
|
||||||
|
/** Keys a cloud-synced profile overwrites, snapshotted first so they can come back. */
|
||||||
|
const SNAPSHOT_KEYS = [
|
||||||
|
"termix-accent",
|
||||||
|
"termix-font-size",
|
||||||
|
"termix-ui-font",
|
||||||
|
"i18nextLng",
|
||||||
|
"commandPaletteShortcutEnabled",
|
||||||
|
"showHostTags",
|
||||||
|
"hostTrayOnClick",
|
||||||
|
"pinAppRail",
|
||||||
|
"expandAppRailOnHover",
|
||||||
|
"disableUpdateCheck",
|
||||||
|
"confirmTabClose",
|
||||||
|
"hiddenRailTabs",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Applies a cloud-synced profile to this browser. */
|
||||||
|
function applyCloudPreferences(
|
||||||
|
prefs: UserPreferences,
|
||||||
|
setTheme: (theme: ThemeId) => void,
|
||||||
|
currentLanguage: string,
|
||||||
|
loginLanguage: string | null,
|
||||||
|
) {
|
||||||
|
// Persist the current browser values before overwriting, so any tab can restore them
|
||||||
|
if (!localStorage.getItem("termix-local-snapshot")) {
|
||||||
|
const snap: Record<string, string | null> = {
|
||||||
|
__theme: localStorage.getItem("termix-theme"),
|
||||||
|
};
|
||||||
|
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
|
||||||
|
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
|
||||||
|
}
|
||||||
|
// The account's own pick wins over the older profile field.
|
||||||
|
const cloudTheme = prefs.themeId ?? prefs.theme;
|
||||||
|
if (cloudTheme) setTheme(cloudTheme as ThemeId);
|
||||||
|
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
|
||||||
|
if (prefs.accentColor) {
|
||||||
|
localStorage.setItem("termix-accent", prefs.accentColor);
|
||||||
|
applyAccentColor(prefs.accentColor);
|
||||||
|
}
|
||||||
|
const preferredLanguage = loginLanguage ?? prefs.language;
|
||||||
|
if (preferredLanguage && preferredLanguage !== currentLanguage) {
|
||||||
|
void changeAppLanguage(preferredLanguage);
|
||||||
|
}
|
||||||
|
if (loginLanguage && loginLanguage !== prefs.language) {
|
||||||
|
void saveUserPreferences({ language: loginLanguage });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
prefs.commandPaletteEnabled !== null &&
|
||||||
|
prefs.commandPaletteEnabled !== undefined
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"commandPaletteShortcutEnabled",
|
||||||
|
String(prefs.commandPaletteEnabled),
|
||||||
|
);
|
||||||
|
if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) {
|
||||||
|
localStorage.setItem("showHostTags", String(prefs.showHostTags));
|
||||||
|
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||||
|
}
|
||||||
|
if (prefs.hostTrayOnClick !== null && prefs.hostTrayOnClick !== undefined)
|
||||||
|
localStorage.setItem("hostTrayOnClick", String(prefs.hostTrayOnClick));
|
||||||
|
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) {
|
||||||
|
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
||||||
|
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
prefs.expandAppRailOnHover !== null &&
|
||||||
|
prefs.expandAppRailOnHover !== undefined
|
||||||
|
) {
|
||||||
|
localStorage.setItem(
|
||||||
|
"expandAppRailOnHover",
|
||||||
|
String(prefs.expandAppRailOnHover),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
prefs.disableUpdateCheck !== null &&
|
||||||
|
prefs.disableUpdateCheck !== undefined
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"disableUpdateCheck",
|
||||||
|
String(prefs.disableUpdateCheck),
|
||||||
|
);
|
||||||
|
if (prefs.confirmTabClose !== null && prefs.confirmTabClose !== undefined)
|
||||||
|
localStorage.setItem("confirmTabClose", String(prefs.confirmTabClose));
|
||||||
|
if (prefs.hiddenRailTabs !== null && prefs.hiddenRailTabs !== undefined) {
|
||||||
|
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
||||||
|
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who is signed in and what the shell does around the tabs: prefs, health, logout. */
|
||||||
|
export function useShellLifecycle({
|
||||||
|
onLogout,
|
||||||
|
openSingletonTabRef,
|
||||||
|
}: {
|
||||||
|
onLogout: (options?: { manual?: boolean }) => void;
|
||||||
|
openSingletonTabRef: MutableRefObject<(type: TabType) => string | undefined>;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
const [userPrefs, setUserPrefs] = useState<UserPreferences>({
|
||||||
|
reopenTabsOnLogin: false,
|
||||||
|
});
|
||||||
|
const [userPrefsLoaded, setUserPrefsLoaded] = useState(false);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [isAppFullscreen, setIsAppFullscreen] = useState(
|
||||||
|
() => !!document.fullscreenElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anything that asks to add or edit a host, a credential or host defaults
|
||||||
|
// ends up in the Manage tab, whichever event it still speaks.
|
||||||
|
useEffect(() => {
|
||||||
|
const names = [
|
||||||
|
"host-manager:add-host",
|
||||||
|
"host-manager:edit-host",
|
||||||
|
"host-manager:add-credential",
|
||||||
|
"host-manager:show-credentials",
|
||||||
|
"host-manager:edit-defaults",
|
||||||
|
"termix:open-host-defaults",
|
||||||
|
];
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail);
|
||||||
|
if (request) requestManage(request);
|
||||||
|
};
|
||||||
|
const open = () => openSingletonTabRef.current("host-manager");
|
||||||
|
for (const name of names) window.addEventListener(name, handler);
|
||||||
|
window.addEventListener(MANAGE_REQUEST_EVENT, open);
|
||||||
|
return () => {
|
||||||
|
for (const name of names) window.removeEventListener(name, handler);
|
||||||
|
window.removeEventListener(MANAGE_REQUEST_EVENT, open);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserInfo()
|
||||||
|
.then((info) => setIsAdmin(info.is_admin))
|
||||||
|
.catch(() => setIsAdmin(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAppFullscreen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.fullscreenEnabled) {
|
||||||
|
toast.error(t("nav.fullscreenUnsupported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("nav.fullscreenFailed"));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsAppFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = (event: Event) => {
|
||||||
|
const manual =
|
||||||
|
event instanceof CustomEvent &&
|
||||||
|
(event.detail as { manual?: boolean } | undefined)?.manual === true;
|
||||||
|
// Drop the cached grants so the next user never inherits them.
|
||||||
|
resetPermissionsCache();
|
||||||
|
onLogout(manual ? { manual: true } : undefined);
|
||||||
|
};
|
||||||
|
window.addEventListener("termix:logout", handle);
|
||||||
|
return () => window.removeEventListener("termix:logout", handle);
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSessionExpired = () => onLogout();
|
||||||
|
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||||
|
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDegraded = () => {
|
||||||
|
toast.loading(t("common.connectionDegraded"), {
|
||||||
|
id: "db-connection-degraded",
|
||||||
|
duration: Infinity,
|
||||||
|
dismissible: false,
|
||||||
|
action: {
|
||||||
|
label: t("common.reload"),
|
||||||
|
onClick: () => window.location.reload(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestored = () => {
|
||||||
|
toast.dismiss("db-connection-degraded");
|
||||||
|
toast.success(t("common.backendReconnected"), { duration: 3000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
dbHealthMonitor.on("database-connection-degraded", handleDegraded);
|
||||||
|
dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dbHealthMonitor.off("database-connection-degraded", handleDegraded);
|
||||||
|
dbHealthMonitor.off(
|
||||||
|
"database-connection-degraded-cleared",
|
||||||
|
handleRestored,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserPreferences()
|
||||||
|
.then((prefs) => {
|
||||||
|
const loginLanguage = consumeLoginLanguage();
|
||||||
|
setUserPrefs(prefs);
|
||||||
|
if (prefs.storageMode === "cloud") {
|
||||||
|
applyCloudPreferences(prefs, setTheme, i18n.language, loginLanguage);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setUserPrefsLoaded(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
userPrefs,
|
||||||
|
setUserPrefs,
|
||||||
|
userPrefsLoaded,
|
||||||
|
isAppFullscreen,
|
||||||
|
toggleAppFullscreen,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react";
|
||||||
|
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import type { Host } from "@/types/ui-types";
|
||||||
|
import { createId } from "@/lib/create-id";
|
||||||
|
import { registerPaletteEntry } from "@/shell/palette-registry";
|
||||||
|
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||||
|
import { runHostAction } from "@/sidebar/host-contributions";
|
||||||
|
import type { SplitViewActions } from "@/shell/split/SplitView";
|
||||||
|
import type { PickHostTarget } from "@/shell/split/EmptyPanePicker";
|
||||||
|
import {
|
||||||
|
addSplitTab,
|
||||||
|
canJoinSplit,
|
||||||
|
closeSplitTab,
|
||||||
|
isSplitTab,
|
||||||
|
makeSplitTab,
|
||||||
|
nextSplitNumber,
|
||||||
|
placeTabInPane,
|
||||||
|
splitTabOf,
|
||||||
|
summarizeSplits,
|
||||||
|
updateSplit,
|
||||||
|
type SplitTab,
|
||||||
|
type TabSplitAction,
|
||||||
|
} from "@/shell/split/split-tabs";
|
||||||
|
import {
|
||||||
|
addPaneAtEdge,
|
||||||
|
assignTab,
|
||||||
|
canAddPane,
|
||||||
|
createPane,
|
||||||
|
createSplitNode,
|
||||||
|
createSplitState,
|
||||||
|
equalizeSplit,
|
||||||
|
findPane,
|
||||||
|
findPaneByTab,
|
||||||
|
focusPane,
|
||||||
|
listPanes,
|
||||||
|
MAX_PANES,
|
||||||
|
movePane,
|
||||||
|
neighborPane,
|
||||||
|
removePane,
|
||||||
|
resizeSplit,
|
||||||
|
shownPanes,
|
||||||
|
splitPane,
|
||||||
|
swapPanes,
|
||||||
|
toggleZoom,
|
||||||
|
type NavDirection,
|
||||||
|
type PaneEdge,
|
||||||
|
type PaneRect,
|
||||||
|
type SplitState,
|
||||||
|
} from "@/shell/split/split-tree";
|
||||||
|
import {
|
||||||
|
applyPreset,
|
||||||
|
buildPreset,
|
||||||
|
type SplitPresetId,
|
||||||
|
} from "@/shell/split/split-presets";
|
||||||
|
import {
|
||||||
|
setSplitDropHandler,
|
||||||
|
type SplitDragSource,
|
||||||
|
type SplitDropHover,
|
||||||
|
} from "@/shell/split/split-drag";
|
||||||
|
import {
|
||||||
|
publishSplitTargets,
|
||||||
|
setSplitOpener,
|
||||||
|
type SplitOpenTarget,
|
||||||
|
} from "@/shell/split/split-targets";
|
||||||
|
import { useTabTypes } from "@/shell/tab-registry";
|
||||||
|
import { useSplitLabel, type TabsState } from "./use-tabs";
|
||||||
|
import type { ShellSplit } from "./types";
|
||||||
|
|
||||||
|
export interface SplitActions {
|
||||||
|
splitActive: (edge: "right" | "bottom") => void;
|
||||||
|
navigate: (direction: NavDirection) => boolean;
|
||||||
|
zoomFocused: () => boolean;
|
||||||
|
closeFocusedPane: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSplit({
|
||||||
|
tabsState,
|
||||||
|
isMobile,
|
||||||
|
paneEls,
|
||||||
|
callbacks,
|
||||||
|
resizeAllTerminals,
|
||||||
|
}: {
|
||||||
|
tabsState: TabsState;
|
||||||
|
isMobile: boolean;
|
||||||
|
paneEls: Map<string, HTMLDivElement>;
|
||||||
|
callbacks: TabShellCallbacks;
|
||||||
|
resizeAllTerminals: () => number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const splitLabel = useSplitLabel();
|
||||||
|
const tabTypes = useTabTypes();
|
||||||
|
const {
|
||||||
|
tabs,
|
||||||
|
setTabs,
|
||||||
|
tabsReady,
|
||||||
|
activeTabId,
|
||||||
|
setActiveTabId,
|
||||||
|
tabsRef,
|
||||||
|
activeTabIdRef,
|
||||||
|
targetPane,
|
||||||
|
} = tabsState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a split with a change computed from the last committed state, for
|
||||||
|
* edits that create panes and so have to know the new pane's id.
|
||||||
|
*/
|
||||||
|
function commitSplit(
|
||||||
|
splitTabId: string,
|
||||||
|
fn: (state: SplitState) => SplitState | null,
|
||||||
|
): SplitState | null {
|
||||||
|
const current = tabsRef.current.find((tab) => tab.id === splitTabId);
|
||||||
|
if (!isSplitTab(current)) return null;
|
||||||
|
const next = fn(current.split);
|
||||||
|
setTabs((prev) =>
|
||||||
|
updateSplit(prev, splitTabId, (state) =>
|
||||||
|
state === current.split ? next : fn(state),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSplit(
|
||||||
|
splitTabId: string,
|
||||||
|
fn: (state: SplitState) => SplitState | null,
|
||||||
|
) {
|
||||||
|
setTabs((prev) => updateSplit(prev, splitTabId, fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnSplitFull() {
|
||||||
|
toast.error(t("splitScreen.maxPanes", { count: MAX_PANES }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSplitTab(state: SplitState): SplitTab {
|
||||||
|
const number = nextSplitNumber(tabsRef.current, splitLabel);
|
||||||
|
const splitTab = makeSplitTab(createId(), splitLabel(number), state);
|
||||||
|
setTabs((prev) => addSplitTab(prev, splitTab));
|
||||||
|
setActiveTabId(splitTab.id);
|
||||||
|
return splitTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */
|
||||||
|
function startSplit(tabId: string | null, edge: PaneEdge): SplitTab {
|
||||||
|
const own = createPane(tabId);
|
||||||
|
const fresh = createPane();
|
||||||
|
const leading = edge === "left" || edge === "top";
|
||||||
|
const root = createSplitNode(
|
||||||
|
edge === "left" || edge === "right" ? "row" : "column",
|
||||||
|
leading ? [fresh, own] : [own, fresh],
|
||||||
|
);
|
||||||
|
return openSplitTab(createSplitState(root, fresh.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) {
|
||||||
|
commitSplit(splitTabId, (state) => {
|
||||||
|
const result = splitPane(state, paneId, edge);
|
||||||
|
if (!result) warnSplitFull();
|
||||||
|
return result ? result.state : state;
|
||||||
|
});
|
||||||
|
setActiveTabId(splitTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a tab: a split grows at its focused pane, a tab inside a split
|
||||||
|
* grows at its own pane, anything else becomes a new split.
|
||||||
|
*/
|
||||||
|
function splitTabAt(tabId: string, edge: PaneEdge) {
|
||||||
|
const current = tabsRef.current;
|
||||||
|
const tab = current.find((t) => t.id === tabId);
|
||||||
|
if (!tab) return;
|
||||||
|
if (isSplitTab(tab)) {
|
||||||
|
splitPaneOf(tab.id, tab.split.focusedPaneId, edge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = splitTabOf(current, tabId);
|
||||||
|
const pane = parent ? findPaneByTab(parent.split, tabId) : undefined;
|
||||||
|
if (parent && pane) {
|
||||||
|
splitPaneOf(parent.id, pane.id, edge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startSplit(canJoinSplit(tab) ? tab.id : null, edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayoutPreset(presetId: SplitPresetId) {
|
||||||
|
const active = tabsRef.current.find(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
if (isSplitTab(active)) {
|
||||||
|
commitSplit(active.id, (state) => applyPreset(state, presetId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = buildPreset(
|
||||||
|
presetId,
|
||||||
|
active && canJoinSplit(active) ? [active.id] : [],
|
||||||
|
);
|
||||||
|
openSplitTab(createSplitState(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes a split tab, sending its tabs back to the tab bar. */
|
||||||
|
function unsplit(splitTabId: string) {
|
||||||
|
const split = tabsRef.current.find((tab) => tab.id === splitTabId);
|
||||||
|
if (!isSplitTab(split)) return;
|
||||||
|
const focusedTabId =
|
||||||
|
findPane(split.split, split.split.focusedPaneId)?.tabId ??
|
||||||
|
listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ??
|
||||||
|
null;
|
||||||
|
setTabs((prev) => closeSplitTab(prev, splitTabId));
|
||||||
|
if (activeTabIdRef.current === splitTabId) {
|
||||||
|
const fallback = tabsRef.current.filter(
|
||||||
|
(tab) => tab.id !== splitTabId && !tab.parentSplitTabId,
|
||||||
|
);
|
||||||
|
setActiveTabId(
|
||||||
|
focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a pane; its tab, if any, goes back to the tab bar. */
|
||||||
|
function closePaneOf(splitTabId: string, paneId: string) {
|
||||||
|
const split = tabsRef.current.find((tab) => tab.id === splitTabId);
|
||||||
|
if (!isSplitTab(split)) return;
|
||||||
|
if (listPanes(split.split.root).length <= 1) {
|
||||||
|
unsplit(splitTabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editSplit(splitTabId, (state) => removePane(state, paneId));
|
||||||
|
}
|
||||||
|
|
||||||
|
tabsState.splitOpsRef.current = { unsplit, closePaneOf };
|
||||||
|
|
||||||
|
function handleTabSplitAction(action: TabSplitAction) {
|
||||||
|
switch (action.kind) {
|
||||||
|
case "split":
|
||||||
|
splitTabAt(action.tabId, action.edge);
|
||||||
|
break;
|
||||||
|
case "splitActive":
|
||||||
|
splitTabAt(activeTabIdRef.current, action.edge);
|
||||||
|
break;
|
||||||
|
case "preset":
|
||||||
|
applyLayoutPreset(action.presetId);
|
||||||
|
break;
|
||||||
|
case "unsplit":
|
||||||
|
unsplit(action.splitTabId);
|
||||||
|
break;
|
||||||
|
case "addToPane":
|
||||||
|
setTabs((prev) =>
|
||||||
|
placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId),
|
||||||
|
);
|
||||||
|
setActiveTabId(action.splitTabId);
|
||||||
|
break;
|
||||||
|
case "addPane":
|
||||||
|
commitSplit(action.splitTabId, (state) => {
|
||||||
|
const result = addPaneAtEdge(state, action.edge, action.tabId);
|
||||||
|
if (!result) warnSplitFull();
|
||||||
|
return result ? result.state : state;
|
||||||
|
});
|
||||||
|
setActiveTabId(action.splitTabId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Open in split" from the host list: aim the next tab, then open it. */
|
||||||
|
const splitOpenerRef = useRef<
|
||||||
|
(target: SplitOpenTarget, open: () => void) => void
|
||||||
|
>(() => {});
|
||||||
|
splitOpenerRef.current = (target, open) => {
|
||||||
|
if (target.kind === "pane") {
|
||||||
|
targetPane(target.splitTabId, target.paneId);
|
||||||
|
} else if (target.kind === "newPane") {
|
||||||
|
const split = tabsRef.current.find((tab) => tab.id === target.splitTabId);
|
||||||
|
if (!isSplitTab(split)) return;
|
||||||
|
const result = addPaneAtEdge(split.split, "right");
|
||||||
|
if (!result) {
|
||||||
|
warnSplitFull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editSplit(split.id, (state) =>
|
||||||
|
state === split.split ? result.state : state,
|
||||||
|
);
|
||||||
|
setActiveTabId(split.id);
|
||||||
|
targetPane(split.id, result.paneId, true);
|
||||||
|
} else {
|
||||||
|
const active = tabsRef.current.find(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
if (!active || !canJoinSplit(active)) return;
|
||||||
|
const splitTab = startSplit(active.id, "right");
|
||||||
|
targetPane(splitTab.id, splitTab.split.focusedPaneId, true);
|
||||||
|
}
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A tab or pane dropped on the main area. */
|
||||||
|
const splitDropRef = useRef<
|
||||||
|
(source: SplitDragSource, hover: SplitDropHover) => void
|
||||||
|
>(() => {});
|
||||||
|
splitDropRef.current = (source, hover) => {
|
||||||
|
if (source.kind === "pane") {
|
||||||
|
const toPaneId = hover.paneId;
|
||||||
|
if (!toPaneId) return;
|
||||||
|
editSplit(source.splitTabId, (state) =>
|
||||||
|
movePane(state, source.paneId, toPaneId, hover.target),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = tabsRef.current.find(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
if (hover.paneId === null) {
|
||||||
|
if (hover.target === "center") return;
|
||||||
|
const other =
|
||||||
|
active && canJoinSplit(active) && active.id !== source.tabId
|
||||||
|
? active.id
|
||||||
|
: null;
|
||||||
|
const dragged = createPane(source.tabId);
|
||||||
|
const rest = createPane(other);
|
||||||
|
const leading = hover.target === "left" || hover.target === "top";
|
||||||
|
const root = createSplitNode(
|
||||||
|
hover.target === "left" || hover.target === "right" ? "row" : "column",
|
||||||
|
leading ? [dragged, rest] : [rest, dragged],
|
||||||
|
);
|
||||||
|
openSplitTab(createSplitState(root, dragged.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSplitTab(active)) return;
|
||||||
|
const paneId = hover.paneId;
|
||||||
|
if (hover.target === "center") {
|
||||||
|
setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const edge = hover.target;
|
||||||
|
commitSplit(active.id, (state) => {
|
||||||
|
const result = splitPane(state, paneId, edge, source.tabId);
|
||||||
|
if (!result) warnSplitFull();
|
||||||
|
return result ? result.state : state;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split actions for handlers registered once, e.g. the global hotkeys.
|
||||||
|
const splitActionsRef = useRef<SplitActions | null>(null);
|
||||||
|
splitActionsRef.current = {
|
||||||
|
splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge),
|
||||||
|
navigate: (direction) => {
|
||||||
|
const active = tabsRef.current.find(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
if (!isSplitTab(active)) return false;
|
||||||
|
const view = document.querySelector(`[data-split-view="${active.id}"]`);
|
||||||
|
if (!view) return false;
|
||||||
|
const rects: Record<string, PaneRect> = {};
|
||||||
|
view
|
||||||
|
.querySelectorAll<HTMLElement>("[data-split-pane-id]")
|
||||||
|
.forEach((el) => {
|
||||||
|
const id = el.dataset.splitPaneId;
|
||||||
|
if (id) rects[id] = el.getBoundingClientRect();
|
||||||
|
});
|
||||||
|
const next = neighborPane(active.split.focusedPaneId, direction, rects);
|
||||||
|
if (next) editSplit(active.id, (state) => focusPane(state, next));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
zoomFocused: () => {
|
||||||
|
const active = tabsRef.current.find(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
if (!isSplitTab(active) || listPanes(active.split.root).length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
closeFocusedPane: () => {
|
||||||
|
const active = tabsRef.current.find(
|
||||||
|
(tab) => tab.id === activeTabIdRef.current,
|
||||||
|
);
|
||||||
|
if (isSplitTab(active)) {
|
||||||
|
closePaneOf(active.id, active.split.focusedPaneId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const disposeOpener = setSplitOpener((target, open) =>
|
||||||
|
splitOpenerRef.current(target, open),
|
||||||
|
);
|
||||||
|
const disposeDrop = setSplitDropHandler((source, hover) =>
|
||||||
|
splitDropRef.current(source, hover),
|
||||||
|
);
|
||||||
|
const activeIsSplit = () =>
|
||||||
|
isSplitTab(
|
||||||
|
tabsRef.current.find((tab) => tab.id === activeTabIdRef.current),
|
||||||
|
);
|
||||||
|
const entries = [
|
||||||
|
registerPaletteEntry({
|
||||||
|
id: "core.reconnectDisconnected",
|
||||||
|
titleKey: "nav.reconnectDisconnectedTerminals",
|
||||||
|
icon: RotateCcw,
|
||||||
|
keywords: ["reconnect", "disconnected", "ssh", "all", "network"],
|
||||||
|
scope: "global",
|
||||||
|
run: () => tabsState.reconnectAllRef.current(),
|
||||||
|
}),
|
||||||
|
registerPaletteEntry({
|
||||||
|
id: "core.split.right",
|
||||||
|
titleKey: "splitScreen.splitRight",
|
||||||
|
icon: Columns2,
|
||||||
|
keywords: ["split", "pane"],
|
||||||
|
scope: "global",
|
||||||
|
run: () => splitActionsRef.current?.splitActive("right"),
|
||||||
|
}),
|
||||||
|
registerPaletteEntry({
|
||||||
|
id: "core.split.down",
|
||||||
|
titleKey: "splitScreen.splitDown",
|
||||||
|
icon: Rows2,
|
||||||
|
keywords: ["split", "pane"],
|
||||||
|
scope: "global",
|
||||||
|
run: () => splitActionsRef.current?.splitActive("bottom"),
|
||||||
|
}),
|
||||||
|
registerPaletteEntry({
|
||||||
|
id: "core.split.zoom",
|
||||||
|
titleKey: "splitScreen.zoom",
|
||||||
|
icon: Maximize2,
|
||||||
|
keywords: ["split", "pane"],
|
||||||
|
scope: "global",
|
||||||
|
when: activeIsSplit,
|
||||||
|
run: () => void splitActionsRef.current?.zoomFocused(),
|
||||||
|
}),
|
||||||
|
registerPaletteEntry({
|
||||||
|
id: "core.split.closePane",
|
||||||
|
titleKey: "splitScreen.closePane",
|
||||||
|
icon: X,
|
||||||
|
keywords: ["split", "pane"],
|
||||||
|
scope: "global",
|
||||||
|
when: activeIsSplit,
|
||||||
|
run: () => splitActionsRef.current?.closeFocusedPane(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
disposeOpener();
|
||||||
|
disposeDrop();
|
||||||
|
for (const dispose of entries) dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||||
|
const activeSplit = isSplitTab(activeTab) ? activeTab : null;
|
||||||
|
// The pane each on-screen tab sits in, for the active split only.
|
||||||
|
const paneIdByTabId = new Map<string, string>();
|
||||||
|
if (activeSplit) {
|
||||||
|
for (const pane of shownPanes(activeSplit.split, isMobile)) {
|
||||||
|
if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Changes when panes appear, move or change tab, but not on a divider drag,
|
||||||
|
// which fits the terminals itself once it ends.
|
||||||
|
const splitShapeKey = activeSplit
|
||||||
|
? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[
|
||||||
|
...paneIdByTabId.entries(),
|
||||||
|
].join(",")}|${listPanes(activeSplit.split.root).length}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// A tab inside a split is never shown on its own: activating one (tab jump,
|
||||||
|
// the connections panel, an existing session) shows its split instead,
|
||||||
|
// focused on its pane. Also falls back when the active tab is gone.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!activeTab) {
|
||||||
|
if (!tabsReady) return;
|
||||||
|
const topLevel = tabs.filter((tab) => !tab.parentSplitTabId);
|
||||||
|
setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!activeTab.parentSplitTabId) return;
|
||||||
|
const parent = splitTabOf(tabs, activeTab.id);
|
||||||
|
if (!parent) return;
|
||||||
|
const childId = activeTab.id;
|
||||||
|
setTabs((prev) =>
|
||||||
|
updateSplit(prev, parent.id, (state) => {
|
||||||
|
const pane = findPaneByTab(state, childId);
|
||||||
|
return pane ? focusPane(state, pane.id) : state;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setActiveTabId(parent.id);
|
||||||
|
}, [activeTab, tabs, tabsReady]);
|
||||||
|
|
||||||
|
// Moving focus to a pane moves keyboard focus into its session, unless the
|
||||||
|
// user is in a menu or already typing in that pane.
|
||||||
|
const focusedPaneId = activeSplit?.split.focusedPaneId ?? null;
|
||||||
|
const focusedPaneTabId = focusedPaneId
|
||||||
|
? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null)
|
||||||
|
: null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedPaneId || !focusedPaneTabId) return;
|
||||||
|
let attempts = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const attempt = () => {
|
||||||
|
const focused = document.activeElement as HTMLElement | null;
|
||||||
|
const paneEl = paneEls.get(focusedPaneId);
|
||||||
|
if (focused && paneEl?.contains(focused)) return;
|
||||||
|
if (
|
||||||
|
focused?.closest?.(
|
||||||
|
'[data-split-pane-header], [role="menu"], [role="dialog"]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId);
|
||||||
|
const handle = tab?.terminalRef?.current as TabHandle | null | undefined;
|
||||||
|
if (handle?.focus) {
|
||||||
|
handle.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A session opened into the pane a moment ago may not have mounted yet.
|
||||||
|
if (attempts++ < 10) timer = setTimeout(attempt, 100);
|
||||||
|
};
|
||||||
|
const frame = requestAnimationFrame(attempt);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [focusedPaneId, focusedPaneTabId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
publishSplitTargets({
|
||||||
|
splits: summarizeSplits(tabs, MAX_PANES),
|
||||||
|
canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId,
|
||||||
|
});
|
||||||
|
}, [tabs, activeTab]);
|
||||||
|
|
||||||
|
function splitViewActions(splitTabId: string): SplitViewActions {
|
||||||
|
return {
|
||||||
|
focusPane: (paneId) =>
|
||||||
|
editSplit(splitTabId, (state) => focusPane(state, paneId)),
|
||||||
|
resize: (nodeId, sizes) =>
|
||||||
|
editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)),
|
||||||
|
equalize: (nodeId) =>
|
||||||
|
editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)),
|
||||||
|
splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge),
|
||||||
|
closePane: (paneId) => closePaneOf(splitTabId, paneId),
|
||||||
|
closeSession: (tabId) => tabsState.closeTab(tabId),
|
||||||
|
moveToTab: (paneId) => closePaneOf(splitTabId, paneId),
|
||||||
|
showInPane: (paneId, tabId) => {
|
||||||
|
if (tabId) {
|
||||||
|
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId));
|
||||||
|
} else {
|
||||||
|
editSplit(splitTabId, (state) => assignTab(state, paneId, null));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
swapPanes: (firstId, secondId) =>
|
||||||
|
editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)),
|
||||||
|
toggleZoom: (paneId) =>
|
||||||
|
editSplit(splitTabId, (state) => toggleZoom(state, paneId)),
|
||||||
|
resizeEnd: () => resizeAllTerminals(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picking a host for a pane always starts a new session, so it never pulls
|
||||||
|
// a session out of the tab bar or another split.
|
||||||
|
const pickerShell: TabShellCallbacks = {
|
||||||
|
...callbacks,
|
||||||
|
openTab: (host, type, options) =>
|
||||||
|
callbacks.openTab(host, type, { ...options, forceNewTab: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const split: ShellSplit = {
|
||||||
|
active: activeSplit,
|
||||||
|
paneIdByTabId,
|
||||||
|
focusedPaneId,
|
||||||
|
summaries: summarizeSplits(tabs, MAX_PANES),
|
||||||
|
activeFull: activeSplit ? !canAddPane(activeSplit.split) : false,
|
||||||
|
handleAction: handleTabSplitAction,
|
||||||
|
viewActions: splitViewActions,
|
||||||
|
emptyPane: {
|
||||||
|
freeTabs: tabs.filter(
|
||||||
|
(tab) => !tab.parentSplitTabId && canJoinSplit(tab),
|
||||||
|
),
|
||||||
|
newTabOptions: tabTypes
|
||||||
|
.filter((def) => def.multiInstance)
|
||||||
|
.map((def) => ({
|
||||||
|
type: def.id,
|
||||||
|
label: def.titleKey ? t(def.titleKey) : def.id,
|
||||||
|
icon: def.icon,
|
||||||
|
})),
|
||||||
|
pickTab: (splitTabId, paneId, tabId) =>
|
||||||
|
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)),
|
||||||
|
pickHost: (splitTabId, paneId, host: Host, pick: PickHostTarget) => {
|
||||||
|
targetPane(splitTabId, paneId);
|
||||||
|
if ("item" in pick) {
|
||||||
|
const item = pick.action
|
||||||
|
.items?.(host)
|
||||||
|
.find((candidate) => candidate.id === pick.item.id);
|
||||||
|
item?.run(host, pickerShell);
|
||||||
|
} else {
|
||||||
|
runHostAction(pick.action, host, pickerShell);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openType: (splitTabId, paneId, type) => {
|
||||||
|
targetPane(splitTabId, paneId);
|
||||||
|
tabsState.openMultiInstanceTab(type);
|
||||||
|
},
|
||||||
|
closePane: closePaneOf,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
split,
|
||||||
|
splitShapeKey,
|
||||||
|
splitActionsRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import {
|
||||||
|
createRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type MutableRefObject,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import type { ConfirmOptions } from "@/components/surface/surface-scope";
|
||||||
|
import type { Host, Tab, TabType } from "@/types/ui-types";
|
||||||
|
import {
|
||||||
|
addOpenTab,
|
||||||
|
createSSHHost,
|
||||||
|
deleteOpenTab,
|
||||||
|
getActiveSessions,
|
||||||
|
getOpenTabs,
|
||||||
|
patchOpenTab,
|
||||||
|
type OpenTabRecord,
|
||||||
|
type UserPreferences,
|
||||||
|
} from "@/main-axios";
|
||||||
|
import { createId } from "@/lib/create-id";
|
||||||
|
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
||||||
|
import { railItemLabel } from "@/sidebar/rail-items";
|
||||||
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||||
|
import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
|
||||||
|
import { canCloseTab } from "@/shell/tab-close-guards";
|
||||||
|
import {
|
||||||
|
canRestoreTabType,
|
||||||
|
getTabType,
|
||||||
|
isPersistentTabType,
|
||||||
|
isSessionTabType,
|
||||||
|
} from "@/shell/tab-registry";
|
||||||
|
import {
|
||||||
|
requestFromLegacyEvent,
|
||||||
|
requestManage,
|
||||||
|
} from "@/manage/manage-requests";
|
||||||
|
import {
|
||||||
|
addSplitTab,
|
||||||
|
canJoinSplit,
|
||||||
|
isSplitTab,
|
||||||
|
makeSplitTab,
|
||||||
|
placeTabInPane,
|
||||||
|
removeTab,
|
||||||
|
restoreSplitTabs,
|
||||||
|
serializeSplitTabs,
|
||||||
|
} from "@/shell/split/split-tabs";
|
||||||
|
import {
|
||||||
|
createSplitState,
|
||||||
|
findPane,
|
||||||
|
listPanes,
|
||||||
|
} from "@/shell/split/split-tree";
|
||||||
|
import { fromLegacyConfig } from "@/shell/split/split-presets";
|
||||||
|
import {
|
||||||
|
notifyShellReady,
|
||||||
|
notifyTabsChanged,
|
||||||
|
} from "@/plugin-host/shell-bridge";
|
||||||
|
import {
|
||||||
|
OPEN_PLUGINS_EVENT,
|
||||||
|
OPEN_PLUGIN_SETTINGS_EVENT,
|
||||||
|
} from "@/plugins/open-plugins";
|
||||||
|
import type { OpenTabOptions, RestoreTarget } from "./types";
|
||||||
|
|
||||||
|
const PENDING_TARGET_MS = 60_000;
|
||||||
|
|
||||||
|
export function dashboardTab(label: string): Tab {
|
||||||
|
return {
|
||||||
|
id: "dashboard",
|
||||||
|
instanceId: "dashboard",
|
||||||
|
type: "dashboard",
|
||||||
|
label,
|
||||||
|
openedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the tab code needs from the split code, filled in by useSplit. */
|
||||||
|
export interface SplitOps {
|
||||||
|
unsplit: (splitTabId: string) => void;
|
||||||
|
closePaneOf: (splitTabId: string, paneId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSplitLabel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (number: number) => t("splitScreen.defaultLabel", { number });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTabs({
|
||||||
|
hosts,
|
||||||
|
hostsRef,
|
||||||
|
hostsLoaded,
|
||||||
|
reloadHosts,
|
||||||
|
userPrefs,
|
||||||
|
userPrefsLoaded,
|
||||||
|
pluginsSettled,
|
||||||
|
confirmRef,
|
||||||
|
layoutDeps,
|
||||||
|
}: {
|
||||||
|
hosts: Host[];
|
||||||
|
hostsRef: MutableRefObject<Host[]>;
|
||||||
|
hostsLoaded: boolean;
|
||||||
|
reloadHosts: () => Promise<void>;
|
||||||
|
userPrefs: UserPreferences;
|
||||||
|
userPrefsLoaded: boolean;
|
||||||
|
pluginsSettled: boolean;
|
||||||
|
confirmRef: MutableRefObject<
|
||||||
|
((options: ConfirmOptions) => Promise<boolean>) | null
|
||||||
|
>;
|
||||||
|
/** Region state whose change also counts as a layout change for plugins. */
|
||||||
|
layoutDeps: unknown[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const splitLabel = useSplitLabel();
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([dashboardTab(t("nav.dashboard"))]);
|
||||||
|
const [activeTabId, setActiveTabId] = useState("dashboard");
|
||||||
|
// Flips to true once the initial DB read (restore or skip) is done. Sync must not fire before this
|
||||||
|
const [tabsReady, setTabsReady] = useState(false);
|
||||||
|
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||||
|
OpenTabRecord[]
|
||||||
|
>([]);
|
||||||
|
// Split tabs are restored once their child tabs have stable live ids.
|
||||||
|
const paneLayoutRestoredRef = useRef(false);
|
||||||
|
const splitTabsRestoredRef = useRef(false);
|
||||||
|
const splitOpsRef = useRef<SplitOps>({
|
||||||
|
unsplit: () => {},
|
||||||
|
closePaneOf: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!splitTabsRestoredRef.current) return;
|
||||||
|
localStorage.setItem(
|
||||||
|
"termix_splitTabs",
|
||||||
|
JSON.stringify(serializeSplitTabs(tabs)),
|
||||||
|
);
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
const tabsRef = useRef(tabs);
|
||||||
|
const activeTabIdRef = useRef(activeTabId);
|
||||||
|
const closeActiveTabRef = useRef<() => void>(() => {});
|
||||||
|
useEffect(() => {
|
||||||
|
tabsRef.current = tabs;
|
||||||
|
}, [tabs]);
|
||||||
|
useEffect(() => {
|
||||||
|
activeTabIdRef.current = activeTabId;
|
||||||
|
}, [activeTabId]);
|
||||||
|
|
||||||
|
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The tab the user is working in: the focused pane's inside a split. */
|
||||||
|
function focusedSessionTabId(): string | null {
|
||||||
|
const id = activeTabIdRef.current;
|
||||||
|
const active = tabsRef.current.find((tab) => tab.id === id);
|
||||||
|
if (!isSplitTab(active)) return id || null;
|
||||||
|
return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Where opened tabs go ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pane the next opened tab should fill, set when the user picks one
|
||||||
|
* (the empty pane picker, "Open in split"). `fresh` marks a pane created in
|
||||||
|
* the same click, which the last committed tabs do not show yet.
|
||||||
|
*/
|
||||||
|
const pendingPaneTargetRef = useRef<{
|
||||||
|
splitTabId: string;
|
||||||
|
paneId: string;
|
||||||
|
at: number;
|
||||||
|
fresh: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
// Set while a saved layout is applied, so its tabs stay where it puts them.
|
||||||
|
const suppressPaneTargetRef = useRef(false);
|
||||||
|
|
||||||
|
function targetPane(splitTabId: string, paneId: string, fresh = false) {
|
||||||
|
pendingPaneTargetRef.current = {
|
||||||
|
splitTabId,
|
||||||
|
paneId,
|
||||||
|
at: Date.now(),
|
||||||
|
fresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a newly opened (or re-focused) tab. It fills the pane the user
|
||||||
|
* picked, else the focused pane of the active split when that is empty,
|
||||||
|
* else it simply becomes the active tab.
|
||||||
|
*/
|
||||||
|
const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {});
|
||||||
|
placeOpenedTabRef.current = (tabId: string) => {
|
||||||
|
const current = tabsRef.current;
|
||||||
|
const pending = pendingPaneTargetRef.current;
|
||||||
|
pendingPaneTargetRef.current = null;
|
||||||
|
if (suppressPaneTargetRef.current || tabId === "dashboard") {
|
||||||
|
setActiveTabId(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = current.find((tab) => tab.id === tabId);
|
||||||
|
|
||||||
|
let target: { splitTabId: string; paneId: string } | null = null;
|
||||||
|
if (pending && Date.now() - pending.at < PENDING_TARGET_MS) {
|
||||||
|
const split = current.find((tab) => tab.id === pending.splitTabId);
|
||||||
|
if (
|
||||||
|
pending.fresh ||
|
||||||
|
(isSplitTab(split) && findPane(split.split, pending.paneId))
|
||||||
|
) {
|
||||||
|
target = pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Opening a session that already sits in a split shows it there.
|
||||||
|
if (!target && existing?.parentSplitTabId) {
|
||||||
|
setActiveTabId(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
const active = current.find((tab) => tab.id === activeTabIdRef.current);
|
||||||
|
if (isSplitTab(active)) {
|
||||||
|
const focused = findPane(active.split, active.split.focusedPaneId);
|
||||||
|
if (focused && focused.tabId === null) {
|
||||||
|
target = { splitTabId: active.id, paneId: focused.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target || (existing && !canJoinSplit(existing))) {
|
||||||
|
setActiveTabId(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { splitTabId, paneId } = target;
|
||||||
|
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId));
|
||||||
|
setActiveTabId(splitTabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Panels that type into a terminal act on "the terminal you're working in".
|
||||||
|
// Once those panels can themselves be the active tab, activeTabId points at
|
||||||
|
// the panel and the lookup misses, so remember the last terminal instead.
|
||||||
|
// In a split, the tab being worked in is the focused pane's.
|
||||||
|
const workingTabId = (() => {
|
||||||
|
const active = tabs.find((t) => t.id === activeTabId);
|
||||||
|
if (!isSplitTab(active)) return activeTabId;
|
||||||
|
return findPane(active.split, active.split.focusedPaneId)?.tabId ?? "";
|
||||||
|
})();
|
||||||
|
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
|
||||||
|
useEffect(() => {
|
||||||
|
const working = tabs.find((t) => t.id === workingTabId);
|
||||||
|
if (working && getTabType(working.type)?.commandTarget) {
|
||||||
|
setLastTerminalTabId(working.id);
|
||||||
|
}
|
||||||
|
}, [workingTabId, tabs]);
|
||||||
|
|
||||||
|
const [showTabNumbers, setShowTabNumbers] = useState<boolean>(
|
||||||
|
() => localStorage.getItem("showTabNumbers") === "true",
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true");
|
||||||
|
};
|
||||||
|
window.addEventListener("showTabNumbersChanged", handler);
|
||||||
|
return () => window.removeEventListener("showTabNumbersChanged", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||||
|
if (!activeTab?.terminalRef) return;
|
||||||
|
let innerRafId: number;
|
||||||
|
const outerRafId = requestAnimationFrame(() => {
|
||||||
|
innerRafId = requestAnimationFrame(() => {
|
||||||
|
const ref = activeTab.terminalRef?.current;
|
||||||
|
ref?.fit?.();
|
||||||
|
ref?.notifyResize?.();
|
||||||
|
ref?.refresh?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(outerRafId);
|
||||||
|
cancelAnimationFrame(innerRafId);
|
||||||
|
};
|
||||||
|
}, [activeTabId]);
|
||||||
|
|
||||||
|
// Sync tab host data when hosts update (e.g. after editing terminal theme in host settings)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hosts.length === 0) return;
|
||||||
|
const byId = new Map(hosts.map((h) => [h.id, h]));
|
||||||
|
setTabs((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = prev.map((t) => {
|
||||||
|
const fresh = t.host ? byId.get(t.host.id) : undefined;
|
||||||
|
if (!fresh || fresh === t.host) return t;
|
||||||
|
// A reload hands back new objects; only replace hosts that changed so
|
||||||
|
// untouched tabs keep their identity and skip a re-render.
|
||||||
|
if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t;
|
||||||
|
changed = true;
|
||||||
|
return { ...t, host: fresh };
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [hosts]);
|
||||||
|
|
||||||
|
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
|
||||||
|
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
|
||||||
|
const tabRestoreAttemptedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
|
||||||
|
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return;
|
||||||
|
if (tabRestoreAttemptedRef.current) return;
|
||||||
|
tabRestoreAttemptedRef.current = true;
|
||||||
|
|
||||||
|
async function loadSavedTabs() {
|
||||||
|
try {
|
||||||
|
const [savedTabs, activeSessions] = await Promise.all([
|
||||||
|
getOpenTabs(),
|
||||||
|
getActiveSessions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!Array.isArray(savedTabs) || savedTabs.length === 0) return;
|
||||||
|
|
||||||
|
const sessionByInstanceId = new Map(
|
||||||
|
(Array.isArray(activeSessions) ? activeSessions : [])
|
||||||
|
.filter((s) => s.tabInstanceId != null)
|
||||||
|
.map((s) => [s.tabInstanceId, s]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userPrefs.reopenTabsOnLogin) {
|
||||||
|
const hasPersistentTabs = tabs.some((t) =>
|
||||||
|
isPersistentTabType(t.type),
|
||||||
|
);
|
||||||
|
if (!hasPersistentTabs) {
|
||||||
|
const restoredTabs: Tab[] = [];
|
||||||
|
for (const saved of savedTabs as OpenTabRecord[]) {
|
||||||
|
const host = saved.hostId
|
||||||
|
? hosts.find((h) => h.id === String(saved.hostId))
|
||||||
|
: undefined;
|
||||||
|
if (!canRestoreTabType(saved.tabType, host)) continue;
|
||||||
|
|
||||||
|
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
|
||||||
|
const tabId = host
|
||||||
|
? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}`
|
||||||
|
: saved.id;
|
||||||
|
const liveSession = sessionByInstanceId.get(saved.id);
|
||||||
|
const restoredSessionId =
|
||||||
|
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
||||||
|
|
||||||
|
const isCustomLabel =
|
||||||
|
host &&
|
||||||
|
saved.label !== host.name &&
|
||||||
|
!/^.+ \(\d+\)$/.test(saved.label);
|
||||||
|
|
||||||
|
restoredTabs.push({
|
||||||
|
id: tabId,
|
||||||
|
instanceId: saved.id,
|
||||||
|
type: saved.tabType as TabType,
|
||||||
|
label: saved.label,
|
||||||
|
customLabel: isCustomLabel ? saved.label : undefined,
|
||||||
|
host,
|
||||||
|
openedAt: new Date(saved.createdAt).getTime(),
|
||||||
|
restoredSessionId,
|
||||||
|
terminalRef: isSessionTabType(saved.tabType)
|
||||||
|
? createRef()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restoredTabs.length > 0) {
|
||||||
|
setTabs((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((t) => t.id));
|
||||||
|
const newTabs = restoredTabs.filter(
|
||||||
|
(t) => !existingIds.has(t.id),
|
||||||
|
);
|
||||||
|
return newTabs.length > 0 ? [...prev, ...newTabs] : prev;
|
||||||
|
});
|
||||||
|
setActiveTabId(restoredTabs[0].id);
|
||||||
|
}
|
||||||
|
// Restored tabs are in the tab bar, not in background records
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not restoring to tab bar, keep as background records for ConnectionsPanel
|
||||||
|
setBackgroundTabRecords(savedTabs as OpenTabRecord[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setTabsReady(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedTabs();
|
||||||
|
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
|
||||||
|
|
||||||
|
// Plugins that act once the session is back (the workspaces plugin applies
|
||||||
|
// a default workspace) wait for this.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabsReady) notifyShellReady();
|
||||||
|
}, [tabsReady]);
|
||||||
|
|
||||||
|
// Restore split tabs once their child sessions have stable live ids. The
|
||||||
|
// oldest single-split keys are migrated once into a first split.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
||||||
|
paneLayoutRestoredRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedSplitTabs = JSON.parse(
|
||||||
|
localStorage.getItem("termix_splitTabs") ?? "[]",
|
||||||
|
) as unknown;
|
||||||
|
if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) {
|
||||||
|
setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedInstanceIds = JSON.parse(
|
||||||
|
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
||||||
|
) as unknown;
|
||||||
|
const savedMode = localStorage.getItem("termix_splitMode");
|
||||||
|
if (!Array.isArray(savedInstanceIds) || !savedMode) return;
|
||||||
|
const savedSizes = JSON.parse(
|
||||||
|
localStorage.getItem("termix_paneSizes") ?? "null",
|
||||||
|
) as { rowSizes?: unknown; rowColSizes?: unknown } | null;
|
||||||
|
const tabIdByInstanceId = new Map(
|
||||||
|
tabs.map((tab) => [tab.instanceId, tab.id]),
|
||||||
|
);
|
||||||
|
const root = fromLegacyConfig(
|
||||||
|
savedMode,
|
||||||
|
savedInstanceIds.map((instanceId) =>
|
||||||
|
typeof instanceId === "string"
|
||||||
|
? (tabIdByInstanceId.get(instanceId) ?? null)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
savedSizes?.rowSizes,
|
||||||
|
savedSizes?.rowColSizes,
|
||||||
|
);
|
||||||
|
if (root && listPanes(root).some((pane) => pane.tabId)) {
|
||||||
|
const splitTab = makeSplitTab(
|
||||||
|
createId(),
|
||||||
|
splitLabel(1),
|
||||||
|
createSplitState(root),
|
||||||
|
);
|
||||||
|
setTabs((prev) => addSplitTab(prev, splitTab));
|
||||||
|
setActiveTabId(splitTab.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
splitTabsRestoredRef.current = true;
|
||||||
|
localStorage.removeItem("termix_splitMode");
|
||||||
|
localStorage.removeItem("termix_paneInstanceIds");
|
||||||
|
localStorage.removeItem("termix_paneSizes");
|
||||||
|
}
|
||||||
|
}, [tabsReady, tabs]);
|
||||||
|
|
||||||
|
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||||
|
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const prevTabOrderRef = useRef<string>("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabsReady) return;
|
||||||
|
const persistable = tabs.filter((t) => isPersistentTabType(t.type));
|
||||||
|
const orderKey = persistable.map((t) => t.instanceId).join(",");
|
||||||
|
if (orderKey === prevTabOrderRef.current) return;
|
||||||
|
prevTabOrderRef.current = orderKey;
|
||||||
|
|
||||||
|
if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current);
|
||||||
|
orderSyncTimeoutRef.current = setTimeout(() => {
|
||||||
|
persistable.forEach((t, i) => {
|
||||||
|
patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {});
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (orderSyncTimeoutRef.current)
|
||||||
|
clearTimeout(orderSyncTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [tabs, tabsReady]);
|
||||||
|
|
||||||
|
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
|
||||||
|
// keep its last-session snapshot current.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabsReady) notifyTabsChanged();
|
||||||
|
}, [tabs, tabsReady, ...layoutDeps]);
|
||||||
|
|
||||||
|
// ─── Tab management ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const openTab = useCallback(function openTab(
|
||||||
|
host: Host,
|
||||||
|
type: TabType,
|
||||||
|
restore?: RestoreTarget,
|
||||||
|
options?: OpenTabOptions,
|
||||||
|
) {
|
||||||
|
if (!restore && !options?.forceNewTab) {
|
||||||
|
const dataKey = JSON.stringify(options?.data ?? null);
|
||||||
|
const existing = tabsRef.current.find(
|
||||||
|
(t) =>
|
||||||
|
t.type === type &&
|
||||||
|
t.host?.id === host.id &&
|
||||||
|
JSON.stringify(t.data ?? null) === dataKey,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
placeOpenedTabRef.current(existing.id);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const instanceId = restore?.instanceId ?? createId();
|
||||||
|
// Unique per open; a timestamp collides when a workspace opens several
|
||||||
|
// tabs in the same millisecond.
|
||||||
|
const tabId = `${type}-${createId()}`;
|
||||||
|
const openedAt = Date.now();
|
||||||
|
const ref = isSessionTabType(type) ? createRef() : undefined;
|
||||||
|
if (ref) terminalRefs.current.set(tabId, ref);
|
||||||
|
|
||||||
|
let finalLabel = host.name;
|
||||||
|
const savedLabel = restore?.savedLabel;
|
||||||
|
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||||
|
const isCustomLabel =
|
||||||
|
savedLabel != null &&
|
||||||
|
savedLabel !== host.name &&
|
||||||
|
!/^.+ \(\d+\)$/.test(savedLabel);
|
||||||
|
|
||||||
|
setTabs((prev) => {
|
||||||
|
if (isCustomLabel && savedLabel) {
|
||||||
|
finalLabel = savedLabel;
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: tabId,
|
||||||
|
instanceId,
|
||||||
|
type,
|
||||||
|
label: finalLabel,
|
||||||
|
customLabel: finalLabel,
|
||||||
|
host,
|
||||||
|
openedAt,
|
||||||
|
terminalRef: ref,
|
||||||
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
data: options?.data,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const same = prev.filter(
|
||||||
|
(t) =>
|
||||||
|
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
||||||
|
);
|
||||||
|
finalLabel = options?.label
|
||||||
|
? options.label
|
||||||
|
: same.length === 0
|
||||||
|
? host.name
|
||||||
|
: `${host.name} (${same.length + 1})`;
|
||||||
|
|
||||||
|
// Retrofit the first duplicate's label to "(1)" if needed
|
||||||
|
const next =
|
||||||
|
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
|
||||||
|
? prev.map((t) =>
|
||||||
|
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
|
||||||
|
)
|
||||||
|
: prev;
|
||||||
|
|
||||||
|
return [
|
||||||
|
...next,
|
||||||
|
{
|
||||||
|
id: tabId,
|
||||||
|
instanceId,
|
||||||
|
type,
|
||||||
|
label: finalLabel,
|
||||||
|
host,
|
||||||
|
openedAt,
|
||||||
|
terminalRef: ref,
|
||||||
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
data: options?.data,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
placeOpenedTabRef.current(tabId);
|
||||||
|
|
||||||
|
if (isPersistentTabType(type)) {
|
||||||
|
addOpenTab({
|
||||||
|
id: instanceId,
|
||||||
|
tabType: type,
|
||||||
|
hostId: host ? parseInt(host.id) : null,
|
||||||
|
label: finalLabel,
|
||||||
|
tabOrder: 0,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function connectHost(
|
||||||
|
host: Host,
|
||||||
|
preferredType?: TabType,
|
||||||
|
options?: OpenTabOptions,
|
||||||
|
) {
|
||||||
|
const type = resolveHostTabType(host, preferredType);
|
||||||
|
if (!type) return;
|
||||||
|
openTab(host, type, undefined, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveQuickConnectHost = useCallback(
|
||||||
|
async (tab: Tab, host: Host) => {
|
||||||
|
try {
|
||||||
|
const savedHost = await createSSHHost(quickConnectHostToPayload(host));
|
||||||
|
await patchOpenTab(tab.instanceId, { hostId: savedHost.id });
|
||||||
|
await reloadHosts();
|
||||||
|
toast.success(t("hosts.hostCreated"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("hosts.failedToSave"));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reloadHosts, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A tab type that opens a fresh tab every time (a local shell). */
|
||||||
|
function openMultiInstanceTab(
|
||||||
|
type: TabType,
|
||||||
|
options?: { host?: Host; data?: Record<string, unknown>; label?: string },
|
||||||
|
): string {
|
||||||
|
const instanceId = createId();
|
||||||
|
const id = `${type}-${instanceId}`;
|
||||||
|
const titleKey = getTabType(type)?.titleKey;
|
||||||
|
const title = options?.label ?? (titleKey ? t(titleKey) : type);
|
||||||
|
setTabs((current) => {
|
||||||
|
const count = current.filter((tab) => tab.type === type).length;
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
instanceId,
|
||||||
|
type,
|
||||||
|
label:
|
||||||
|
count === 0 || options?.label ? title : `${title} (${count + 1})`,
|
||||||
|
openedAt: Date.now(),
|
||||||
|
...(options?.host ? { host: options.host } : {}),
|
||||||
|
...(options?.data !== undefined ? { data: options.data } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
placeOpenedTabRef.current(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSingletonTab = useCallback(
|
||||||
|
// `host` optionally preselects a host for a singleton plugin tab.
|
||||||
|
function openSingletonTab(
|
||||||
|
type: TabType,
|
||||||
|
pendingEvent?: string,
|
||||||
|
host?: Host,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
tabLabel?: string,
|
||||||
|
): string | undefined {
|
||||||
|
// A local shell is never a singleton: each open is its own session.
|
||||||
|
if (getTabType(type)?.multiInstance) {
|
||||||
|
return openMultiInstanceTab(type, { host, data, label: tabLabel });
|
||||||
|
}
|
||||||
|
if (type === "host-manager" && pendingEvent) {
|
||||||
|
const request = requestFromLegacyEvent(pendingEvent);
|
||||||
|
if (request) requestManage(request);
|
||||||
|
}
|
||||||
|
// Profile and admin are pages of the one Settings tab now.
|
||||||
|
if (type === "user-profile" || type === "admin-settings") {
|
||||||
|
return openSingletonTab(
|
||||||
|
"settings",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
section:
|
||||||
|
typeof data?.section === "string"
|
||||||
|
? data.section
|
||||||
|
: type === "admin-settings"
|
||||||
|
? "admin-general"
|
||||||
|
: "account",
|
||||||
|
},
|
||||||
|
tabLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Asking for a page again still goes there after the user moved away
|
||||||
|
if (type === "settings" && data?.section && data.revealAt === undefined) {
|
||||||
|
data = { ...data, revealAt: Date.now() };
|
||||||
|
}
|
||||||
|
const id = type;
|
||||||
|
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||||
|
"host-manager": t("nav.manage"),
|
||||||
|
settings: t("nav.settings"),
|
||||||
|
plugins: t("nav.plugins"),
|
||||||
|
};
|
||||||
|
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||||
|
// label so the two stay in sync.
|
||||||
|
const titleKey = getTabType(type)?.titleKey;
|
||||||
|
const label =
|
||||||
|
singletonLabels[type] ??
|
||||||
|
(titleKey ? t(titleKey) : railItemLabel(type, t));
|
||||||
|
setTabs((prev) => {
|
||||||
|
const existing = prev.find((t) => t.id === id);
|
||||||
|
if (existing) {
|
||||||
|
// Refocusing a singleton with a host or data passes it on.
|
||||||
|
if (!host && data === undefined) return prev;
|
||||||
|
return prev.map((t) =>
|
||||||
|
t.id === id
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
...(host ? { host } : {}),
|
||||||
|
...(data !== undefined ? { data } : {}),
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
instanceId: id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
openedAt: Date.now(),
|
||||||
|
...(host ? { host } : {}),
|
||||||
|
...(data !== undefined ? { data } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
placeOpenedTabRef.current(id);
|
||||||
|
if (isPersistentTabType(type)) {
|
||||||
|
addOpenTab({
|
||||||
|
id,
|
||||||
|
tabType: type,
|
||||||
|
hostId: null,
|
||||||
|
label,
|
||||||
|
tabOrder: 0,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openSingletonTabRef = useRef(openSingletonTab);
|
||||||
|
openSingletonTabRef.current = openSingletonTab;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => openSingletonTabRef.current("plugins");
|
||||||
|
window.addEventListener(OPEN_PLUGINS_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const pluginId = (event as CustomEvent<string>).detail;
|
||||||
|
if (typeof pluginId !== "string") return;
|
||||||
|
openSingletonTabRef.current("settings", undefined, undefined, {
|
||||||
|
section: `plugin:${pluginId}`,
|
||||||
|
revealAt: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTabCloseLabel = useCallback((tab: Tab) => {
|
||||||
|
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isActiveConnectionTab = useCallback((tab: Tab) => {
|
||||||
|
if (!isSessionTabType(tab.type)) return false;
|
||||||
|
return tab.terminalRef?.current?.isConnected?.() === true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasActiveConnection = useCallback(() => {
|
||||||
|
return tabsRef.current.some(isActiveConnectionTab);
|
||||||
|
}, [isActiveConnectionTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!hasActiveConnection()) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [hasActiveConnection]);
|
||||||
|
|
||||||
|
const doCloseTabRef = useRef<(id: string) => void>(() => {});
|
||||||
|
doCloseTabRef.current = doCloseTab;
|
||||||
|
function doCloseTab(id: string) {
|
||||||
|
const tabToClose = tabs.find((t) => t.id === id);
|
||||||
|
if (tabToClose?.terminalRef?.current?.disconnect) {
|
||||||
|
tabToClose.terminalRef.current.disconnect();
|
||||||
|
}
|
||||||
|
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
|
||||||
|
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalRefs.current.delete(id);
|
||||||
|
if (isSplitTab(tabToClose)) {
|
||||||
|
splitOpsRef.current.unsplit(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id === activeTabId) {
|
||||||
|
const remaining = tabs.filter(
|
||||||
|
(tab) => tab.id !== id && !tab.parentSplitTabId,
|
||||||
|
);
|
||||||
|
setActiveTabId(
|
||||||
|
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTabs((prev) => {
|
||||||
|
// A pane that held the tab stays, empty and focused, ready for the next one.
|
||||||
|
const next = removeTab(prev, id);
|
||||||
|
if (next.length === 0) return [dashboardTab(t("nav.dashboard"))];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectAllDisconnected() {
|
||||||
|
const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current);
|
||||||
|
if (reconnected || !failed)
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
reconnected
|
||||||
|
? "nav.reconnectingTerminals"
|
||||||
|
: "nav.noDisconnectedTerminals",
|
||||||
|
{ count: reconnected },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (failed)
|
||||||
|
toast.error(t("nav.reconnectTerminalsFailed", { count: failed }));
|
||||||
|
}
|
||||||
|
const reconnectAllRef = useRef<() => void>(() => {});
|
||||||
|
reconnectAllRef.current = reconnectAllDisconnected;
|
||||||
|
|
||||||
|
function refreshTab(id: string) {
|
||||||
|
const tab = tabs.find((t) => t.id === id);
|
||||||
|
const handle = tab?.terminalRef?.current;
|
||||||
|
if (!handle) return;
|
||||||
|
// Session tabs expose one or the other on their handle.
|
||||||
|
if (handle.reconnect) handle.reconnect();
|
||||||
|
else handle.refresh?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTab(id: string) {
|
||||||
|
const tab = tabsRef.current.find((t) => t.id === id);
|
||||||
|
const guard = tab?.terminalRef?.current?.confirmClose;
|
||||||
|
if (guard && !(await guard())) return;
|
||||||
|
if (!(await canCloseTab(id))) return;
|
||||||
|
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||||
|
if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
|
||||||
|
const ask = confirmRef.current;
|
||||||
|
const ok = ask
|
||||||
|
? await ask({
|
||||||
|
title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }),
|
||||||
|
confirmLabel: t("nav.close"),
|
||||||
|
})
|
||||||
|
: true;
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
doCloseTabRef.current(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a split, closing acts on the focused pane: its session, or the pane
|
||||||
|
// itself once it is empty.
|
||||||
|
closeActiveTabRef.current = () => {
|
||||||
|
const id = activeTabIdRef.current;
|
||||||
|
const active = tabsRef.current.find((tab) => tab.id === id);
|
||||||
|
if (isSplitTab(active)) {
|
||||||
|
const tabId = focusedSessionTabId();
|
||||||
|
if (tabId) closeTab(tabId);
|
||||||
|
else
|
||||||
|
splitOpsRef.current.closePaneOf(active.id, active.split.focusedPaneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id !== "dashboard") closeTab(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
function renameTab(tabId: string, newLabel: string) {
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tab = tabs.find((t) => t.id === tabId);
|
||||||
|
if (tab?.instanceId && tab.type !== "split-screen") {
|
||||||
|
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderTopLevelTabs(reordered: Tab[]) {
|
||||||
|
setTabs((prev) => [
|
||||||
|
...reordered,
|
||||||
|
...prev.filter((tab) => tab.parentSplitTabId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget);
|
||||||
|
// What command-target panels act on. Falls back to the remembered
|
||||||
|
// terminal when the active tab isn't one, and drops it once it's closed.
|
||||||
|
const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId)
|
||||||
|
? workingTabId
|
||||||
|
: terminalTabs.some((t) => t.id === lastTerminalTabId)
|
||||||
|
? lastTerminalTabId
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
setTabs,
|
||||||
|
activeTabId,
|
||||||
|
setActiveTabId,
|
||||||
|
tabsReady,
|
||||||
|
backgroundTabRecords,
|
||||||
|
setBackgroundTabRecords,
|
||||||
|
tabsRef,
|
||||||
|
activeTabIdRef,
|
||||||
|
closeActiveTabRef,
|
||||||
|
reconnectAllRef,
|
||||||
|
terminalRefs,
|
||||||
|
splitOpsRef,
|
||||||
|
suppressPaneTargetRef,
|
||||||
|
pendingPaneTargetRef,
|
||||||
|
openSingletonTabRef,
|
||||||
|
hostsRef,
|
||||||
|
focusedSessionTabId,
|
||||||
|
targetPane,
|
||||||
|
openTab,
|
||||||
|
connectHost,
|
||||||
|
saveQuickConnectHost,
|
||||||
|
openMultiInstanceTab,
|
||||||
|
openSingletonTab,
|
||||||
|
doCloseTab,
|
||||||
|
closeTab,
|
||||||
|
renameTab,
|
||||||
|
refreshTab,
|
||||||
|
reconnectAllDisconnected,
|
||||||
|
reorderTopLevelTabs,
|
||||||
|
showTabNumbers,
|
||||||
|
terminalTabs,
|
||||||
|
targetTerminalTab: terminalTabs.find((t) => t.id === targetTerminalTabId),
|
||||||
|
handleFor: (id: string) =>
|
||||||
|
terminalRefs.current.get(id)?.current as TabHandle | null | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabsState = ReturnType<typeof useTabs>;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { WorkspacePayload } from "@/types/ui-types";
|
||||||
|
import { createId } from "@/lib/create-id";
|
||||||
|
import { rightDockableIds } from "@/sidebar/rail-items";
|
||||||
|
import {
|
||||||
|
buildLayoutPayload,
|
||||||
|
resolveLayoutSplits,
|
||||||
|
resolveLayoutTabTarget,
|
||||||
|
snapshotData,
|
||||||
|
} from "@/shell/shell-layout";
|
||||||
|
import { addSplitTab, makeSplitTab } from "@/shell/split/split-tabs";
|
||||||
|
import { listPanes } from "@/shell/split/split-tree";
|
||||||
|
import { setShellLayoutProvider } from "@/plugin-host/shell-bridge";
|
||||||
|
import { useSplitLabel, type TabsState } from "./use-tabs";
|
||||||
|
import type { ShellRegions } from "./types";
|
||||||
|
|
||||||
|
/** Saving and applying a whole arrangement, for workspaces and plugins. */
|
||||||
|
export function useWorkspaceLayout(
|
||||||
|
tabsState: TabsState,
|
||||||
|
regions: ShellRegions,
|
||||||
|
) {
|
||||||
|
const splitLabel = useSplitLabel();
|
||||||
|
const {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
tabsReady,
|
||||||
|
tabsRef,
|
||||||
|
hostsRef,
|
||||||
|
setTabs,
|
||||||
|
setActiveTabId,
|
||||||
|
suppressPaneTargetRef,
|
||||||
|
pendingPaneTargetRef,
|
||||||
|
} = tabsState;
|
||||||
|
|
||||||
|
function buildWorkspacePayload(): WorkspacePayload {
|
||||||
|
return buildLayoutPayload({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
sidebar: {
|
||||||
|
left: {
|
||||||
|
view: regions.left.view,
|
||||||
|
open: regions.left.open,
|
||||||
|
width: regions.left.width,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
view: regions.right.view,
|
||||||
|
open: regions.right.view !== null,
|
||||||
|
width: regions.right.width,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLayout(
|
||||||
|
payload: WorkspacePayload,
|
||||||
|
name: string,
|
||||||
|
): Promise<{ skipped: string[] }> {
|
||||||
|
// Tear down the current arrangement the same way an individual tab close does.
|
||||||
|
for (const tab of [...tabsRef.current]) {
|
||||||
|
tabsState.doCloseTab(tab.id);
|
||||||
|
}
|
||||||
|
// Tabs reopened here must not be pulled into a pane of the old layout.
|
||||||
|
suppressPaneTargetRef.current = true;
|
||||||
|
pendingPaneTargetRef.current = null;
|
||||||
|
try {
|
||||||
|
const slotIdToNewTabId = new Map<string, string>();
|
||||||
|
const skippedTabs: string[] = [];
|
||||||
|
|
||||||
|
for (const snapshot of payload.tabs) {
|
||||||
|
const target = resolveLayoutTabTarget(snapshot, hostsRef.current);
|
||||||
|
|
||||||
|
if (target.kind === "skip") {
|
||||||
|
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.kind === "singleton") {
|
||||||
|
const newTabId = tabsState.openSingletonTab(
|
||||||
|
snapshot.type,
|
||||||
|
undefined,
|
||||||
|
target.host,
|
||||||
|
snapshotData(snapshot),
|
||||||
|
);
|
||||||
|
slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.kind === "host") {
|
||||||
|
const newTabId = tabsState.openTab(
|
||||||
|
target.host,
|
||||||
|
snapshot.type,
|
||||||
|
{
|
||||||
|
instanceId: createId(),
|
||||||
|
restoredSessionId: null,
|
||||||
|
savedLabel: snapshot.customLabel ?? snapshot.label,
|
||||||
|
},
|
||||||
|
{ data: snapshotData(snapshot) },
|
||||||
|
);
|
||||||
|
slotIdToNewTabId.set(snapshot.slotId, newTabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitIdBySlotId = new Map<string, string>();
|
||||||
|
const splitIdByTabId = new Map<string, string>();
|
||||||
|
let firstSplitId: string | null = null;
|
||||||
|
resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => {
|
||||||
|
const splitTab = makeSplitTab(
|
||||||
|
createId(),
|
||||||
|
saved.label || name || splitLabel(index + 1),
|
||||||
|
saved.split,
|
||||||
|
);
|
||||||
|
if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id);
|
||||||
|
for (const pane of listPanes(saved.split.root)) {
|
||||||
|
if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id);
|
||||||
|
}
|
||||||
|
firstSplitId ??= splitTab.id;
|
||||||
|
setTabs((prev) => addSplitTab(prev, splitTab));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tab that now sits in a split is shown through its split.
|
||||||
|
const activeSlot = payload.activeSlotId;
|
||||||
|
const activeTabInLayout = activeSlot
|
||||||
|
? slotIdToNewTabId.get(activeSlot)
|
||||||
|
: undefined;
|
||||||
|
const activeId =
|
||||||
|
(activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ??
|
||||||
|
(activeTabInLayout
|
||||||
|
? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout)
|
||||||
|
: undefined) ??
|
||||||
|
firstSplitId ??
|
||||||
|
"dashboard";
|
||||||
|
setActiveTabId(activeId);
|
||||||
|
|
||||||
|
// Older payloads predate the sidebar field, so leave the docks alone then.
|
||||||
|
const sidebar = payload.sidebar;
|
||||||
|
if (sidebar) {
|
||||||
|
if (sidebar.left.view) {
|
||||||
|
regions.left.setView(
|
||||||
|
sidebar.left.view === "split-screen" ? "hosts" : sidebar.left.view,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
regions.left.setOpen(sidebar.left.open);
|
||||||
|
if (sidebar.left.width) regions.left.setWidth(sidebar.left.width);
|
||||||
|
|
||||||
|
const right = sidebar.right.open ? sidebar.right.view : null;
|
||||||
|
regions.right.setView(
|
||||||
|
right && rightDockableIds().includes(right) ? right : null,
|
||||||
|
);
|
||||||
|
if (sidebar.right.width) regions.right.setWidth(sidebar.right.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skipped: skippedTabs };
|
||||||
|
} finally {
|
||||||
|
suppressPaneTargetRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShellLayoutProvider({
|
||||||
|
getLayout: () => (tabsReady ? buildWorkspacePayload() : null),
|
||||||
|
applyLayout: (layout, options) =>
|
||||||
|
applyLayout(layout as WorkspacePayload, options?.name ?? ""),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Component, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
|
import { retryPluginFrontend, syncPlugins } from "@/plugin-host/loader";
|
||||||
|
import { chosenLayoutId, useLookState } from "@/look/look-store";
|
||||||
|
import {
|
||||||
|
getRegisteredLayout,
|
||||||
|
useRegisteredLayouts,
|
||||||
|
} from "@/look/layout-registry";
|
||||||
|
import { layoutStatus } from "@/look/layout-status";
|
||||||
|
import { safeLayoutInUrl } from "@/look/resolve-look";
|
||||||
|
import { useLookTheme } from "@/look/use-look-theme";
|
||||||
|
import { RepairView } from "@/look/RepairView";
|
||||||
|
import { registerBuiltinLayout } from "./builtin";
|
||||||
|
|
||||||
|
registerBuiltinLayout();
|
||||||
|
|
||||||
|
/** Catches a crash while a layout draws, so the repair view can take over. */
|
||||||
|
class LayoutErrorBoundary extends Component<
|
||||||
|
{ onCrash: (error: Error) => void; children: ReactNode },
|
||||||
|
{ crashed: boolean }
|
||||||
|
> {
|
||||||
|
state = { crashed: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { crashed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
console.error("[layout] crashed while drawing", error);
|
||||||
|
this.props.onCrash(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.crashed ? null : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrashState {
|
||||||
|
layoutId: string | null;
|
||||||
|
count: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the chosen layout, or the repair view when it cannot be drawn. A
|
||||||
|
* crash remounts the layout once; a second hands over to the repair view.
|
||||||
|
*/
|
||||||
|
export function LayoutHost() {
|
||||||
|
const look = useLookState();
|
||||||
|
const store = usePluginStore();
|
||||||
|
useRegisteredLayouts();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [crash, setCrash] = useState<CrashState>({
|
||||||
|
layoutId: null,
|
||||||
|
count: 0,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
// The look store changes identity whenever the choice does.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const layoutId = useMemo(() => chosenLayoutId(), [look]);
|
||||||
|
useLookTheme(layoutId);
|
||||||
|
|
||||||
|
const crashes = crash.layoutId === layoutId ? crash.count : 0;
|
||||||
|
const owner = [...store.records.values()].find((record) =>
|
||||||
|
record.summary.contributes?.layouts?.some((l) => l.id === layoutId),
|
||||||
|
);
|
||||||
|
const status = layoutStatus({
|
||||||
|
layoutId,
|
||||||
|
safeFromUrl: safeLayoutInUrl(window.location.search),
|
||||||
|
safeFromEnv: look.response?.safeLayout === true,
|
||||||
|
ready: look.loaded && store.settled,
|
||||||
|
registered: getRegisteredLayout(layoutId),
|
||||||
|
owner,
|
||||||
|
unavailable: look.response?.unavailable.find((plugin) =>
|
||||||
|
plugin.layouts.includes(layoutId),
|
||||||
|
),
|
||||||
|
crashes,
|
||||||
|
lastCrash: crash.layoutId === layoutId ? crash.error : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
setCrash({ layoutId: null, count: 0, error: null });
|
||||||
|
setAttempt((value) => value + 1);
|
||||||
|
if (owner) void retryPluginFrontend(owner.summary.id);
|
||||||
|
else void syncPlugins();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.kind === "loading") {
|
||||||
|
return <div className="h-dvh w-screen bg-background" />;
|
||||||
|
}
|
||||||
|
if (status.kind === "repair") {
|
||||||
|
return <RepairView status={status} onRetry={retry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { layout } = status;
|
||||||
|
// The desktop app always gets the desktop layout.
|
||||||
|
const Draw =
|
||||||
|
isMobile && !isElectron() && layout.mobileComponent
|
||||||
|
? layout.mobileComponent
|
||||||
|
: layout.component;
|
||||||
|
return (
|
||||||
|
<LayoutErrorBoundary
|
||||||
|
key={`${layoutId}:${crashes}:${attempt}`}
|
||||||
|
onCrash={(error) =>
|
||||||
|
setCrash((previous) => ({
|
||||||
|
layoutId,
|
||||||
|
count: (previous.layoutId === layoutId ? previous.count : 0) + 1,
|
||||||
|
error: error.message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Draw />
|
||||||
|
</LayoutErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Sheet, SheetContent } from "@/components/sheet";
|
||||||
|
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||||
|
import { MobileBar } from "@/shell/MobileBar";
|
||||||
|
import { AppRail } from "@/sidebar/AppRail";
|
||||||
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||||
|
import {
|
||||||
|
isCoreRailView,
|
||||||
|
railItemLabel,
|
||||||
|
promotableIds,
|
||||||
|
rightDockableIds,
|
||||||
|
} from "@/sidebar/rail-items";
|
||||||
|
import { DockPanel, DockReopenStrip } from "@/shell/DockPanel";
|
||||||
|
import { TabBar } from "@/shell/TabBar";
|
||||||
|
import { requestManage, type ManageMode } from "@/manage/manage-requests";
|
||||||
|
import { getTabType } from "@/shell/tab-registry";
|
||||||
|
import { getPanel, usePanels } from "@/shell/panel-registry";
|
||||||
|
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||||
|
import type { TabType } from "@/types/ui-types";
|
||||||
|
import {
|
||||||
|
ShellConfirmCapture,
|
||||||
|
useShellController,
|
||||||
|
} from "@/shell/controller/ShellControllerProvider";
|
||||||
|
import { TabStage } from "@/shell/stage/TabStage";
|
||||||
|
|
||||||
|
const HostsPanel = lazy(() =>
|
||||||
|
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
|
||||||
|
);
|
||||||
|
const QuickConnectPanel = lazy(() =>
|
||||||
|
import("@/sidebar/QuickConnectPanel").then((m) => ({
|
||||||
|
default: m.QuickConnectPanel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const SyncPanel = lazy(() =>
|
||||||
|
import("@/settings/sync/SyncPanel").then((m) => ({
|
||||||
|
default: m.SyncPanel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const CredentialsPanel = lazy(() =>
|
||||||
|
import("@/sidebar/CredentialsPanel").then((m) => ({
|
||||||
|
default: m.CredentialsPanel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ConnectionsPanel = lazy(() =>
|
||||||
|
import("@/sidebar/ConnectionsPanel").then((m) => ({
|
||||||
|
default: m.ConnectionsPanel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function SidebarPanelFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6">
|
||||||
|
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The look Termix has always had: icon rail, left sidebar, tab bar, right
|
||||||
|
* dock and the mobile bottom bar. Built in until it moves to its own plugin.
|
||||||
|
*/
|
||||||
|
export function DefaultLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const shell = useShellController();
|
||||||
|
const registeredPanels = usePanels();
|
||||||
|
const { tabs, regions, hosts, isMobile } = shell;
|
||||||
|
const { left, right } = regions;
|
||||||
|
const closeOnMobile = () => {
|
||||||
|
if (isMobile) left.setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every panel gets its own surface, so its inline views and confirms stay
|
||||||
|
// inside it. Only the left dock widens for them.
|
||||||
|
const panelScope = (
|
||||||
|
view: string,
|
||||||
|
owned: boolean,
|
||||||
|
shown: boolean,
|
||||||
|
children: React.ReactNode,
|
||||||
|
className = "",
|
||||||
|
) => (
|
||||||
|
<SurfaceScope
|
||||||
|
key={view}
|
||||||
|
kind="panel"
|
||||||
|
onEditingChange={owned ? regions.scopeEditingSetter(view) : undefined}
|
||||||
|
className={`${shown ? "" : "hidden"} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SurfaceScope>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar panel content, shared between the desktop sidebar, the mobile
|
||||||
|
* sheet and the right dock. Takes the view rather than reading the left
|
||||||
|
* dock's so both docks can render from the same code.
|
||||||
|
*
|
||||||
|
* `owned` marks the dock responsible for the panels that stay mounted while
|
||||||
|
* hidden (hosts, credentials, fleets). Only one dock may own them, otherwise
|
||||||
|
* two live instances fight over the shared editing state.
|
||||||
|
*/
|
||||||
|
const renderSidebarPanels = (railView: string, owned = true) => (
|
||||||
|
<Suspense fallback={<SidebarPanelFallback />}>
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
|
{owned && (
|
||||||
|
<>
|
||||||
|
{panelScope(
|
||||||
|
"hosts",
|
||||||
|
owned,
|
||||||
|
railView === "hosts",
|
||||||
|
<HostsPanel
|
||||||
|
onOpenTab={(host, type, options) => {
|
||||||
|
tabs.connectHost(host, type, options);
|
||||||
|
closeOnMobile();
|
||||||
|
}}
|
||||||
|
onEditHost={hosts.edit}
|
||||||
|
onCloseSidebar={() => left.setOpen(false)}
|
||||||
|
hostTree={hosts.tree ?? undefined}
|
||||||
|
loading={hosts.loading}
|
||||||
|
onEditingChange={regions.panelEditingSetter("hosts")}
|
||||||
|
active={railView === "hosts"}
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
{panelScope(
|
||||||
|
"credentials",
|
||||||
|
owned,
|
||||||
|
railView === "credentials",
|
||||||
|
<CredentialsPanel
|
||||||
|
onEditingChange={regions.panelEditingSetter("credentials")}
|
||||||
|
active={railView === "credentials"}
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "quick-connect" &&
|
||||||
|
panelScope(
|
||||||
|
"quick-connect",
|
||||||
|
owned,
|
||||||
|
true,
|
||||||
|
<QuickConnectPanel
|
||||||
|
onConnect={(host, type) => {
|
||||||
|
tabs.open(host, type);
|
||||||
|
closeOnMobile();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{registeredPanels.map((panel) => {
|
||||||
|
const shown = railView === panel.id;
|
||||||
|
// A kept-mounted panel lives in the owning dock only, so two live
|
||||||
|
// copies never fight over the same state.
|
||||||
|
if (panel.keepMounted ? !owned : !shown) return null;
|
||||||
|
const Panel = panel.component;
|
||||||
|
return panelScope(
|
||||||
|
panel.id,
|
||||||
|
owned,
|
||||||
|
shown,
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<Panel
|
||||||
|
targetTab={tabs.targetTerminal}
|
||||||
|
active={shown}
|
||||||
|
shell={shell.panelCallbacks}
|
||||||
|
setEditing={
|
||||||
|
owned ? regions.panelEditingSetter(panel.id) : () => undefined
|
||||||
|
}
|
||||||
|
activeTabType={tabs.active?.type ?? undefined}
|
||||||
|
placement={owned ? "left" : "right"}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!isCoreRailView(railView) && !getPanel(railView) && (
|
||||||
|
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "connections" &&
|
||||||
|
panelScope(
|
||||||
|
"connections",
|
||||||
|
owned,
|
||||||
|
true,
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<ConnectionsPanel
|
||||||
|
tabs={tabs.list}
|
||||||
|
activeTabId={tabs.activeId}
|
||||||
|
allHosts={hosts.list}
|
||||||
|
backgroundTabRecords={tabs.background}
|
||||||
|
onSwitchToTab={(tabId) => {
|
||||||
|
tabs.setActive(tabId);
|
||||||
|
closeOnMobile();
|
||||||
|
}}
|
||||||
|
onCloseTab={tabs.close}
|
||||||
|
onReopenTab={(record, restoredSessionId) => {
|
||||||
|
const host = record.hostId
|
||||||
|
? hosts.list.find((h) => h.id === String(record.hostId))
|
||||||
|
: undefined;
|
||||||
|
if (!host && !getTabType(record.tabType)?.hostless) return;
|
||||||
|
tabs.setBackground((prev) =>
|
||||||
|
prev.filter((r) => r.id !== record.id),
|
||||||
|
);
|
||||||
|
if (host) {
|
||||||
|
const effectiveSessionId =
|
||||||
|
restoredSessionId ?? record.backendSessionId ?? null;
|
||||||
|
tabs.open(host, record.tabType as TabType, {
|
||||||
|
instanceId: record.id,
|
||||||
|
restoredSessionId: effectiveSessionId,
|
||||||
|
savedLabel: record.label,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tabs.openSingleton(record.tabType as TabType);
|
||||||
|
}
|
||||||
|
closeOnMobile();
|
||||||
|
}}
|
||||||
|
onForgetBackground={(recordId) => {
|
||||||
|
tabs.setBackground((prev) =>
|
||||||
|
prev.filter((r) => r.id !== recordId),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onRenameTab={tabs.rename}
|
||||||
|
onReorderTabs={tabs.setList}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "sync" &&
|
||||||
|
panelScope(
|
||||||
|
"sync",
|
||||||
|
owned,
|
||||||
|
true,
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<SyncPanel />
|
||||||
|
</div>,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarPanelContent = renderSidebarPanels(left.view);
|
||||||
|
|
||||||
|
const dockProps = (view: string) => ({
|
||||||
|
// Titles come from the shared rail definitions so they stay translated
|
||||||
|
// and in step with the rail itself.
|
||||||
|
title: railItemLabel(view, t),
|
||||||
|
onOpenAsTab: isMobile
|
||||||
|
? undefined
|
||||||
|
: view === "hosts" || view === "credentials"
|
||||||
|
? () => requestManage({ kind: "browse", mode: view as ManageMode })
|
||||||
|
: promotableIds().includes(view)
|
||||||
|
? () => tabs.openSingleton(view as TabType)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col w-screen bg-background"
|
||||||
|
style={{ height: "100dvh" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
{/* Skinny icon rail, desktop only */}
|
||||||
|
<AppRail
|
||||||
|
railView={left.view}
|
||||||
|
sidebarOpen={left.open}
|
||||||
|
username={shell.username}
|
||||||
|
isAdmin={shell.isAdmin}
|
||||||
|
pluginsSettled={shell.pluginsSettled}
|
||||||
|
onRailClick={regions.railClick}
|
||||||
|
onOpenTab={tabs.openSingleton}
|
||||||
|
onOpenInRightDock={regions.openInRightDock}
|
||||||
|
onOpenSettings={() => tabs.openSingleton("settings")}
|
||||||
|
onOpenNavigationSettings={() =>
|
||||||
|
tabs.openSingleton("settings", undefined, undefined, {
|
||||||
|
section: "appearance",
|
||||||
|
reveal: "settings-navigation",
|
||||||
|
revealAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenPalette={shell.palette.open}
|
||||||
|
onLogout={shell.logout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Desktop: inline resizable sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<DockPanel
|
||||||
|
side="left"
|
||||||
|
{...dockProps(left.view)}
|
||||||
|
open={left.open}
|
||||||
|
width={left.width}
|
||||||
|
editing={left.editing}
|
||||||
|
dragging={left.dragging}
|
||||||
|
onResizeStart={left.startResize}
|
||||||
|
onClose={() => left.setOpen(false)}
|
||||||
|
onMoveToRightDock={
|
||||||
|
rightDockableIds().includes(left.view)
|
||||||
|
? () => regions.openInRightDock(left.view)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onResetWidth={left.resetWidth}
|
||||||
|
>
|
||||||
|
{sidebarPanelContent}
|
||||||
|
</DockPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: sidebar as overlay sheet */}
|
||||||
|
{isMobile && (
|
||||||
|
<Sheet open={left.open} onOpenChange={left.setOpen}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
showCloseButton={false}
|
||||||
|
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${left.editing ? "w-screen" : "w-[min(85vw,360px)]"}`}
|
||||||
|
style={{ height: "100dvh" }}
|
||||||
|
>
|
||||||
|
<DockPanel
|
||||||
|
side="left"
|
||||||
|
{...dockProps(left.view)}
|
||||||
|
open
|
||||||
|
fill
|
||||||
|
width={left.width}
|
||||||
|
onClose={() => left.setOpen(false)}
|
||||||
|
>
|
||||||
|
{sidebarPanelContent}
|
||||||
|
</DockPanel>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !left.open ? "pl-6" : ""}`}
|
||||||
|
>
|
||||||
|
{!isMobile && !left.open && (
|
||||||
|
<DockReopenStrip onClick={() => left.setOpen(true)} />
|
||||||
|
)}
|
||||||
|
<SurfaceScope kind="tab" className="overflow-hidden">
|
||||||
|
<ShellConfirmCapture />
|
||||||
|
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||||
|
<TabBar
|
||||||
|
tabs={tabs.topLevel}
|
||||||
|
activeTabId={tabs.activeId}
|
||||||
|
splits={shell.split.summaries}
|
||||||
|
activeSplitFull={shell.split.activeFull}
|
||||||
|
onSetActiveTab={tabs.setActive}
|
||||||
|
onCloseTab={tabs.close}
|
||||||
|
onRefreshTab={tabs.refresh}
|
||||||
|
onReconnectDisconnected={tabs.reconnectAll}
|
||||||
|
onReorderTabs={tabs.reorder}
|
||||||
|
onSplitAction={shell.split.handleAction}
|
||||||
|
onRenameTab={tabs.rename}
|
||||||
|
isAppFullscreen={shell.fullscreen.active}
|
||||||
|
onToggleAppFullscreen={shell.fullscreen.toggle}
|
||||||
|
rightDockOpen={right.view !== null}
|
||||||
|
onToggleRightDock={
|
||||||
|
isMobile ? undefined : regions.toggleRightDock
|
||||||
|
}
|
||||||
|
showTabNumbers={tabs.showNumbers}
|
||||||
|
/>
|
||||||
|
<TabStage />
|
||||||
|
</div>
|
||||||
|
</SurfaceScope>
|
||||||
|
|
||||||
|
{/* Bottom nav bar, mobile only. The desktop app keeps the rail. */}
|
||||||
|
{!shell.isElectron && (
|
||||||
|
<MobileBar
|
||||||
|
railView={left.view}
|
||||||
|
sidebarOpen={left.open}
|
||||||
|
username={shell.username}
|
||||||
|
onRailClick={regions.railClick}
|
||||||
|
onOpenTab={(type) => tabs.openSingleton(type)}
|
||||||
|
onOpenSettings={() => tabs.openSingleton("settings")}
|
||||||
|
onOpenPalette={shell.palette.open}
|
||||||
|
onLogout={() => shell.logout({ manual: true })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right dock, desktop only, holds a second reference panel */}
|
||||||
|
{!isMobile && right.view && (
|
||||||
|
<DockPanel
|
||||||
|
side="right"
|
||||||
|
{...dockProps(right.view)}
|
||||||
|
open
|
||||||
|
width={right.width}
|
||||||
|
dragging={right.dragging}
|
||||||
|
onResizeStart={right.startResize}
|
||||||
|
onClose={() => right.setView(null)}
|
||||||
|
onResetWidth={right.resetWidth}
|
||||||
|
>
|
||||||
|
{renderSidebarPanels(right.view, false)}
|
||||||
|
</DockPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin panels pinned to the right edge, sized by the plugin */}
|
||||||
|
<ComponentSlot slotId="shell.rightEdge" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { BUILTIN_LAYOUT_ID, registerLayout } from "@/look/layout-registry";
|
||||||
|
import { DefaultLayout } from "./DefaultLayout";
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
/** Core's own layout, there whatever plugins are installed. */
|
||||||
|
export function registerBuiltinLayout(): void {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
registerLayout({ id: BUILTIN_LAYOUT_ID, component: DefaultLayout });
|
||||||
|
}
|
||||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user