Compare commits

...
13 Commits
30 changed files with 517 additions and 96 deletions

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 26.10.5
### Changed
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
## 26.10.4
### Fixed
- Pin classic layout plugin (fixes proxmox install)
## 26.10.3 ## 26.10.3
### Added ### Added
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do cask "termix" do
version "26.10.2" version "26.10.3"
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223" sha256 "a0c9db16f144c1e2be6ce2f4ead9268a010d12e0d0608feb34976750661a2239"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg" url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix" name "Termix"
+6
View File
@@ -66,7 +66,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | | [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. | | [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. | | [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. | | [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
@@ -76,6 +78,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | | [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. | | [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. | | [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. | | [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
@@ -83,8 +86,11 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | | [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | | [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. | | [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. | | [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. | | [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. | | [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. | | [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | | [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
+2 -2
View File
@@ -37,8 +37,8 @@
{ {
"id": "classic-layout", "id": "classic-layout",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug",
"sha256": "aff01b91a28a17013cd4cf64b47dcc02cb85a35710e3382aa9adfe7d93e0fb3a", "sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34",
"defaultLook": true "defaultLook": true
}, },
{ {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.10.3", "version": "26.10.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.10.3", "version": "26.10.5",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+2 -2
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.10.3", "version": "26.10.5",
"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",
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs", "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
"prepare": "husky || true", "prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs", "prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs", "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-button-colors.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force", "type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run", "test": "vitest run",
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 1.0.20
Plugin API 1.12.0.
### Added
- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6
- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type
- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns`
## 1.0.19 ## 1.0.19
Plugin API 1.11.0. Plugin API 1.11.0.
+4 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.19 | Plugin API 1.11.0 SDK 1.0.20 | Plugin API 1.12.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.
@@ -643,6 +643,9 @@ exports with props is `ui.api.txt` in the Termix repo
`PanePrompt` for small prompts. `PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, - Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`. forms end with `FormFooter`, empty lists show `EmptyState`.
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors). - Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the - Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.19", "version": "1.0.20",
"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": {
+24 -3
View File
@@ -1711,28 +1711,49 @@ export function usePluginUiPreferences<
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never; return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
} }
type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
const PANEL_GRID_SIZES: readonly string[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
/** /**
* Grid or list and row density for one of this plugin's views, kept in its * Grid or list, row density and cards per row for one of this plugin's
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those * views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
* keys in contributes.uiPresets so each preset level can pick a default. * and `<prefix>columns`. Declare those keys in contributes.uiPresets so each
* preset level can pick a default. Hand columns to ViewToggle and DataView.
*/ */
export function usePluginPanelView(prefix = ""): { export function usePluginPanelView(prefix = ""): {
view: "grid" | "list"; view: "grid" | "list";
density: "comfortable" | "compact"; density: "comfortable" | "compact";
compact: boolean; compact: boolean;
columns: PanelGridSize;
setView: (view: "grid" | "list") => void; setView: (view: "grid" | "list") => void;
setDensity: (density: "comfortable" | "compact") => void; setDensity: (density: "comfortable" | "compact") => void;
setColumns: (columns: PanelGridSize) => void;
} { } {
const { values, set } = usePluginUiPreferences(); const { values, set } = usePluginUiPreferences();
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid"; const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
const density = const density =
values[`${prefix}density`] === "compact" ? "compact" : "comfortable"; values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
const saved = values[`${prefix}columns`];
const columns =
typeof saved === "string" && PANEL_GRID_SIZES.includes(saved)
? (saved as PanelGridSize)
: "auto";
return { return {
view, view,
density, density,
compact: density === "compact", compact: density === "compact",
columns,
setView: (next) => set(`${prefix}viewMode`, next), setView: (next) => set(`${prefix}viewMode`, next),
setDensity: (next) => set(`${prefix}density`, next), setDensity: (next) => set(`${prefix}density`, next),
setColumns: (next) => set(`${prefix}columns`, next),
}; };
} }
+1 -1
View File
@@ -37,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.11.0"; export const PLUGIN_API_VERSION = "1.12.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(
+3 -2
View File
@@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder" DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T> DataColumn (type): DataColumn<T>
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
DesktopUpdateMode (type): DesktopUpdateMode DesktopUpdateMode (type): DesktopUpdateMode
@@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan GridColSpan (type): GridColSpan
GridColumns (type): GridColumns GridColumns (type): GridColumns
GridLayout (type): GridLayout GridLayout (type): GridLayout
GridSize (type): GridSize
GridSlot (type): GridSlot GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
@@ -321,7 +322,7 @@ UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element
WebSocketConnectionTarget (type): WebSocketConnectionTarget WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
actionOrder (value): (action: HostActionDef) => number actionOrder (value): (action: HostActionDef) => number
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Buttons are never white.
*
* Every Button variant is the brand orange with a different background
* opacity (or red for destructive, or transparent for ghost). Fails when a
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
* in its className.
*/
const fs = require("node:fs");
const path = require("node:path");
const NEUTRAL =
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
/** The opening tag text of each <Button ...> in a source file. */
function buttonTags(source) {
const tags = [];
const re = /<Button(?=[\s/>])/g;
let match;
while ((match = re.exec(source))) {
let depth = 0;
let quote = "";
let i = match.index + match[0].length;
for (; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = "";
} else if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push(source.slice(match.index, i + 1));
}
return tags;
}
/** Variant strings from the cva block of button.tsx. */
function variantLines(source) {
const start = source.indexOf("variant: {");
const end = source.indexOf("size: {");
if (start < 0 || end < 0) return [];
return source
.slice(start, end)
.split("\n")
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
}
/** Problems found under the given roots. */
function scan(roots, buttonFile) {
const problems = [];
if (buttonFile && fs.existsSync(buttonFile)) {
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
if (NEUTRAL.test(line)) {
problems.push(
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
);
}
}
}
const files = [];
for (const root of roots) walk(root, files);
for (const file of files) {
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
if (NEUTRAL.test(tag)) {
problems.push(
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
);
}
}
}
return problems;
}
module.exports = { scan, buttonTags, variantLines };
if (require.main === module) {
const core = path.resolve(__dirname, "..");
const plugins = path.resolve(core, "..", "Plugins");
const roots = [path.join(core, "src", "ui")];
if (fs.existsSync(plugins)) {
for (const entry of fs.readdirSync(plugins)) {
roots.push(path.join(plugins, entry, "src"));
}
}
const problems = scan(
roots,
path.join(core, "src", "ui", "components", "button.tsx"),
);
if (problems.length) {
console.error("Buttons must be the brand orange, never white or neutral:");
for (const p of problems) console.error(" " + p);
process.exit(1);
}
console.log("ok button colors");
}
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
scan: (roots: string[], buttonFile?: string) => string[];
buttonTags: (source: string) => string[];
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("buttonTags", () => {
it("reads a tag with arrow functions inside", () => {
const tags = buttonTags(
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
);
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('className="x"');
});
});
describe("check-button-colors", () => {
it("accepts orange buttons and hover fills", () => {
const dir = fixture({
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
});
expect(scan([dir])).toEqual([]);
});
it("rejects a white or neutral fill on a Button", () => {
for (const cls of [
"bg-primary",
"bg-white",
"bg-foreground text-background",
"dark:bg-secondary",
"bg-muted",
"text-primary-foreground",
]) {
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
expect(scan([dir]), cls).toHaveLength(1);
}
});
it("rejects a neutral fill in a variant", () => {
const dir = fixture({
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
});
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
});
it("passes on the real button", () => {
const button = path.resolve(
__dirname,
"..",
"src",
"ui",
"components",
"button.tsx",
);
expect(scan([], button)).toEqual([]);
});
});
+1
View File
@@ -151,6 +151,7 @@ const ALLOWED = [
"GridCardCatalogEntry", "GridCardCatalogEntry",
"GridColSpan", "GridColSpan",
"GridColumns", "GridColumns",
"GridSize",
"GridLayout", "GridLayout",
"GridSlot", "GridSlot",
"GroupHeading", "GroupHeading",
+2
View File
@@ -21,6 +21,8 @@ const packages = [
"@img/sharp-win32-x64", "@img/sharp-win32-x64",
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`); ].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
// A .cmd file only starts through a shell on current Node.
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], { execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
stdio: "inherit", stdio: "inherit",
shell: true,
}); });
+12 -2
View File
@@ -137,9 +137,19 @@ function verifyArtifact(artifact) {
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`); console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
} finally { } finally {
if (mountedAt) { if (mountedAt) {
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" }); for (let attempt = 0; attempt < 5; attempt++) {
const args =
attempt < 3 ? ["detach", mountedAt] : ["detach", "-force", mountedAt];
if (spawnSync("hdiutil", args, { stdio: "ignore" }).status === 0) break;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
}
} }
fs.rmSync(temporaryRoot, { recursive: true, force: true }); fs.rmSync(temporaryRoot, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 1000,
});
} }
} }
+5 -4
View File
@@ -10,16 +10,17 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default:
"border-accent-brand/40 bg-accent-brand/15 text-accent-brand hover:bg-accent-brand/25 aria-expanded:bg-accent-brand/25",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-accent-brand/40 bg-transparent text-accent-brand hover:bg-accent-brand/10 aria-expanded:bg-accent-brand/10",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "border-accent-brand/20 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 aria-expanded:bg-accent-brand/20",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-accent-brand underline-offset-4 hover:underline",
}, },
size: { size: {
default: default:
+21 -2
View File
@@ -2,6 +2,8 @@ import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
PANEL, PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity, type PanelDensity,
type PanelViewMode, type PanelViewMode,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
@@ -67,6 +69,8 @@ export function DataView<T>({
getKey, getKey,
renderCard, renderCard,
columns, columns,
size,
minCardWidth,
listColumns, listColumns,
onRowClick, onRowClick,
isRowActive, isRowActive,
@@ -78,7 +82,12 @@ export function DataView<T>({
density?: PanelDensity; density?: PanelDensity;
getKey: (item: T) => string; getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode; renderCard: (item: T) => React.ReactNode;
/** Breakpoint columns, used when no size is given. */
columns: GridColumns; columns: GridColumns;
/** Columns the user picked with ViewToggle. Wins over columns. */
size?: GridSize;
/** Narrowest card in auto mode, in px. */
minCardWidth?: number;
listColumns: DataColumn<T>[]; listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void; onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean; isRowActive?: (item: T) => boolean;
@@ -93,9 +102,19 @@ export function DataView<T>({
if (view === "grid") { if (view === "grid") {
return ( return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}> <div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
>
{items.map((item) => ( {items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div> <div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
))} ))}
</div> </div>
); );
+82 -3
View File
@@ -1,11 +1,26 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react"; import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react"; import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react"; import {
ArrowLeft,
Columns3,
Grid3X3,
List,
Rows3,
Search,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
/** One spacing rhythm for every panel and tab. */ /** One spacing rhythm for every panel and tab. */
export const PANEL = { export const PANEL = {
@@ -19,6 +34,37 @@ export const PANEL = {
export type PanelViewMode = "grid" | "list"; export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact"; export type PanelDensity = "comfortable" | "compact";
/** Cards per row. "auto" fits as many as the pane has room for. */
export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
export const GRID_SIZES: readonly GridSize[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
// Sized off the pane's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
export function gridSizeStyle(
size: GridSize,
minCardWidth = 320,
gap = "0.5rem",
): React.CSSProperties {
if (size === "auto") {
return {
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
};
}
const n = Number(size);
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`,
};
}
/** The header, toolbar band and body every tab and panel draws. */ /** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({ export function PanelShell({
chrome = true, chrome = true,
@@ -308,20 +354,25 @@ export function Segmented<T extends string>({
); );
} }
/** Grid or list, plus the compact rows toggle. */ /** Grid or list, plus the compact rows toggle and the cards per row picker. */
export function ViewToggle({ export function ViewToggle({
view, view,
onView, onView,
density, density,
onDensity, onDensity,
columns,
onColumns,
}: { }: {
view: PanelViewMode; view: PanelViewMode;
onView: (next: PanelViewMode) => void; onView: (next: PanelViewMode) => void;
density?: PanelDensity; density?: PanelDensity;
onDensity?: (next: PanelDensity) => void; onDensity?: (next: PanelDensity) => void;
/** Cards per row in grid view. Pass with onColumns to show the picker. */
columns?: GridSize;
onColumns?: (next: GridSize) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( const toggle = (
<div className="flex items-center border border-border"> <div className="flex items-center border border-border">
<ToggleButton <ToggleButton
active={view === "grid"} active={view === "grid"}
@@ -356,6 +407,34 @@ export function ViewToggle({
)} )}
</div> </div>
); );
if (!columns || !onColumns || view !== "grid") return toggle;
return (
<div className="flex items-center gap-2">
<Select value={columns} onValueChange={(v) => onColumns(v as GridSize)}>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("panel.columns.title")}
aria-label={t("panel.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("panel.columns.auto")
: t("panel.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
{toggle}
</div>
);
} }
function ToggleButton({ function ToggleButton({
+8 -6
View File
@@ -1098,6 +1098,8 @@
"resetEveryone": "Reset the default for everyone", "resetEveryone": "Reset the default for everyone",
"update": "Update {{name}} to {{version}}", "update": "Update {{name}} to {{version}}",
"disable": "Turn off {{name}}", "disable": "Turn off {{name}}",
"enable": "Turn on {{name}}",
"startAgain": "Start {{name}} again",
"install": "Find layouts and themes", "install": "Find layouts and themes",
"installTitle": "Layouts and themes", "installTitle": "Layouts and themes",
"installButton": "Install", "installButton": "Install",
@@ -1323,11 +1325,6 @@
"updated": "Recently updated", "updated": "Recently updated",
"category": "Category" "category": "Category"
}, },
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"table": { "table": {
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
@@ -1652,7 +1649,12 @@
"gridView": "Grid", "gridView": "Grid",
"listView": "List", "listView": "List",
"compactRows": "Compact rows", "compactRows": "Compact rows",
"comfortableRows": "Comfortable rows" "comfortableRows": "Comfortable rows",
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
}
}, },
"fields": { "fields": {
"secretSaved": "Saved. Type to replace it." "secretSaved": "Saved. Type to replace it."
+12
View File
@@ -81,6 +81,18 @@ function AdminTools({
})} })}
</Button> </Button>
)} )}
{entry?.installed && !entry.enabled && (
<Button
size="sm"
onClick={() => void manager.toggle(entry).then(onRetry)}
disabled={manager.busy.has(entry.id)}
>
<Power />
{status.reason === "disabled"
? t("looks.repair.enable", { name: entry.name })
: t("looks.repair.startAgain", { name: entry.name })}
</Button>
)}
{entry?.installed && entry.enabled && !entry.managedByServer && ( {entry?.installed && entry.enabled && !entry.managedByServer && (
<Button <Button
size="sm" size="sm"
+3 -1
View File
@@ -79,13 +79,15 @@ export function layoutStatus(input: LayoutStatusInput): LayoutStatus {
const owner = input.owner; const owner = input.owner;
if (!owner) return repair("missing"); if (!owner) return repair("missing");
const summary = owner.summary; const summary = owner.summary;
if (!summary.enabled) return repair("disabled"); // Before the enabled check: the server reports a plugin that failed to
// start as not enabled, which hid the real error behind "turned off".
if (owner.frontend === "blocked" || summary.state === "blocked") { if (owner.frontend === "blocked" || summary.state === "blocked") {
return repair("blocked", { error: owner.error ?? summary.lastError }); return repair("blocked", { error: owner.error ?? summary.lastError });
} }
if (owner.frontend === "failed" || summary.state === "failed") { if (owner.frontend === "failed" || summary.state === "failed") {
return repair("failed", { error: owner.error ?? summary.lastError }); return repair("failed", { error: owner.error ?? summary.lastError });
} }
if (!summary.enabled) return repair("disabled");
if (owner.frontend === "loading") return { kind: "loading" }; if (owner.frontend === "loading") return { kind: "loading" };
// Just chosen: the runtime is about to load its code. // Just chosen: the runtime is about to load its code.
if ( if (
+1
View File
@@ -289,6 +289,7 @@ export {
PanelShell, PanelShell,
Segmented, Segmented,
ViewToggle, ViewToggle,
type GridSize,
type PanelDensity, type PanelDensity,
type PanelViewMode, type PanelViewMode,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
+19 -55
View File
@@ -5,7 +5,6 @@ import {
Check, Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Columns3,
Download, Download,
Globe, Globe,
Library, Library,
@@ -37,7 +36,9 @@ import {
GroupHeading, GroupHeading,
PANEL, PANEL,
PanelShell, PanelShell,
GRID_SIZES,
ViewToggle, ViewToggle,
type GridSize,
type PanelViewMode, type PanelViewMode,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
import { DataView, type DataColumn } from "@/components/data-view"; import { DataView, type DataColumn } from "@/components/data-view";
@@ -72,9 +73,6 @@ import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates" | "registries"; type Section = "installed" | "browse" | "updates" | "registries";
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number];
interface Layout { interface Layout {
view: PanelViewMode; view: PanelViewMode;
columns: GridSize; columns: GridSize;
@@ -364,54 +362,16 @@ function LayoutControls({
onView: (next: PanelViewMode) => void; onView: (next: PanelViewMode) => void;
onColumns: (next: GridSize) => void; onColumns: (next: GridSize) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<> <ViewToggle
{layout.view === "grid" && ( view={layout.view}
<Select onView={onView}
value={layout.columns} columns={layout.columns}
onValueChange={(v) => onColumns(v as GridSize)} onColumns={onColumns}
> />
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("plugins.manager.columns.title")}
aria-label={t("plugins.manager.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("plugins.manager.columns.auto")
: t("plugins.manager.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ViewToggle view={layout.view} onView={onView} />
</>
); );
} }
// Sized off the tab's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
function gridStyle(columns: GridSize): React.CSSProperties {
if (columns === "auto") {
return {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))",
};
}
const n = Number(columns);
const gaps = (n - 1) * 12;
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`,
};
}
function PluginCollection({ function PluginCollection({
plugins, plugins,
layout, layout,
@@ -440,13 +400,17 @@ function PluginCollection({
); );
} }
return ( return (
<div className="grid gap-3" style={gridStyle(layout.columns)}> <DataView
{plugins.map((p) => ( items={plugins}
<div key={p.id} className="flex flex-col *:flex-1"> view="grid"
{renderCard(p)} getKey={(p) => p.id}
</div> renderCard={renderCard}
))} columns={{}}
</div> size={layout.columns}
minCardWidth={400}
listColumns={listColumns}
empty={null}
/>
); );
} }
@@ -34,6 +34,24 @@ describe("DataView", () => {
expect(screen.queryByText("Name")).toBeNull(); expect(screen.queryByText("Name")).toBeNull();
}); });
it("uses the picked size over breakpoint columns", () => {
const { container } = render(
<DataView
items={items}
view="grid"
getKey={(item) => item.id}
renderCard={(item) => <div>card {item.name}</div>}
columns={{ base: 1, md: 2 }}
size="3"
listColumns={[]}
empty={null}
/>,
);
const grid = container.firstElementChild as HTMLElement;
expect(grid.className).not.toContain("md:grid-cols-2");
expect(grid.style.gridTemplateColumns).toContain("/ 3");
});
it("renders a header and clickable rows in list mode", () => { it("renders a header and clickable rows in list mode", () => {
const onRowClick = vi.fn(); const onRowClick = vi.fn();
renderView("list", onRowClick); renderView("list", onRowClick);
@@ -11,6 +11,7 @@ import {
PanelShell, PanelShell,
Segmented, Segmented,
ViewToggle, ViewToggle,
gridSizeStyle,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
afterEach(cleanup); afterEach(cleanup);
@@ -72,6 +73,34 @@ describe("ViewToggle", () => {
fireEvent.click(screen.getByLabelText("panel.compactRows")); fireEvent.click(screen.getByLabelText("panel.compactRows"));
expect(onDensity).toHaveBeenCalledWith("compact"); expect(onDensity).toHaveBeenCalledWith("compact");
}); });
it("shows the columns picker only in grid view", () => {
const props = { onView: vi.fn(), columns: "auto" as const };
const { rerender } = render(
<ViewToggle view="grid" {...props} onColumns={vi.fn()} />,
);
expect(screen.getByLabelText("panel.columns.title")).toBeTruthy();
rerender(<ViewToggle view="list" {...props} onColumns={vi.fn()} />);
expect(screen.queryByLabelText("panel.columns.title")).toBeNull();
rerender(<ViewToggle view="grid" {...props} />);
expect(screen.queryByLabelText("panel.columns.title")).toBeNull();
});
});
describe("gridSizeStyle", () => {
it("fits auto cards to the pane", () => {
expect(gridSizeStyle("auto", 280).gridTemplateColumns).toBe(
"repeat(auto-fill, minmax(min(100%, 280px), 1fr))",
);
});
it("splits the width for a fixed count", () => {
expect(gridSizeStyle("4").gridTemplateColumns).toContain(
"(100% - 3 * 0.5rem) / 4",
);
});
}); });
describe("Facts", () => { describe("Facts", () => {
+29 -1
View File
@@ -53,6 +53,8 @@ vi.mock("@/look/use-look-theme", () => ({
useChooseTheme: () => look.chooseTheme, useChooseTheme: () => look.chooseTheme,
})); }));
const fancy = vi.hoisted(() => ({ enabled: true }));
const manager = vi.hoisted(() => ({ const manager = vi.hoisted(() => ({
requestUpdate: vi.fn(), requestUpdate: vi.fn(),
requestInstall: vi.fn(), requestInstall: vi.fn(),
@@ -72,7 +74,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
id: "fancy-layout", id: "fancy-layout",
name: "Fancy", name: "Fancy",
installed: true, installed: true,
enabled: true, enabled: fancy.enabled,
updateAvailable: true, updateAvailable: true,
latestVersion: "1.1.0", latestVersion: "1.1.0",
managedByServer: false, managedByServer: false,
@@ -115,6 +117,7 @@ const onRetry = vi.fn();
beforeEach(() => { beforeEach(() => {
permissions.isAdmin = true; permissions.isAdmin = true;
fancy.enabled = true;
for (const fn of [ for (const fn of [
...Object.values(look), ...Object.values(look),
...Object.values(manager), ...Object.values(manager),
@@ -190,6 +193,31 @@ describe("RepairView", () => {
); );
}); });
it("lets an admin turn the plugin back on and then tries again", async () => {
fancy.enabled = false;
manager.toggle.mockResolvedValue(undefined);
render(
<RepairView
status={{ ...status, reason: "disabled", error: null }}
onRetry={onRetry}
/>,
);
expect(screen.queryByText(/looks\.repair\.disable/)).toBeNull();
fireEvent.click(screen.getByText('looks.repair.enable:{"name":"Fancy"}'));
expect(manager.toggle).toHaveBeenCalledWith(
expect.objectContaining({ id: "fancy-layout", enabled: false }),
);
await waitFor(() => expect(onRetry).toHaveBeenCalled());
});
it("offers to start a failed plugin again", () => {
fancy.enabled = false;
render(<RepairView status={status} onRetry={onRetry} />);
expect(
screen.getByText('looks.repair.startAgain:{"name":"Fancy"}'),
).toBeTruthy();
});
it("lets an admin restore the bundled copy and then tries again", async () => { it("lets an admin restore the bundled copy and then tries again", async () => {
render(<RepairView status={status} onRetry={onRetry} />); render(<RepairView status={status} onRetry={onRetry} />);
fireEvent.click(screen.getByText('looks.repair.restore:{"name":"Fancy"}')); fireEvent.click(screen.getByText('looks.repair.restore:{"name":"Fancy"}'));
+13
View File
@@ -120,6 +120,19 @@ describe("layoutStatus", () => {
).toMatchObject({ reason: "failed", error: "server" }); ).toMatchObject({ reason: "failed", error: "server" });
}); });
it("says failed, not disabled, when the server could not start it", () => {
expect(
layoutStatus(
input({
owner: owner(
{ enabled: false, state: "failed", lastError: "bad bundle" },
"none",
),
}),
),
).toMatchObject({ reason: "failed", error: "bad bundle" });
});
it("says incompatible when the server skipped its plugin", () => { it("says incompatible when the server skipped its plugin", () => {
expect( expect(
layoutStatus( layoutStatus(
+2 -6
View File
@@ -183,17 +183,13 @@ describe("PluginsTab", () => {
it("switches to a table and remembers it", async () => { it("switches to a table and remembers it", async () => {
await renderTab(); await renderTab();
expect(screen.queryByRole("table")).toBeNull(); expect(screen.queryByRole("table")).toBeNull();
expect( expect(screen.getByLabelText(en.panel.columns.title)).toBeTruthy();
screen.getByLabelText(en.plugins.manager.columns.title),
).toBeTruthy();
fireEvent.click(screen.getByLabelText(en.panel.listView)); fireEvent.click(screen.getByLabelText(en.panel.listView));
expect(screen.getByRole("table")).toBeTruthy(); expect(screen.getByRole("table")).toBeTruthy();
expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy(); expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy();
expect( expect(screen.queryByLabelText(en.panel.columns.title)).toBeNull();
screen.queryByLabelText(en.plugins.manager.columns.title),
).toBeNull();
expect(localStorage.getItem("pluginsTab.view")).toBe("list"); expect(localStorage.getItem("pluginsTab.view")).toBe("list");
cleanup(); cleanup();