mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c265c4bc6 | ||
|
|
57eb7fab5c | ||
|
|
a73782e2bc | ||
|
|
18e7f5689b | ||
|
|
a91339f09f | ||
|
|
a2308c9d71 | ||
|
|
2ee31ac9cc | ||
|
|
4aead47049 | ||
|
|
19bdd589f3 | ||
|
|
1867be3f58 | ||
|
|
09e04d076a | ||
|
|
ea4e2d2124 | ||
|
|
c7aa1c64b5 | ||
|
|
9613b787a7 | ||
|
|
1c4fa52de3 | ||
|
|
2e79bf9d0d | ||
|
|
03029cd372 | ||
|
|
446596989e | ||
|
|
b7f749cc9d | ||
|
|
3ae56a96d0 | ||
|
|
631f6ac9f7 |
No files matched your search
+22
-1
@@ -5,13 +5,23 @@
|
||||
### Added
|
||||
|
||||
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
||||
- API keys can be limited to reading or running actions
|
||||
- API keys can be limited to reading or running actions, when an admin makes them
|
||||
- A click on a host can open its host page, when a plugin offers one
|
||||
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
|
||||
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
|
||||
|
||||
### Changed
|
||||
|
||||
- Plugin cards and pages use the dashboard look everywhere, with shared lines between sections instead of separate boxes with gaps
|
||||
- The Plugins tab and plugin pages use the same look
|
||||
- Line charts that cover a few minutes show seconds in their times
|
||||
- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on
|
||||
|
||||
- Host status, import and export, and credential tools now start on for new installs
|
||||
- Sync now starts on in a new desktop app, so it can link to a server
|
||||
- The first run plugin picker leaves plugins you did not choose off instead of removing them
|
||||
- Dropdowns and text areas are the same size as the text fields next to them
|
||||
- A page's own tabs sit above its search bar
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -19,6 +29,17 @@
|
||||
- Alerts with no icon no longer squeeze their text to one word per line
|
||||
- Password fields with the show button are the same height as the other fields next to them
|
||||
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
|
||||
- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot
|
||||
- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind
|
||||
- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one
|
||||
- Release notes on a plugin's page are no longer cut off on the left
|
||||
- The Community badge in Browse is no longer missing its top border
|
||||
- Official plugin pages no longer show a stray divider before the author
|
||||
- The version picker on a plugin's page is no longer empty for a plugin installed from a local build
|
||||
- The plugin video no longer has a second border around it
|
||||
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
|
||||
- Sign in methods on the Security page have a line between them
|
||||
- Chart axis labels with long units, like KiB/s, are no longer cut off
|
||||
|
||||
## 26.11.0
|
||||
|
||||
|
||||
@@ -64,10 +64,13 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||
| [Backups](https://github.com/Termix-SSH/Plugin-Backups) | Back up folders, databases and Docker data on your hosts with restic, then browse, download and restore. |
|
||||
| [Certificates](https://github.com/Termix-SSH/Plugin-Certificates) | See, issue and renew TLS certificates on your hosts with certbot and acme.sh, and get an alert before they expire. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Cron](https://github.com/Termix-SSH/Plugin-Cron) | See, add and run cron jobs on your hosts, with a schedule preview and the system cron files. |
|
||||
| [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. |
|
||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||
@@ -82,7 +85,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [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. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Packages](https://github.com/Termix-SSH/Plugin-Package-Manager) | See and install updates on your hosts, search, install and remove packages, and read the package history. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
@@ -106,6 +111,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
||||
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||
| [Users](https://github.com/Termix-SSH/Plugin-User-Manager) | Manage the user accounts, groups, passwords and SSH keys on your hosts. |
|
||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
|
||||
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget iputils-ping && \
|
||||
update-ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.11.0",
|
||||
"version": "26.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.11.0",
|
||||
"version": "26.11.1",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.11.0",
|
||||
"version": "26.11.1",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.0
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type
|
||||
- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid
|
||||
- Dashboard cards get `config`, `setConfig` and `editMode`
|
||||
- `/ui` exports `TileWall` (a fixed grid of tiles with shared lines), `TileStack` and `TileColumn` (sections stacked with a line between), `TileEditBar`, and `SeamlessTileContext` with `useInSeamlessTile`
|
||||
- `/ui` exports `StatTile`, `InfoGrid`, `StatusDot`, `StatusBadge` and `statusTone`, `LoadingState`, `NoticePanel`, `Pager` with `usePaged` and `pageOf`, `DataTable` with `DataTableRow`, `SideNav` with `SideNavItem`, `PanelFrame` and `RefreshButton`
|
||||
- `InlineView` takes `stacked`, which lays SectionCards out in one column with lines between them
|
||||
- `GroupHeading` takes `plain`, which drops the rule after the title. Inside a tile layout that is the default
|
||||
|
||||
### Changed
|
||||
|
||||
- `Card`, `SectionCard`, `MetricCard` and `DataView` use the tile look: no fill, and inside a tile layout no box either. `DataView` grids sit edge to edge with shared lines
|
||||
|
||||
## 1.0.25
|
||||
|
||||
Plugin API 1.14.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Host click behavior `hostPage` opens a host page when a plugin offers one
|
||||
- `createApiKey` takes scopes, to limit a key to actions
|
||||
|
||||
## 1.0.24
|
||||
|
||||
Plugin API 1.14.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerHostSection` adds a card to the host page
|
||||
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
|
||||
|
||||
## 1.0.23
|
||||
|
||||
Plugin API 1.14.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.23 | Plugin API 1.14.0
|
||||
SDK 1.1.0 | Plugin API 1.15.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -766,9 +766,28 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
- Small type: most text 11 to 13px.
|
||||
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
|
||||
- Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and
|
||||
`ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows
|
||||
`LoadingState`, and "not installed" or "no access" shows `NoticePanel`.
|
||||
- The tile look: page content sits edge to edge on the page background, with one line between sections and
|
||||
no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with
|
||||
`TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of
|
||||
`StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside
|
||||
these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s
|
||||
takes `stacked`.
|
||||
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes.
|
||||
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with
|
||||
`RefreshButton`, and the docs link with the `docs` prop.
|
||||
- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows),
|
||||
`InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists.
|
||||
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
|
||||
`{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps
|
||||
preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. While editing, show
|
||||
`TileEditBar` over the grid. Needs Termix 26.11.1.
|
||||
- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the
|
||||
card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for
|
||||
status and action tiles.
|
||||
- Card grids use `DataView` with `ViewToggle` (the cards share lines like a `TileWall`). 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.23",
|
||||
"version": "1.1.0",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||
/** A control in the host editor's toolbar. */
|
||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||
|
||||
export interface HostSectionProps {
|
||||
host: PluginHostRecord;
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
export interface HostSectionContribution {
|
||||
/** Declare it in manifest contributes.hostSections too. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
/** Lower sorts first. */
|
||||
order?: number;
|
||||
when?: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<HostSectionProps>;
|
||||
}
|
||||
|
||||
export interface HostContextMenuItemContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
@@ -783,6 +799,26 @@ export interface KeybindingDefaultContribution {
|
||||
export interface DashboardCardProps {
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
/** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */
|
||||
config?: Record<string, unknown>;
|
||||
/** Saves this tile's settings. Keep it small, it is stored in one line. */
|
||||
setConfig?: (config: Record<string, unknown>) => void;
|
||||
/** True while the user is arranging the dashboard. */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
/** One tile a card with `entries` offers in the dashboard's add list. */
|
||||
export interface DashboardCardEntry {
|
||||
/** Stable within the card, used to mark entries already placed. */
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading in the add list. Defaults to the card title. */
|
||||
group?: string;
|
||||
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
|
||||
/** Passed back as `config`. */
|
||||
config: Record<string, unknown>;
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
}
|
||||
|
||||
export interface DashboardCardContribution {
|
||||
@@ -797,6 +833,13 @@ export interface DashboardCardContribution {
|
||||
* edge, for a strip of numbers. Defaults to "framed".
|
||||
*/
|
||||
frame?: "framed" | "bare";
|
||||
/** Size on the 12 column dashboard grid when added. Core 26.11.1+. */
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
/**
|
||||
* Lets the card be placed many times, once per entry, each with its own
|
||||
* `config`. Titles come back already translated. Core 26.11.1+.
|
||||
*/
|
||||
entries?: (language: string) => Promise<DashboardCardEntry[]>;
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
@@ -1172,6 +1215,11 @@ export interface TermixApp extends TermixAppInfo {
|
||||
registerHostContextMenuItem: (
|
||||
item: HostContextMenuItemContribution,
|
||||
) => Disposer;
|
||||
/**
|
||||
* A card on the host page. Core 26.11.1 and newer; call it as
|
||||
* app.registerHostSection?.(...) if your plugin allows older cores.
|
||||
*/
|
||||
registerHostSection: (section: HostSectionContribution) => Disposer;
|
||||
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
||||
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
||||
|
||||
@@ -42,7 +42,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.14.0";
|
||||
export const PLUGIN_API_VERSION = "1.15.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes<HTMLTableElement> & TableHTMLAttributes<HTMLTableElement> & { containerClassName?: string; }) => Element
|
||||
DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes<HTMLTableRowElement> & HTMLAttributes<HTMLTableRowElement> & { active?: boolean; }) => 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
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
@@ -133,7 +135,7 @@ GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSize (type): GridSize
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
GroupHeading (value): ({ title, count, action, plain, className, }: { title: string; count?: number; action?: ReactNode; plain?: boolean; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
Host (type): Host
|
||||
HostActionDef (type): HostActionDef
|
||||
@@ -159,12 +161,16 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
HostRowFields (type): HostRowFields
|
||||
HostSectionDef (type): HostSectionDef
|
||||
HostSidebarFilterState (type): HostSidebarFilterState
|
||||
HostSidebarPreferences (type): HostSidebarPreferences
|
||||
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
|
||||
HostTrayTrigger (type): HostTrayTrigger
|
||||
HttpsSettings (type): HttpsSettings
|
||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
InfoGrid (value): ({ items, className, }: { items: InfoItem[]; className?: string; }) => Element
|
||||
InfoItem (type): InfoItem
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, stacked, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
@@ -178,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelEle
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "warning" | "muted" | "brand" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
ListRowTone (type): ListRowTone
|
||||
LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||
@@ -199,6 +206,7 @@ MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scr
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
|
||||
NativeViewProps (type): NativeViewProps
|
||||
NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }: { icon?: LucideIcon; title: ReactNode; children?: ReactNode; action?: ReactNode; tone?: "default" | "destructive" | "warning"; className?: string; }) => Element
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
@@ -207,9 +215,11 @@ PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element
|
||||
PaletteItemDef (type): PaletteItemDef
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelFrame (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
|
||||
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
|
||||
@@ -234,6 +244,7 @@ RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
RailItemDef (type): RailItemDef
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element
|
||||
ReleaseInfo (type): ReleaseInfo
|
||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||
ResolvedHostDefault (type): ResolvedHostDefault
|
||||
@@ -246,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus
|
||||
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SeamlessTileContext (value): Context<boolean>
|
||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
|
||||
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
|
||||
@@ -275,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ShellConfirmCapture (value): () => null
|
||||
SideNav (value): ({ entries, active, onSelect, footer, label, className, }: { entries: SideNavEntry[]; label?: string; active: string; onSelect: (id: string) => void; footer?: ReactNode; className?: string; }) => Element
|
||||
SideNavEntry (type): SideNavEntry
|
||||
SideNavItem (value): ({ entry, active, onClick, }: { entry: SideNavEntry; active: boolean; onClick: () => void; }) => Element
|
||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
SortKey (type): SortKey
|
||||
@@ -284,11 +299,16 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a
|
||||
SplitOpenTarget (type): SplitOpenTarget
|
||||
SplitSummary (type): SplitSummary
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
StatTile (value): ({ label, icon, value, unit, percent, hint, tone, actions, onClick, children, className, }: { label: ReactNode; icon?: ReactNode; value: ReactNode; unit?: ReactNode; percent?: number; hint?: ReactNode; tone?: "default" | "ok" | "warn" | "bad"; actions?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; }) => Element
|
||||
StatusBadge (value): ({ tone, state, className, children, }: { tone?: StatusTone; state?: string; className?: string; children?: ReactNode; }) => Element
|
||||
StatusDot (value): ({ tone, state, pulse, className, title, }: { tone?: StatusTone; state?: string; pulse?: boolean; className?: string; title?: string; }) => Element
|
||||
StatusTone (type): StatusTone
|
||||
StatusValue (type): StatusValue
|
||||
SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => 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
|
||||
TILE_COLUMNS (value): 12
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -303,6 +323,17 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
ThemeId (type): string
|
||||
TileColumn (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
TileEditBar (value): ({ children, onReset, onAdd, onDone, className, }: { children?: ReactNode; onReset?: () => void; onAdd?: () => void; onDone: () => void; className?: string; }) => Element
|
||||
TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element
|
||||
TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element
|
||||
TileGridProps (type): TileGridProps
|
||||
TileKind (type): TileKind
|
||||
TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element
|
||||
TilePickerEntry (type): TilePickerEntry
|
||||
TileSlot (type): TileSlot
|
||||
TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
@@ -339,6 +370,7 @@ adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||
appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[]
|
||||
applyAccentColor (value): (colorValue: string) => void
|
||||
applyFontSize (value): (id: FontSizeId) => void
|
||||
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
|
||||
@@ -354,7 +386,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
|
||||
clearLocalAdaptivePreferences (value): () => void
|
||||
cn (value): (...inputs: ClassValue[]) => string
|
||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
|
||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||
@@ -382,6 +414,7 @@ endSplitDrag (value): (commit: boolean) => void
|
||||
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[]
|
||||
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||
@@ -457,6 +490,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
|
||||
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||
invalidateHostsAndStatusCaches (value): () => void
|
||||
@@ -475,6 +509,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
|
||||
linkedServerUrl (value): () => Promise<string>
|
||||
listHostActions (value): () => HostActionDef[]
|
||||
listHostProtocols (value): () => HostProtocolDef[]
|
||||
listHostSections (value): () => HostSectionDef[]
|
||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
@@ -488,6 +523,8 @@ moveSplitDrag (value): (x: number, y: number) => void
|
||||
normalizeLanguageCode (value): (language?: string) => string
|
||||
notifySyncChanged (value): () => void
|
||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||
operationsApi (value): AxiosInstance
|
||||
pageOf (value): <T>(items: T[], page: number, size: number) => { rows: T[]; page: number; pages: number; }
|
||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||
parseServerTime (value): (ts: string) => Date
|
||||
@@ -509,6 +546,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
rem (value): (px: number) => string
|
||||
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
removeTile (value): (slots: TileSlot[], id: string) => TileSlot[]
|
||||
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
@@ -538,6 +576,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||
sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[]
|
||||
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||
@@ -556,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
statusTone (value): (state: string) => StatusTone
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
@@ -598,8 +638,10 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||
useHostProtocols (value): () => HostProtocolDef[]
|
||||
useHostSections (value): () => HostSectionDef[]
|
||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||
useInSeamlessTile (value): () => boolean
|
||||
useInstalledPlugins (value): () => PluginSummary[]
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
@@ -607,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[]
|
||||
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||
usePaged (value): <T>(items: T[], size: number, reset?: unknown) => { setPage: Dispatch<SetStateAction<number>>; rows: T[]; page: number; pages: number; }
|
||||
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||
usePanels (value): () => PanelDef[]
|
||||
|
||||
@@ -74,7 +74,7 @@ async function main() {
|
||||
const staged = [...pinned, ...extra];
|
||||
fs.writeFileSync(
|
||||
path.join(destination, "bundled-index.json"),
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)}
|
||||
`,
|
||||
);
|
||||
|
||||
|
||||
@@ -221,7 +221,34 @@ describe("buildBundledIndex", () => {
|
||||
];
|
||||
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
|
||||
version: 1,
|
||||
profile: "standard",
|
||||
plugins: { a: { onboarding: { recommended: true, consent: false } } },
|
||||
catalog: {
|
||||
a: { tier: "full" },
|
||||
b: { tier: "full" },
|
||||
c: { tier: "full" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists every pin with its tier and version", () => {
|
||||
const plugins = [
|
||||
{
|
||||
id: "a",
|
||||
tier: "standard",
|
||||
url: "https://example.com/v1.2.0/a-1.2.0.tmxplug",
|
||||
onboarding: null,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
tier: "minimal",
|
||||
path: "x/b-2.0.0-beta.1.tmxplug",
|
||||
onboarding: null,
|
||||
},
|
||||
];
|
||||
expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({
|
||||
a: { tier: "standard", version: "1.2.0" },
|
||||
b: { tier: "minimal", version: "2.0.0-beta.1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -520,7 +547,7 @@ describe("default look", () => {
|
||||
],
|
||||
["look", "plain"],
|
||||
),
|
||||
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
|
||||
const index = {
|
||||
plugins: [
|
||||
|
||||
@@ -19,6 +19,49 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"SeamlessTileContext",
|
||||
"TileEditBar",
|
||||
"TileStack",
|
||||
"TileColumn",
|
||||
"TileWall",
|
||||
"useInSeamlessTile",
|
||||
"NoticePanel",
|
||||
"LoadingState",
|
||||
"StatusBadge",
|
||||
"StatusDot",
|
||||
"statusTone",
|
||||
"StatusTone",
|
||||
"StatTile",
|
||||
"InfoGrid",
|
||||
"InfoItem",
|
||||
"Pager",
|
||||
"pageOf",
|
||||
"usePaged",
|
||||
"DataTable",
|
||||
"DataTableRow",
|
||||
"SideNav",
|
||||
"SideNavItem",
|
||||
"SideNavEntry",
|
||||
"PanelFrame",
|
||||
"RefreshButton",
|
||||
"TileFrame",
|
||||
"TileGrid",
|
||||
"TilePicker",
|
||||
"appendTile",
|
||||
"flowTiles",
|
||||
"removeTile",
|
||||
"sanitizeTiles",
|
||||
"TILE_COLUMNS",
|
||||
"TileGridProps",
|
||||
"TileKind",
|
||||
"TilePickerEntry",
|
||||
"TileSlot",
|
||||
"useHostSections",
|
||||
"operationsApi",
|
||||
"listHostSections",
|
||||
"hostSectionsFor",
|
||||
"HostTabEmbed",
|
||||
"HostSectionDef",
|
||||
"ACCENT_PRESET_COLORS",
|
||||
"APP_VERSION",
|
||||
"AccessRecord",
|
||||
|
||||
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
|
||||
|
||||
/**
|
||||
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
|
||||
* staged plugin, read by the backend when it seeds a fresh install.
|
||||
* staged plugin, read by the backend when it seeds a fresh install, plus the
|
||||
* profile and every pin it ships, so an existing install can fetch a shipped
|
||||
* plugin it finds missing.
|
||||
*/
|
||||
function buildBundledIndex(plugins, stagedIds) {
|
||||
function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
const catalog = {};
|
||||
for (const plugin of plugins) {
|
||||
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec(
|
||||
plugin.url ?? plugin.path ?? "",
|
||||
)?.[1];
|
||||
catalog[plugin.id] = {
|
||||
tier: plugin.tier ?? "full",
|
||||
...(version ? { version } : {}),
|
||||
};
|
||||
if (!staged.has(plugin.id)) continue;
|
||||
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||
out[plugin.id] = {
|
||||
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||
};
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
return { version: 1, profile, plugins: out, catalog };
|
||||
}
|
||||
|
||||
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
/** JSON list, or null for full access. */
|
||||
scopes: string | null;
|
||||
}
|
||||
|
||||
export class ApiKeyRepository {
|
||||
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
scopes: apiKeys.scopes,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||
|
||||
@@ -622,6 +622,7 @@ router.get("/signals", authenticateJWT, async (req, res) => {
|
||||
userId: authReq.userId,
|
||||
actionId: signal.read,
|
||||
hostIds,
|
||||
freshMs: signal.refreshSec ? signal.refreshSec * 1000 : undefined,
|
||||
origin: originOf(authReq),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Optional expiration date. Null means the key never expires.
|
||||
* scopes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enum: [operations:read, operations:run]
|
||||
* description: Limits the key to actions only. Leave out for full access.
|
||||
* responses:
|
||||
* 201:
|
||||
* description: API key created. Contains the full token (shown only once).
|
||||
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
|
||||
*/
|
||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
||||
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
|
||||
const apiKeyRepository = createCurrentApiKeyRepository();
|
||||
const userRepository = createCurrentUserRepository();
|
||||
|
||||
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
|
||||
expiresAtValue = parsed.toISOString();
|
||||
}
|
||||
|
||||
let scopesValue: string[] | null = null;
|
||||
if (scopes !== undefined && scopes !== null) {
|
||||
if (
|
||||
!Array.isArray(scopes) ||
|
||||
scopes.some(
|
||||
(scope) =>
|
||||
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ error: "Unknown API key scope" });
|
||||
}
|
||||
scopesValue =
|
||||
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
|
||||
}
|
||||
|
||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||
const tokenPrefix = rawToken.substring(0, 12);
|
||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
|
||||
expiresAt: expiresAtValue,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
|
||||
});
|
||||
|
||||
const actorId = (req as AuthenticatedRequest).userId;
|
||||
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
|
||||
details: JSON.stringify({
|
||||
targetUserId,
|
||||
targetUsername: targetUser.username,
|
||||
scopes: scopesValue,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
scopes: scopesValue,
|
||||
token: rawToken,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void {
|
||||
}
|
||||
|
||||
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
|
||||
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
|
||||
// approved: a person approved this exact request in the plugin's own UI.
|
||||
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]);
|
||||
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
|
||||
|
||||
/** A person is the check when the request came from something else. */
|
||||
|
||||
@@ -78,6 +78,8 @@ interface ReadOptions {
|
||||
actionId: string;
|
||||
hostIds?: number[];
|
||||
refresh?: boolean;
|
||||
/** How long a cached answer stays fresh. Defaults to RESOURCE_FRESH_MS. */
|
||||
freshMs?: number;
|
||||
input?: Record<string, unknown>;
|
||||
origin: ActorOrigin;
|
||||
}
|
||||
@@ -155,7 +157,9 @@ export async function readAcrossHosts<T>(
|
||||
|
||||
const key = cacheKey(options.userId, options.actionId, hostId);
|
||||
const cached = cache.get(key);
|
||||
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS;
|
||||
const fresh =
|
||||
cached &&
|
||||
Date.now() - cached.fetchedAt < (options.freshMs ?? RESOURCE_FRESH_MS);
|
||||
|
||||
if (isOffline(hostId)) {
|
||||
reads.push({
|
||||
|
||||
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
|
||||
.map((record) => record.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins this image's profile ships that the install has never seen and
|
||||
* that are not on disk. They get fetched from the registry once, the first
|
||||
* boot that can reach it.
|
||||
*/
|
||||
export function newShippedPluginIds(
|
||||
shipped: Iterable<string>,
|
||||
records: { id: string }[],
|
||||
presentIds: ReadonlySet<string>,
|
||||
ignored: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const known = new Set(records.map((record) => record.id));
|
||||
return [...shipped]
|
||||
.filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id))
|
||||
.sort();
|
||||
}
|
||||
@@ -18,7 +18,14 @@ export interface OnboardingDefaults {
|
||||
|
||||
type Recommended = boolean | "desktop";
|
||||
|
||||
export interface BundledCatalogEntry {
|
||||
tier: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface BundledIndex {
|
||||
profile?: string;
|
||||
catalog?: Record<string, BundledCatalogEntry>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
|
||||
);
|
||||
cached =
|
||||
raw && typeof raw.plugins === "object" && raw.plugins
|
||||
? { plugins: raw.plugins }
|
||||
? {
|
||||
plugins: raw.plugins,
|
||||
profile: typeof raw.profile === "string" ? raw.profile : undefined,
|
||||
catalog:
|
||||
raw.catalog && typeof raw.catalog === "object"
|
||||
? raw.catalog
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
cached = null;
|
||||
@@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults {
|
||||
};
|
||||
}
|
||||
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. Bundled plugins start
|
||||
* enabled, except on a fresh install, where only recommended ones do and
|
||||
* consent plugins wait for the admin. A plugin dropped into the data
|
||||
* directory always starts disabled.
|
||||
* Plugins the image's profile ships (its tier and every smaller one), with
|
||||
* the version it pins. Read from the bundled index, so core never names them.
|
||||
*/
|
||||
export function shippedCatalog(): Map<string, BundledCatalogEntry> {
|
||||
const index = readBundledIndex();
|
||||
const out = new Map<string, BundledCatalogEntry>();
|
||||
if (!index?.catalog) return out;
|
||||
const rank = PROFILES.indexOf(index.profile ?? "standard");
|
||||
for (const [id, entry] of Object.entries(index.catalog)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const tier = PROFILES.indexOf(entry.tier);
|
||||
if (tier === -1 || rank === -1 || tier > rank) continue;
|
||||
out.set(id, entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a shipped plugin should be on when an install first sees it. */
|
||||
export function startsEnabled(pluginId: string): boolean {
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return true;
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. A bundled plugin starts on
|
||||
* only when it is recommended, so a plugin a later release adds to the image
|
||||
* does not switch itself on everywhere. Consent plugins wait for the admin.
|
||||
* A plugin dropped into the data directory always starts disabled.
|
||||
*/
|
||||
export function initialPluginState(
|
||||
pluginId: string,
|
||||
source: "bundled" | "user",
|
||||
freshInstall: boolean,
|
||||
_freshInstall: boolean,
|
||||
): "enabled" | "disabled" {
|
||||
if (source !== "bundled") return "disabled";
|
||||
if (!freshInstall) return "enabled";
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent ? "enabled" : "disabled";
|
||||
return startsEnabled(pluginId) ? "enabled" : "disabled";
|
||||
}
|
||||
@@ -560,7 +560,13 @@ export async function openDownload(
|
||||
}
|
||||
},
|
||||
abort: async () => {
|
||||
stream.destroy();
|
||||
// The file opens in the background, so wait for the stream to close
|
||||
// or a late open would leave the partial file behind.
|
||||
await new Promise<void>((resolve) => {
|
||||
if (stream.closed) return resolve();
|
||||
stream.once("close", () => resolve());
|
||||
stream.destroy();
|
||||
});
|
||||
await fsp.rm(partialPath, { force: true }).catch(() => {});
|
||||
release();
|
||||
},
|
||||
|
||||
@@ -243,8 +243,10 @@ export async function initializePlugins(
|
||||
|
||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||
if (!fresh) {
|
||||
const { restoreMissingPlugins } = await import("./manage.js");
|
||||
const { installNewShippedPlugins, restoreMissingPlugins } =
|
||||
await import("./manage.js");
|
||||
loaded.push(...(await restoreMissingPlugins()));
|
||||
loaded.push(...(await installNewShippedPlugins()));
|
||||
}
|
||||
await syncCapabilityGrants(loaded);
|
||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||
|
||||
@@ -409,6 +409,15 @@ export class PluginLoader {
|
||||
}
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
// A newer image ships a newer copy, so the old update can go.
|
||||
if (isArtifact && error instanceof SupersededUpdateError) {
|
||||
await fs.promises.rm(dir, { force: true }).catch(() => {});
|
||||
await fs.promises.rm(`${dir}.sig`, { force: true }).catch(() => {});
|
||||
pluginLogger.info(`Removed ${entry.name}: ${error.message}`, {
|
||||
operation: "plugin_load",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
this.skippedPlugins.push(
|
||||
describeSkipped(dir, entry.name, isArtifact, error),
|
||||
);
|
||||
@@ -948,6 +957,16 @@ function withTimeout<T>(
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** An installed update that the bundled copy has caught up with. */
|
||||
export class SupersededUpdateError extends Error {
|
||||
constructor(id: string, version: string, bundledVersion: string) {
|
||||
super(
|
||||
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
|
||||
);
|
||||
this.name = "SupersededUpdateError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws unless an artifact may replace the bundled plugin with the same id:
|
||||
* a trusted key signed it and its version is strictly higher, so an old
|
||||
@@ -971,9 +990,7 @@ export function assertBundledUpdate(
|
||||
// An admin pinning an exact older release is the one allowed downgrade.
|
||||
if (pinnedVersion && pinnedVersion === version) return;
|
||||
if (bundledVersion && !semver.gt(version, bundledVersion)) {
|
||||
throw new Error(
|
||||
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
|
||||
);
|
||||
throw new SupersededUpdateError(id, version, bundledVersion);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import {
|
||||
isDefaultLookPlugin,
|
||||
shippedCatalog,
|
||||
startsEnabled,
|
||||
} from "./bundled-index.js";
|
||||
import { mayRunInSafeMode } from "./safe-mode.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
@@ -26,7 +30,7 @@ import {
|
||||
registerLoadedPlugins,
|
||||
unloadPlugin,
|
||||
} from "./index.js";
|
||||
import { missingShippedPluginIds } from "./boot-state.js";
|
||||
import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
|
||||
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
||||
import {
|
||||
getBundledPluginsDir,
|
||||
@@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at boot on an existing install. A plugin this image's profile ships
|
||||
* that the install never had and that is not on disk is fetched from the
|
||||
* official registry and starts on only if it is recommended. Offline, nothing
|
||||
* is recorded, so the next start tries again.
|
||||
*/
|
||||
export function installNewShippedPlugins(): Promise<LoadedPlugin[]> {
|
||||
return serialized(async () => {
|
||||
if (await isLinkedDesktop()) return [];
|
||||
const catalog = shippedCatalog();
|
||||
if (catalog.size === 0) return [];
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const { loader } = getPluginRuntime();
|
||||
const ids = newShippedPluginIds(
|
||||
catalog.keys(),
|
||||
await repository.listAll(),
|
||||
new Set(loader.list().map((plugin) => plugin.id)),
|
||||
await readUninstalled(),
|
||||
);
|
||||
const added: LoadedPlugin[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { source, release } = await requireRelease(
|
||||
id,
|
||||
catalog.get(id)?.version,
|
||||
"stable",
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
const plugin = await swapIn(id, { release, source });
|
||||
const enabled = startsEnabled(id);
|
||||
await repository.update(id, {
|
||||
registryId: source.id,
|
||||
tier: "bundled",
|
||||
state: enabled ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
});
|
||||
await grantDeclared(plugin, null);
|
||||
added.push(plugin);
|
||||
pluginLogger.info(
|
||||
`Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`,
|
||||
{ operation: "plugin_install_shipped" },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pluginLogger.warn(`Could not add ${id}: ${message}`, {
|
||||
operation: "plugin_install_shipped",
|
||||
});
|
||||
// A half made row would hide the plugin from the next try.
|
||||
if (!loader.get(id)) await repository.delete(id).catch(() => false);
|
||||
if (
|
||||
error instanceof PluginManageError &&
|
||||
error.code === "REGISTRY_UNREACHABLE"
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlugin(
|
||||
pluginId: string,
|
||||
options: InstallOptions,
|
||||
|
||||
@@ -275,3 +275,27 @@ describe("GET /operations/catalog", () => {
|
||||
expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /operations/signals", () => {
|
||||
it("keeps a signal's answer for its refreshSec", async () => {
|
||||
const { buildCatalog } = await import("../../../operations/catalog.js");
|
||||
const { readAcrossHosts } =
|
||||
await import("../../../operations/resources.js");
|
||||
vi.mocked(buildCatalog).mockResolvedValueOnce({
|
||||
actions: [],
|
||||
events: [],
|
||||
resources: [],
|
||||
signals: [
|
||||
{ id: "a", read: "demo.a", scope: "host", refreshSec: 600 },
|
||||
{ id: "b", read: "demo.b", scope: "host" },
|
||||
],
|
||||
} as never);
|
||||
await request(app).get("/operations/signals").expect(200);
|
||||
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actionId: "demo.a", freshMs: 600_000 }),
|
||||
);
|
||||
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actionId: "demo.b", freshMs: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -148,8 +148,23 @@ async function build(
|
||||
});
|
||||
}
|
||||
|
||||
// Building a hop's config parses keys, which is slow while the whole suite
|
||||
// runs, so give the multi hop steps more than vi.waitFor's 1 s.
|
||||
const settle = (check: () => void) => vi.waitFor(check, { timeout: 10_000 });
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks keeps queued Once values, so a test that stopped early
|
||||
// would hand its leftovers to the next one.
|
||||
for (const mock of [
|
||||
mocks.resolveHostById,
|
||||
mocks.createJumpHostChain,
|
||||
mocks.createSocks5Connection,
|
||||
mocks.applyCertificateAuth,
|
||||
mocks.applyAgentAuth,
|
||||
])
|
||||
mock.mockReset();
|
||||
FakeSshClient.nextBehaviour = [];
|
||||
FakeSshClient.instances = [];
|
||||
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
|
||||
@@ -532,7 +547,7 @@ describe("connectHost", () => {
|
||||
"user-1",
|
||||
prompt,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
|
||||
);
|
||||
const first = FakeSshClient.instances[0];
|
||||
@@ -546,11 +561,9 @@ describe("connectHost", () => {
|
||||
[{ prompt: "Verification code:", echo: false }],
|
||||
firstAnswer,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
|
||||
);
|
||||
await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"]));
|
||||
first.emit("ready");
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
|
||||
);
|
||||
const second = FakeSshClient.instances[1];
|
||||
@@ -566,7 +579,7 @@ describe("connectHost", () => {
|
||||
],
|
||||
secondAnswer,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
|
||||
);
|
||||
second.emit("ready");
|
||||
@@ -674,7 +687,7 @@ describe("connectHost", () => {
|
||||
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
|
||||
await vi.waitFor(() => {
|
||||
await settle(() => {
|
||||
const last = FakeSshClient.instances.at(-1)!;
|
||||
expect(last.connectConfig).not.toBeNull();
|
||||
});
|
||||
@@ -704,7 +717,7 @@ describe("connectHost", () => {
|
||||
purpose: "file-manager",
|
||||
prompt: { ask },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
|
||||
);
|
||||
const client = FakeSshClient.instances.at(-1)!;
|
||||
@@ -720,7 +733,7 @@ describe("connectHost", () => {
|
||||
],
|
||||
finish,
|
||||
);
|
||||
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
|
||||
await settle(() => expect(finish).toHaveBeenCalled());
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
kind: "totp",
|
||||
prompt: "Verification code: ",
|
||||
@@ -751,7 +764,7 @@ describe("connectHost", () => {
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
|
||||
);
|
||||
dispose();
|
||||
|
||||
@@ -451,6 +451,15 @@ describe("policy", () => {
|
||||
expect(outcome.status).toBe("success");
|
||||
});
|
||||
|
||||
it("lets a request a person approved in a plugin go by RBAC", async () => {
|
||||
register();
|
||||
const outcome = await runOperation({
|
||||
...base,
|
||||
origin: { kind: "plugin", pluginId: "x", via: "approved" },
|
||||
});
|
||||
expect(outcome.status).toBe("success");
|
||||
});
|
||||
|
||||
it("treats a community read as a change", async () => {
|
||||
state.plugin.source = "user";
|
||||
state.plugin.signedTier = "community";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
missingShippedPluginIds,
|
||||
newShippedPluginIds,
|
||||
startablePluginIds,
|
||||
} from "../../plugins/boot-state.js";
|
||||
|
||||
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
|
||||
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("newShippedPluginIds", () => {
|
||||
it("lists shipped plugins the install never had and that are not on disk", () => {
|
||||
expect(
|
||||
newShippedPluginIds(
|
||||
["new", "known", "here", "removed", "another"],
|
||||
[{ id: "known" }],
|
||||
new Set(["here"]),
|
||||
new Set(["removed"]),
|
||||
),
|
||||
).toEqual(["another", "new"]);
|
||||
});
|
||||
|
||||
it("finds nothing for an empty catalog", () => {
|
||||
expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,18 @@ import {
|
||||
isDefaultLookPlugin,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
shippedCatalog,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
function writeIndex(
|
||||
plugins: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
JSON.stringify({ version: 1, plugins, ...extra }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables every bundled plugin on an existing install", () => {
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
|
||||
it("enables only recommended plugins an existing install sees for the first time", () => {
|
||||
expect(initialPluginState("kept", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("disabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("disabled");
|
||||
});
|
||||
|
||||
it("enables only recommended, non-consent plugins on a fresh install", () => {
|
||||
@@ -106,6 +111,40 @@ describe("initialPluginState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shippedCatalog", () => {
|
||||
it("is empty for an index without a catalog", () => {
|
||||
writeIndex({});
|
||||
expect(shippedCatalog().size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the pins of the image's tier and smaller ones", () => {
|
||||
writeIndex(
|
||||
{},
|
||||
{
|
||||
profile: "standard",
|
||||
catalog: {
|
||||
look: { tier: "minimal", version: "1.0.0" },
|
||||
ops: { tier: "standard", version: "1.0.0" },
|
||||
big: { tier: "full", version: "2.0.0" },
|
||||
odd: { tier: "huge" },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]);
|
||||
});
|
||||
|
||||
it("follows a minimal image", () => {
|
||||
writeIndex(
|
||||
{},
|
||||
{
|
||||
profile: "minimal",
|
||||
catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } },
|
||||
},
|
||||
);
|
||||
expect([...shippedCatalog().keys()]).toEqual(["look"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
it("lists the plugins flagged as the default look", () => {
|
||||
writeIndex({
|
||||
|
||||
@@ -186,6 +186,21 @@ describe("ctx.desktop.local", () => {
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves no partial file when aborted before the file has opened", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const download = await ctx.desktop.local.fs.openDownload({
|
||||
rootPath: dir,
|
||||
destPath: path.join(dir, `early-${i}.txt`),
|
||||
transferId: `early-${i}`,
|
||||
});
|
||||
await download.abort();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends trash and open through Electron main and audits each call", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
@@ -231,6 +231,16 @@ describe("updating a bundled plugin", () => {
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("removes an update the bundled copy caught up with", async () => {
|
||||
const { userDir } = setup();
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
expect(fs.existsSync(`${file}.sig`)).toBe(false);
|
||||
expect(loader.skipped()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a downgrade after the bundled copy was unloaded", async () => {
|
||||
const { userDir } = setup();
|
||||
const loader = new PluginLoader();
|
||||
|
||||
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
||||
|
||||
/** What clicking a host does when it already has an open tab. */
|
||||
export type HostClickBehavior =
|
||||
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
|
||||
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
|
||||
|
||||
export interface HostSidebarFilterState {
|
||||
status: ("online" | "offline" | "pinned")[];
|
||||
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
|
||||
"newTab",
|
||||
"focusExisting",
|
||||
"focusExistingDoubleClickNew",
|
||||
"hostPage",
|
||||
];
|
||||
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
||||
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ApiKey {
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
/** Null for full access. */
|
||||
scopes?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey extends ApiKey {
|
||||
@@ -97,12 +99,14 @@ export async function createApiKey(
|
||||
name: string,
|
||||
userId: string,
|
||||
expiresAt?: string,
|
||||
scopes?: string[] | null,
|
||||
): Promise<CreatedApiKey> {
|
||||
try {
|
||||
const response = await authApi.post("/users/api-keys", {
|
||||
name,
|
||||
userId,
|
||||
expiresAt: expiresAt ?? null,
|
||||
scopes: scopes?.length ? scopes : null,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
// Inside an edge to edge tile layout the layout draws the lines.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-tile-card=""
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-none bg-card py-4 text-xs/relaxed text-card-foreground border border-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
|
||||
!tiled && "border border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseServerTime } from "@/lib/server-time";
|
||||
import { formatTimestamp, pickXTicks } from "./x-ticks";
|
||||
|
||||
export interface LineChartSeries {
|
||||
key: string;
|
||||
@@ -19,23 +20,11 @@ interface LineChartProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Y_LABEL_W = 44;
|
||||
const Y_LABEL_MIN = 44;
|
||||
const X_LABEL_H = 20;
|
||||
const TOP_PAD = 6;
|
||||
const TICK_COUNT = 5;
|
||||
|
||||
function formatTimestamp(ts: string, rangeMs: number): string {
|
||||
const d = parseServerTime(ts);
|
||||
if (rangeMs <= 25 * 60 * 60 * 1000) {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return (
|
||||
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
}
|
||||
|
||||
function valToY(v: number, drawH: number, domain: [number, number]): number {
|
||||
const [lo, hi] = domain;
|
||||
const range = hi === lo ? 1 : hi - lo;
|
||||
@@ -95,7 +84,18 @@ export function LineChart({
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const chartW = Math.max(0, width - Y_LABEL_W);
|
||||
const yTicks = (() => {
|
||||
const [lo, hi] = domain;
|
||||
const step = (hi - lo) / (TICK_COUNT - 1);
|
||||
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
|
||||
})();
|
||||
|
||||
// Wide enough for the longest tick label, like "1.5 KiB/s".
|
||||
const yLabelW = Math.max(
|
||||
Y_LABEL_MIN,
|
||||
Math.max(...yTicks.map((tick) => yFormatter(tick).length)) * 6 + 12,
|
||||
);
|
||||
const chartW = Math.max(0, width - yLabelW);
|
||||
const drawH = height - TOP_PAD;
|
||||
const svgH = height + X_LABEL_H;
|
||||
|
||||
@@ -105,36 +105,19 @@ export function LineChart({
|
||||
parseServerTime(timestamps[0]).getTime()
|
||||
: 0;
|
||||
|
||||
const yTicks = (() => {
|
||||
const [lo, hi] = domain;
|
||||
const step = (hi - lo) / (TICK_COUNT - 1);
|
||||
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
|
||||
})();
|
||||
|
||||
const xTickIndices: number[] = [];
|
||||
if (timestamps.length > 1 && chartW > 0) {
|
||||
const n = timestamps.length;
|
||||
// Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px.
|
||||
// Use at most 3 ticks for long labels, 5 for short, so they never crowd.
|
||||
const longLabel = rangeMs > 25 * 60 * 60 * 1000;
|
||||
const maxTicks = longLabel ? 3 : 5;
|
||||
const count = Math.min(maxTicks, n);
|
||||
for (let k = 0; k < count; k++) {
|
||||
xTickIndices.push(Math.round((k / (count - 1)) * (n - 1)));
|
||||
}
|
||||
}
|
||||
const xTickIndices = pickXTicks(timestamps, rangeMs, chartW);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGElement>) => {
|
||||
if (timestamps.length < 2 || chartW <= 0) return;
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const relX = e.clientX - rect.left - Y_LABEL_W;
|
||||
const relX = e.clientX - rect.left - yLabelW;
|
||||
const index = Math.round(
|
||||
Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1),
|
||||
);
|
||||
setTooltip({ x: e.clientX - rect.left, index });
|
||||
},
|
||||
[timestamps.length, chartW],
|
||||
[timestamps.length, chartW, yLabelW],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -156,7 +139,7 @@ export function LineChart({
|
||||
return (
|
||||
<g key={ti}>
|
||||
<line
|
||||
x1={Y_LABEL_W}
|
||||
x1={yLabelW}
|
||||
y1={y}
|
||||
x2={width}
|
||||
y2={y}
|
||||
@@ -165,7 +148,7 @@ export function LineChart({
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<text
|
||||
x={Y_LABEL_W - 6}
|
||||
x={yLabelW - 6}
|
||||
y={y + 4}
|
||||
fontSize={10}
|
||||
textAnchor="end"
|
||||
@@ -179,15 +162,15 @@ export function LineChart({
|
||||
|
||||
{/* Left + bottom border */}
|
||||
<line
|
||||
x1={Y_LABEL_W}
|
||||
x1={yLabelW}
|
||||
y1={TOP_PAD}
|
||||
x2={Y_LABEL_W}
|
||||
x2={yLabelW}
|
||||
y2={TOP_PAD + drawH}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={Y_LABEL_W}
|
||||
x1={yLabelW}
|
||||
y1={TOP_PAD + drawH}
|
||||
x2={width}
|
||||
y2={TOP_PAD + drawH}
|
||||
@@ -209,7 +192,7 @@ export function LineChart({
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
transform={`translate(${Y_LABEL_W},0)`}
|
||||
transform={`translate(${yLabelW},0)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -217,13 +200,9 @@ export function LineChart({
|
||||
{/* Crosshair */}
|
||||
{tooltip !== null && timestamps.length > 1 && chartW > 0 && (
|
||||
<line
|
||||
x1={
|
||||
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
|
||||
}
|
||||
x1={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
|
||||
y1={TOP_PAD}
|
||||
x2={
|
||||
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
|
||||
}
|
||||
x2={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
|
||||
y2={TOP_PAD + drawH}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.4}
|
||||
@@ -237,7 +216,7 @@ export function LineChart({
|
||||
const isFirst = li === 0;
|
||||
const isLast = li === xTickIndices.length - 1;
|
||||
const x =
|
||||
Y_LABEL_W + (i / Math.max(1, timestamps.length - 1)) * chartW;
|
||||
yLabelW + (i / Math.max(1, timestamps.length - 1)) * chartW;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { parseServerTime } from "@/lib/server-time";
|
||||
|
||||
export function formatTimestamp(ts: string, rangeMs: number): string {
|
||||
const d = parseServerTime(ts);
|
||||
// A live chart covers a minute or two, so minutes alone would repeat.
|
||||
if (rangeMs <= 10 * 60 * 1000) {
|
||||
return d.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
if (rangeMs <= 25 * 60 * 60 * 1000) {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return (
|
||||
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
}
|
||||
|
||||
// Rough width of a 10px label, used to keep x labels apart.
|
||||
const CHAR_W = 6.2;
|
||||
const LABEL_GAP = 12;
|
||||
|
||||
/**
|
||||
* Picks which timestamps get an x label. The count follows the chart width and
|
||||
* label length, and a label that would touch its neighbour is dropped.
|
||||
*/
|
||||
export function pickXTicks(
|
||||
timestamps: string[],
|
||||
rangeMs: number,
|
||||
chartW: number,
|
||||
): number[] {
|
||||
const n = timestamps.length;
|
||||
if (n < 2 || chartW <= 0) return [];
|
||||
const labelW = (i: number) =>
|
||||
formatTimestamp(timestamps[i], rangeMs).length * CHAR_W;
|
||||
const widest = Math.max(labelW(0), labelW(n - 1));
|
||||
const fit = Math.floor((chartW + LABEL_GAP) / (widest + LABEL_GAP));
|
||||
const count = Math.max(1, Math.min(5, n, fit));
|
||||
const candidates =
|
||||
count === 1
|
||||
? [n - 1]
|
||||
: Array.from({ length: count }, (_, k) =>
|
||||
Math.round((k / (count - 1)) * (n - 1)),
|
||||
);
|
||||
|
||||
// Box of a label as drawn: first is start anchored, last end, others middle.
|
||||
const box = (i: number): [number, number] => {
|
||||
const x = (i / (n - 1)) * chartW;
|
||||
const w = labelW(i);
|
||||
if (i === 0) return [x, x + w];
|
||||
if (i === n - 1) return [x - w, x];
|
||||
return [x - w / 2, x + w / 2];
|
||||
};
|
||||
|
||||
const clashes = (a: number | undefined, b: number) =>
|
||||
a !== undefined && box(a)[1] + LABEL_GAP > box(b)[0];
|
||||
const placed: number[] = [];
|
||||
for (const i of [...new Set(candidates)]) {
|
||||
// The newest time stays visible on a live chart, older labels make room.
|
||||
if (i === n - 1) {
|
||||
while (placed.length && clashes(placed[placed.length - 1], i)) {
|
||||
placed.pop();
|
||||
}
|
||||
} else if (clashes(placed[placed.length - 1], i)) {
|
||||
continue;
|
||||
}
|
||||
placed.push(i);
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A plain table in the tile look: sticky header on the page background, a
|
||||
* line under each row, no outer box. For wide data with real columns; use
|
||||
* DataView when the same list also has a card view.
|
||||
*/
|
||||
export function DataTable({
|
||||
className,
|
||||
containerClassName,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"table"> & {
|
||||
/** Classes for the scroll box around the table, like "flex-1". */
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-auto thin-scrollbar",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<table
|
||||
className={cn(
|
||||
"w-full border-collapse text-xs [&_td]:px-2 [&_td]:py-1.5 [&_th]:px-2 [&_th]:py-2 [&_th]:text-left [&_th]:text-[10px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-widest [&_th]:text-muted-foreground [&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10 [&_thead]:bg-background [&_thead_tr]:border-b [&_tr]:border-b [&_tr]:border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A body row that highlights on hover, and when active. */
|
||||
export function DataTableRow({
|
||||
active = false,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<"tr"> & { active?: boolean }) {
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
onClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TileWall } from "@/components/tile-grid/TileWall";
|
||||
import { BottomEdge } from "@/components/tile-grid/edge";
|
||||
import {
|
||||
PANEL,
|
||||
gridSizeStyle,
|
||||
type GridSize,
|
||||
type PanelDensity,
|
||||
type PanelViewMode,
|
||||
@@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** One collection as a card grid or a dense row table. */
|
||||
/**
|
||||
* One collection as a card grid or a dense row table, edge to edge with
|
||||
* shared lines like the rest of the tile look.
|
||||
*/
|
||||
export function DataView<T>({
|
||||
items,
|
||||
view,
|
||||
@@ -132,21 +135,18 @@ export function DataView<T>({
|
||||
|
||||
if (view === "grid") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
PANEL.gap,
|
||||
!size && gridClass(columns),
|
||||
className,
|
||||
)}
|
||||
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
|
||||
<TileWall
|
||||
size={size}
|
||||
minWidth={minCardWidth ?? 320}
|
||||
gridClassName={size ? undefined : gridClass(columns)}
|
||||
className={className}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={getKey(item)} className="flex flex-col *:flex-1">
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TileWall>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,68 +154,70 @@ export function DataView<T>({
|
||||
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("@container border border-border bg-card", className)}
|
||||
style={
|
||||
{
|
||||
"--data-view-cols": templates.base,
|
||||
"--data-view-cols-md": templates.md,
|
||||
"--data-view-cols-lg": templates.lg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="table"
|
||||
>
|
||||
<BottomEdge>
|
||||
<div
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}
|
||||
className={cn("@container", className)}
|
||||
style={
|
||||
{
|
||||
"--data-view-cols": templates.base,
|
||||
"--data-view-cols-md": templates.md,
|
||||
"--data-view-cols-lg": templates.lg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="table"
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<span
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"truncate py-2",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{items.map((item) => {
|
||||
const active = isRowActive?.(item) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={getKey(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
<div
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"sticky top-0 z-10 items-center border-b border-border bg-background text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<span
|
||||
key={column.key}
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
|
||||
rowHeight,
|
||||
onRowClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
"truncate py-2",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{column.cell(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{column.header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{items.map((item) => {
|
||||
const active = isRowActive?.(item) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={getKey(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"items-center border-b border-border/50 text-xs transition-colors last:border-border",
|
||||
rowHeight,
|
||||
onRowClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{column.cell(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BottomEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InfoItem {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
/** Monospace value, for ids, paths and addresses. */
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/** Label and value pairs in two aligned columns. Empty values are skipped. */
|
||||
export function InfoGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: InfoItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
const shown = items.filter(
|
||||
(item) =>
|
||||
item.value !== null && item.value !== undefined && item.value !== "",
|
||||
);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
"grid grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{shown.map((item, index) => (
|
||||
<div key={index} className="contents">
|
||||
<dt className="text-muted-foreground">{item.label}</dt>
|
||||
<dd className={cn("min-w-0 break-words", item.mono && "font-mono")}>
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A spinner with a short line, for a view waiting on its first load. */
|
||||
export function LoadingState({
|
||||
label,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
label?: string;
|
||||
/** One inline line instead of the centered block. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const text = label ?? t("common.loading");
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin opacity-60" />
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Default cap (px) for a scrollable card body before it scrolls. */
|
||||
@@ -29,11 +30,37 @@ export function MetricCard({
|
||||
/** Max body height in px for scrollable cards. */
|
||||
scrollMax?: number;
|
||||
}) {
|
||||
// In an edge to edge tile grid the grid draws the lines, so the card takes
|
||||
// the dashboard tile look: no border or fill, the small title row.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="flex-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<div
|
||||
data-tile-card=""
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden",
|
||||
!tiled && "border border-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 border-b border-border",
|
||||
tiled ? "px-3 py-1.5" : "px-4 py-2.5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
tiled && "flex [&_svg]:size-3",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 truncate font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
tiled ? "text-[10px]" : "text-xs",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{action && <div className="ml-auto shrink-0">{action}</div>}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A block that explains why a view has nothing to show yet and what to do,
|
||||
* like "Samba is not installed" with an Install button. Unlike EmptyState the
|
||||
* action always shows.
|
||||
*/
|
||||
export function NoticePanel({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
tone = "default",
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
action?: ReactNode;
|
||||
tone?: "default" | "warning" | "destructive";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 px-4 py-4",
|
||||
tone === "warning" && "bg-warning/5",
|
||||
tone === "destructive" && "bg-destructive/5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-5 text-muted-foreground",
|
||||
tone === "warning" && "text-warning",
|
||||
tone === "destructive" && "text-destructive",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{children && (
|
||||
<div className="text-xs leading-relaxed text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{action && <div className="mt-1 flex flex-wrap gap-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** One page of a list, with the page clamped to what exists. */
|
||||
export function pageOf<T>(
|
||||
items: T[],
|
||||
page: number,
|
||||
size: number,
|
||||
): { rows: T[]; page: number; pages: number } {
|
||||
const pages = Math.max(1, Math.ceil(items.length / size));
|
||||
const at = Math.min(Math.max(0, page), pages - 1);
|
||||
return { rows: items.slice(at * size, at * size + size), page: at, pages };
|
||||
}
|
||||
|
||||
/** Pages a list in memory. Goes back to the first page when `reset` changes. */
|
||||
export function usePaged<T>(items: T[], size: number, reset?: unknown) {
|
||||
const [page, setPage] = useState(0);
|
||||
useEffect(() => setPage(0), [reset]);
|
||||
return { ...pageOf(items, page, size), setPage };
|
||||
}
|
||||
|
||||
/** Previous and Next under a list. Hidden when there is only one page. */
|
||||
export function Pager({
|
||||
page,
|
||||
pages,
|
||||
hasNext,
|
||||
onPage,
|
||||
className,
|
||||
}: {
|
||||
page: number;
|
||||
/** Total pages when known; leave out for a list read a page at a time. */
|
||||
pages?: number;
|
||||
hasNext?: boolean;
|
||||
onPage: (next: number) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const next = hasNext ?? (pages !== undefined && page < pages - 1);
|
||||
if (page === 0 && !next) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2 border-t border-border px-2 py-1 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={page === 0}
|
||||
onClick={() => onPage(page - 1)}
|
||||
>
|
||||
{t("panel.pager.previous")}
|
||||
</Button>
|
||||
<span>
|
||||
{pages !== undefined
|
||||
? t("panel.pager.pageOf", { page: page + 1, pages })
|
||||
: t("panel.pager.page", { page: page + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!next}
|
||||
onClick={() => onPage(page + 1)}
|
||||
>
|
||||
{t("panel.pager.next")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Columns3,
|
||||
Grid3X3,
|
||||
List,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Search,
|
||||
X,
|
||||
@@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -116,6 +118,9 @@ export function PanelShell({
|
||||
|
||||
const bands = (
|
||||
<>
|
||||
{tabs && (
|
||||
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
|
||||
)}
|
||||
{toolbar && (
|
||||
<div
|
||||
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
|
||||
@@ -123,9 +128,6 @@ export function PanelShell({
|
||||
{toolbar}
|
||||
</div>
|
||||
)}
|
||||
{tabs && (
|
||||
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -215,6 +217,59 @@ export function BackButton({
|
||||
}
|
||||
|
||||
/** Search box sized to match toolbar buttons. Esc clears it. */
|
||||
/** The refresh button panel headers show, spinning while it loads. */
|
||||
export function RefreshButton({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const text = label ?? t("common.refresh");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled ?? loading}
|
||||
title={text}
|
||||
aria-label={text}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center text-accent-brand transition-colors hover:bg-muted/40 disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn("size-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outer box of a plugin tab: fills the tab and clips, so the PanelShell
|
||||
* inside can scroll its own body.
|
||||
*/
|
||||
export function PanelFrame({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-full w-full overflow-hidden", className)}>
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelSearch({
|
||||
value,
|
||||
onChange,
|
||||
@@ -333,7 +388,7 @@ export function Segmented<T extends string>({
|
||||
title={option.title ?? option.label}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"flex h-[30px] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
i > 0 && "border-l border-border",
|
||||
active
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
@@ -458,7 +513,7 @@ function ToggleButton({
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"flex size-[30px] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
bordered && "border-l border-border",
|
||||
active
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
@@ -513,13 +568,18 @@ export function GroupHeading({
|
||||
title,
|
||||
count,
|
||||
action,
|
||||
plain,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
action?: React.ReactNode;
|
||||
/** No rule after the title. The default inside a tile layout. */
|
||||
plain?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const tiled = useInSeamlessTile();
|
||||
const noRule = plain ?? tiled;
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -530,7 +590,11 @@ export function GroupHeading({
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
{noRule ? (
|
||||
<div className="flex-1" />
|
||||
) : (
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
|
||||
import { useState } from "react";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type React from "react";
|
||||
|
||||
export function SectionCard({
|
||||
@@ -18,19 +20,28 @@ export function SectionCard({
|
||||
/** Lets settings search jump to this card. */
|
||||
rowId?: string;
|
||||
}) {
|
||||
// Inside an edge to edge tile layout the layout draws the lines.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-tile-card=""
|
||||
data-setting-row={rowId}
|
||||
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden",
|
||||
!tiled && "border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<span className="flex text-muted-foreground [&_svg]:size-3">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
|
||||
{title}
|
||||
</span>
|
||||
{action && <div className="ml-auto">{action}</div>}
|
||||
</div>
|
||||
<div className="px-3 md:px-4 py-1">{children}</div>
|
||||
<div className="px-3 py-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-2.5 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -105,7 +105,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -154,7 +154,7 @@ function Select2({
|
||||
aria-expanded={open}
|
||||
aria-required={required}
|
||||
className={cn(
|
||||
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-8 w-full items-center justify-between gap-2 rounded-none border px-2.5 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -189,7 +189,7 @@ function Select2({
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("common.search")}
|
||||
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@ function Select2({
|
||||
className="max-h-64 overflow-y-auto p-1 thin-scrollbar"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("common.noResults")}
|
||||
</div>
|
||||
) : (
|
||||
@@ -222,7 +222,7 @@ function Select2({
|
||||
aria-selected={isSelected}
|
||||
disabled={option.disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-xs outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
|
||||
isSelected && "bg-muted text-accent-foreground",
|
||||
)}
|
||||
onClick={() => selectValue(option.value)}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
export interface SideNavEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
/** A count or short badge after the label. */
|
||||
badge?: ReactNode;
|
||||
/** Opens it in a normal tab instead, for split view. */
|
||||
onPopOut?: () => void;
|
||||
popOutLabel?: string;
|
||||
/** Draws a line above this entry. */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The left list of sections a page switches between, like the host page and
|
||||
* the Operations tab. On a phone it turns into a select over the content.
|
||||
*/
|
||||
export function SideNav({
|
||||
entries,
|
||||
active,
|
||||
onSelect,
|
||||
footer,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
entries: SideNavEntry[];
|
||||
/** Names the list for screen readers and the phone select. */
|
||||
label?: string;
|
||||
active: string;
|
||||
onSelect: (id: string) => void;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"hidden w-44 shrink-0 flex-col gap-0.5 overflow-auto border-r border-border p-2 thin-scrollbar md:flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="contents">
|
||||
{entry.divider && <div className="my-1 h-px bg-border" />}
|
||||
<SideNavItem
|
||||
entry={entry}
|
||||
active={entry.id === active}
|
||||
onClick={() => onSelect(entry.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{footer && <div className="mt-auto pt-2">{footer}</div>}
|
||||
</nav>
|
||||
<div className="shrink-0 border-b border-border p-2 md:hidden">
|
||||
<Select2
|
||||
value={active}
|
||||
aria-label={label}
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
className="h-8 w-full px-2.5 text-xs"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideNavItem({
|
||||
entry,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
entry: SideNavEntry;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center",
|
||||
active
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{entry.label}</span>
|
||||
{entry.badge !== undefined && entry.badge !== null && (
|
||||
<span className="ml-auto shrink-0 px-1 text-[10px] tabular-nums">
|
||||
{entry.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{entry.onPopOut && (
|
||||
<button
|
||||
type="button"
|
||||
title={entry.popOutLabel}
|
||||
aria-label={entry.popOutLabel}
|
||||
onClick={entry.onPopOut}
|
||||
className={cn(
|
||||
"mr-1 shrink-0 p-1 text-muted-foreground hover:text-foreground",
|
||||
!active && "opacity-0 focus:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Meter } from "@/components/meter";
|
||||
import { TileFrame } from "@/components/tile-grid/TileFrame";
|
||||
|
||||
const VALUE_TONE = {
|
||||
default: "",
|
||||
ok: "text-green-500",
|
||||
warn: "text-warning",
|
||||
bad: "text-destructive",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One number in the tile look: the small title row, a big value, then an
|
||||
* optional usage bar and a hint line. Put several in a TileWall for a strip.
|
||||
*/
|
||||
export function StatTile({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
unit,
|
||||
percent,
|
||||
hint,
|
||||
tone = "default",
|
||||
actions,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
value: ReactNode;
|
||||
unit?: ReactNode;
|
||||
/** Draws a usage bar under the value. */
|
||||
percent?: number | null;
|
||||
hint?: ReactNode;
|
||||
tone?: keyof typeof VALUE_TONE;
|
||||
actions?: ReactNode;
|
||||
onClick?: () => void;
|
||||
/** Extra lines under the hint. */
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2.5">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-2xl font-bold tabular-nums leading-none",
|
||||
VALUE_TONE[tone],
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{percent !== undefined && <Meter percent={percent} />}
|
||||
{hint && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<TileFrame
|
||||
title={label}
|
||||
icon={icon}
|
||||
actions={actions}
|
||||
className={className}
|
||||
bodyClassName="overflow-hidden"
|
||||
>
|
||||
{onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</TileFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ListBadge } from "@/components/list-kit";
|
||||
|
||||
export type StatusTone = "ok" | "warn" | "bad" | "info" | "idle";
|
||||
|
||||
const DOT: Record<StatusTone, string> = {
|
||||
ok: "bg-green-500",
|
||||
warn: "bg-warning",
|
||||
bad: "bg-destructive",
|
||||
info: "bg-accent-brand",
|
||||
idle: "bg-muted-foreground/50",
|
||||
};
|
||||
|
||||
const BADGE = {
|
||||
ok: "success",
|
||||
warn: "warning",
|
||||
bad: "destructive",
|
||||
info: "brand",
|
||||
idle: "muted",
|
||||
} as const;
|
||||
|
||||
// Common state words, so most callers only pass the state.
|
||||
const WORDS: Record<string, StatusTone> = {
|
||||
running: "ok",
|
||||
up: "ok",
|
||||
online: "ok",
|
||||
active: "ok",
|
||||
healthy: "ok",
|
||||
ready: "ok",
|
||||
ok: "ok",
|
||||
success: "ok",
|
||||
succeeded: "ok",
|
||||
done: "ok",
|
||||
completed: "ok",
|
||||
enabled: "ok",
|
||||
connected: "ok",
|
||||
bound: "ok",
|
||||
compliant: "ok",
|
||||
pending: "warn",
|
||||
paused: "warn",
|
||||
restarting: "warn",
|
||||
starting: "warn",
|
||||
stopping: "warn",
|
||||
waiting: "warn",
|
||||
degraded: "warn",
|
||||
warn: "warn",
|
||||
warning: "warn",
|
||||
unhealthy: "warn",
|
||||
drifted: "warn",
|
||||
pending_approval: "warn",
|
||||
needs_interaction: "warn",
|
||||
interrupted: "warn",
|
||||
update: "info",
|
||||
created: "idle",
|
||||
exited: "idle",
|
||||
stopped: "idle",
|
||||
inactive: "idle",
|
||||
disabled: "idle",
|
||||
offline: "bad",
|
||||
down: "bad",
|
||||
dead: "bad",
|
||||
failed: "bad",
|
||||
fail: "bad",
|
||||
error: "bad",
|
||||
crashloopbackoff: "bad",
|
||||
};
|
||||
|
||||
/** The tone for a state word like "running" or "failed". Unknown words are idle. */
|
||||
export function statusTone(state: string | null | undefined): StatusTone {
|
||||
if (!state) return "idle";
|
||||
const key = state.toLowerCase().replace(/[\s-]+/g, "_");
|
||||
return WORDS[key] ?? WORDS[key.replace(/_/g, "")] ?? "idle";
|
||||
}
|
||||
|
||||
/** A small round status light. */
|
||||
export function StatusDot({
|
||||
tone,
|
||||
state,
|
||||
pulse = false,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
tone?: StatusTone;
|
||||
/** A state word, used when no tone is given. */
|
||||
state?: string | null;
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const resolved = tone ?? statusTone(state);
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
aria-hidden={title ? undefined : true}
|
||||
className={cn(
|
||||
"inline-block size-2 shrink-0 rounded-full",
|
||||
DOT[resolved],
|
||||
pulse && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A state as a small colored badge. The text is the state unless children are given. */
|
||||
export function StatusBadge({
|
||||
tone,
|
||||
state,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: StatusTone;
|
||||
state?: string | null;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const resolved = tone ?? statusTone(state);
|
||||
return (
|
||||
<ListBadge tone={BADGE[resolved]} className={className}>
|
||||
{children ?? state}
|
||||
</ListBadge>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||
import { TileColumn } from "@/components/tile-grid/TileColumn";
|
||||
import {
|
||||
PROMPT_BUTTON,
|
||||
PROMPT_DESTRUCTIVE_BUTTON,
|
||||
@@ -397,6 +398,7 @@ export function InlineView({
|
||||
onBeforeClose,
|
||||
scroll = true,
|
||||
bare = false,
|
||||
stacked = false,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
@@ -416,6 +418,11 @@ export function InlineView({
|
||||
scroll?: boolean;
|
||||
/** Draw children edge to edge, for views with their own bands. */
|
||||
bare?: boolean;
|
||||
/**
|
||||
* Cards stacked in one column with a line between them and no padding,
|
||||
* like a settings page. For forms built from SectionCards.
|
||||
*/
|
||||
stacked?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -462,6 +469,10 @@ export function InlineView({
|
||||
>
|
||||
{bare ? (
|
||||
children
|
||||
) : stacked ? (
|
||||
<TileColumn className={cn("w-full", className)}>
|
||||
{children}
|
||||
</TileColumn>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -96,7 +96,7 @@ export function TabStrip({
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
variant === "secondary" && "border-t border-border bg-card",
|
||||
variant === "secondary" && "border-t border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
|
||||
"flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-2.5 py-2 text-xs shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge, EdgeOwned } from "./edge";
|
||||
|
||||
/**
|
||||
* A column of cards that can sit at any depth, like a settings page built
|
||||
* from several panels. Cards drop their box and the outermost ones draw a
|
||||
* line under themselves. Use TileStack when the sections are direct children.
|
||||
*/
|
||||
export function TileColumn({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
<BottomEdge
|
||||
className={className}
|
||||
innerClassName="flex min-w-0 flex-col [&_[data-tile-card]]:border-x-0 [&_[data-tile-card]]:border-t-0 [&_[data-tile-card]]:border-b [&_[data-tile-card]]:border-border [&_[data-tile-card]_[data-tile-card]]:border-b-0"
|
||||
>
|
||||
<EdgeOwned>{children}</EdgeOwned>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The bar over a tile grid while it is being edited: a hint or extra controls
|
||||
* on the left, then Reset, Add tiles (while the picker is closed) and Done.
|
||||
*/
|
||||
export function TileEditBar({
|
||||
children,
|
||||
onReset,
|
||||
onAdd,
|
||||
onDone,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onReset?: () => void;
|
||||
/** Leave out while the tile picker is open. */
|
||||
onAdd?: () => void;
|
||||
onDone: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onReset && (
|
||||
<Button size="xs" variant="ghost" className="gap-1" onClick={onReset}>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("tileGrid.reset")}
|
||||
</Button>
|
||||
)}
|
||||
{onAdd && (
|
||||
<Button size="xs" variant="ghost" onClick={onAdd}>
|
||||
{t("tileGrid.addTiles")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" onClick={onDone}>
|
||||
{t("tileGrid.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The look every tile shares with the dashboard: a small uppercase title row
|
||||
* over a body that scrolls when the tile has a fixed height. The grid draws
|
||||
* the lines between tiles. `bare` drops the title row.
|
||||
*/
|
||||
export function TileFrame({
|
||||
title,
|
||||
icon,
|
||||
actions,
|
||||
bare = false,
|
||||
onTitleClick,
|
||||
className,
|
||||
bodyClassName,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
bare?: boolean;
|
||||
onTitleClick?: () => void;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 min-w-0 flex-col overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!bare && (title || icon || actions) && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
{icon && (
|
||||
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTitleClick}
|
||||
className="min-w-0 truncate text-left text-[10px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{actions && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{actions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-auto thin-scrollbar",
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GripVertical, Settings2, X } from "lucide-react";
|
||||
import { remScale } from "@/lib/rem";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAX_TILE_HEIGHT,
|
||||
MIN_TILE_COLUMNS,
|
||||
MIN_TILE_HEIGHT,
|
||||
TILE_COLUMNS,
|
||||
clampTileColumns,
|
||||
settleTiles,
|
||||
tileBottom,
|
||||
tileBoxes,
|
||||
tilesFromBoxes,
|
||||
type TileBox,
|
||||
type TileSlot,
|
||||
} from "./layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { gridLines } from "./layout";
|
||||
import { scrollParent } from "./edge";
|
||||
|
||||
type Edge = "left" | "right" | "bottom" | "corner";
|
||||
interface Proposal {
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number | null;
|
||||
}
|
||||
interface Interaction {
|
||||
id: string;
|
||||
kind: "move" | "resize";
|
||||
base: TileSlot[];
|
||||
proposal: Proposal;
|
||||
frame: { left: number; top: number; width: number; height?: number };
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 4;
|
||||
const SNAP = 8;
|
||||
const SCROLL_EDGE = 48;
|
||||
|
||||
const styles = `
|
||||
.tg-grid { position: relative; }
|
||||
.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
|
||||
.tg-tile[data-active] { z-index: 40; }
|
||||
.tg-tile[data-active="move"] { opacity: 0.85; cursor: grabbing; }
|
||||
.tg-grid[data-edit-mode="true"] .tg-tile { user-select: none; }
|
||||
.tg-grid[data-edit-mode="true"] .tg-tile:not([data-active]) { cursor: grab; }
|
||||
[data-tg-drop-preview] {
|
||||
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; box-sizing: border-box;
|
||||
background: color-mix(in srgb, var(--accent-brand, #60a5fa) 12%, transparent);
|
||||
border: 2px dashed color-mix(in srgb, var(--accent-brand, #60a5fa) 70%, transparent);
|
||||
}
|
||||
.tg-handle { position: absolute; z-index: 30; display: flex; touch-action: none; align-items: center; justify-content: center; }
|
||||
.tg-handle > span { background: var(--border); }
|
||||
.tg-handle:hover > span { background: var(--accent-brand, #60a5fa); }
|
||||
.tg-edge-left, .tg-edge-right { top: 2rem; bottom: 1rem; width: 0.75rem; cursor: ew-resize; }
|
||||
.tg-edge-left { left: 0; }
|
||||
.tg-edge-right { right: 0; }
|
||||
.tg-edge-bottom { bottom: 0; left: 1rem; right: 1rem; height: 0.75rem; cursor: ns-resize; }
|
||||
.tg-edge-corner { bottom: 0; right: 0; width: 1rem; height: 1rem; cursor: nwse-resize; }
|
||||
.tg-edge-corner::after { content: ""; width: 0.5rem; height: 0.5rem; border-right: 2px solid var(--border); border-bottom: 2px solid var(--border); }
|
||||
.tg-handle:focus-visible { outline: 2px solid var(--accent-brand, #60a5fa); }
|
||||
.tg-card[data-fixed-height="true"] { height: 100%; }
|
||||
.tg-card[data-fixed-height="true"] > .tg-content { overflow: hidden; }
|
||||
.tg-card[data-fixed-height="true"] > .tg-content > * { height: 100%; }
|
||||
`;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, value));
|
||||
|
||||
function propose(base: TileSlot[], id: string, proposal: Proposal) {
|
||||
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
|
||||
}
|
||||
|
||||
const NO_DRAG =
|
||||
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
|
||||
|
||||
export interface TileGridProps {
|
||||
slots: TileSlot[];
|
||||
editMode: boolean;
|
||||
renderTile: (id: string) => ReactNode;
|
||||
/** Name of a tile, for the edit controls. */
|
||||
tileLabel: (id: string) => string;
|
||||
onChange: (slots: TileSlot[]) => void;
|
||||
/** Shows a settings button on each tile while editing. */
|
||||
onSettings?: (id: string) => void;
|
||||
/** Space between tiles in px. 0 draws tiles edge to edge with shared borders, like the dashboard. */
|
||||
gap?: number;
|
||||
/** Below this width tiles stack in one column and editing is off. */
|
||||
stackBelow?: number;
|
||||
minColumns?: number;
|
||||
minHeight?: number;
|
||||
/** Shown while editing an empty grid. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A 12 column grid of tiles the user can drag and resize. Tiles keep their
|
||||
* column and settle upward, so a layout never has holes it did not ask for.
|
||||
*/
|
||||
export function TileGrid({
|
||||
slots,
|
||||
editMode,
|
||||
renderTile,
|
||||
tileLabel,
|
||||
onChange,
|
||||
onSettings,
|
||||
gap = 0,
|
||||
stackBelow = 640,
|
||||
minColumns = MIN_TILE_COLUMNS,
|
||||
minHeight = MIN_TILE_HEIGHT,
|
||||
empty,
|
||||
className,
|
||||
}: TileGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const gridEl = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
// Whether the grid reaches the bottom of the view it scrolls in. Then its
|
||||
// last row meets the bottom of the screen and needs no line under it.
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const overflowsRef = useRef(overflows);
|
||||
overflowsRef.current = overflows;
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const [active, setActive] = useState<Interaction | null>(null);
|
||||
const cancel = useRef<(() => void) | null>(null);
|
||||
const heightsRef = useRef(heights);
|
||||
heightsRef.current = heights;
|
||||
|
||||
const scale = remScale();
|
||||
const seamless = gap <= 0;
|
||||
// Edge to edge tiles draw no lines of their own. The grid works out every
|
||||
// line between them and draws each once, so a line is never doubled at any
|
||||
// display scaling and a taller neighbour still gets its edge.
|
||||
const inset = seamless ? 0 : gap * scale;
|
||||
const settleGap = seamless ? 0 : gap;
|
||||
const pitch = (width + inset) / TILE_COLUMNS;
|
||||
const stacked = width > 0 && width < stackBelow;
|
||||
const editing = editMode && !stacked;
|
||||
|
||||
const settled = useMemo(
|
||||
() => settleTiles(tileBoxes(slots, heights), settleGap),
|
||||
[slots, heights, settleGap],
|
||||
);
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
active
|
||||
? settleTiles(
|
||||
tileBoxes(
|
||||
propose(active.base, active.id, active.proposal),
|
||||
heights,
|
||||
),
|
||||
settleGap,
|
||||
)
|
||||
: settled,
|
||||
[active, settled, heights, settleGap],
|
||||
);
|
||||
const boxById = useMemo(
|
||||
() => new Map(preview.map((box) => [box.id, box])),
|
||||
[preview],
|
||||
);
|
||||
// Same elements across pointer moves, so tiles only re-render on new data.
|
||||
const content = useMemo(
|
||||
() =>
|
||||
new Map<string, ReactNode>(
|
||||
slots.map((slot) => [slot.id, renderTile(slot.id)]),
|
||||
),
|
||||
[slots, renderTile],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = root.current;
|
||||
if (!element) return;
|
||||
const update = () =>
|
||||
setWidth(element.getBoundingClientRect().width || element.clientWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = root.current;
|
||||
const scroller = seamless ? scrollParent(element) : null;
|
||||
if (!element || !scroller) {
|
||||
setOverflows(false);
|
||||
return;
|
||||
}
|
||||
// Where the grid's last row ends inside the scrolled content, without the
|
||||
// kept bottom line's px, so adding or dropping that line never flips it.
|
||||
const update = () => {
|
||||
const end =
|
||||
element.getBoundingClientRect().bottom -
|
||||
scroller.getBoundingClientRect().top +
|
||||
scroller.scrollTop -
|
||||
(overflowsRef.current ? 0 : 1);
|
||||
setOverflows(end >= scroller.clientHeight - 0.5);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(scroller);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [seamless, settled, width]);
|
||||
|
||||
// An outside change (undo, reset, host switch) ends any drag in progress.
|
||||
useEffect(() => () => cancel.current?.(), [slots, editing]);
|
||||
|
||||
const reportHeight = useCallback(
|
||||
(id: string, height: number) =>
|
||||
setHeights((previous) =>
|
||||
Math.abs((previous[id] ?? -1) - height) < 0.5
|
||||
? previous
|
||||
: { ...previous, [id]: height },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const commit = (base: TileSlot[], id: string | null, proposal: Proposal) => {
|
||||
const next = id ? propose(base, id, proposal) : base;
|
||||
const boxes = settleTiles(tileBoxes(next, heightsRef.current), settleGap);
|
||||
onChange(tilesFromBoxes(next, boxes));
|
||||
};
|
||||
const settledBase = (): TileSlot[] =>
|
||||
slots.map((slot) => ({
|
||||
...slot,
|
||||
y: settled.find((box) => box.id === slot.id)?.y ?? slot.y,
|
||||
}));
|
||||
|
||||
const begin = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
id: string,
|
||||
edge?: Edge,
|
||||
) => {
|
||||
if (event.button !== 0 || !editing || cancel.current) return;
|
||||
const element = gridEl.current;
|
||||
const box = settled.find((b) => b.id === id);
|
||||
const slot = slots.find((s) => s.id === id);
|
||||
if (!element || !box || !slot) return;
|
||||
if (!edge) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(NO_DRAG) && !target.closest("[data-tg-grip]")) return;
|
||||
const native = event.nativeEvent;
|
||||
const scrolls =
|
||||
target.scrollHeight > target.clientHeight ||
|
||||
target.scrollWidth > target.clientWidth;
|
||||
if (
|
||||
scrolls &&
|
||||
(native.offsetX > target.clientWidth ||
|
||||
native.offsetY > target.clientHeight)
|
||||
)
|
||||
return;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const kind = edge ? "resize" : "move";
|
||||
const base = settledBase();
|
||||
const origin = element.getBoundingClientRect();
|
||||
const startLeft = box.x * pitch;
|
||||
const startTop = box.y * scale;
|
||||
const startWidth = box.w * pitch - inset;
|
||||
const startHeight = box.h * scale;
|
||||
const fixed = slot.h != null;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const grabX = startX - origin.left - startLeft;
|
||||
const grabY = startY - origin.top - startTop;
|
||||
const scroller = scrollParent(element);
|
||||
const others = settled.filter((b) => b.id !== id);
|
||||
let pointer = { x: startX, y: startY };
|
||||
let started = kind === "resize";
|
||||
let current: Interaction | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const compute = (): Interaction => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (kind === "move") {
|
||||
const left = pointer.x - rect.left - grabX;
|
||||
const top = pointer.y - rect.top - grabY;
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
base,
|
||||
proposal: {
|
||||
x: clamp(Math.round(left / pitch), 0, TILE_COLUMNS - box.w),
|
||||
y: Math.max(0, top / scale),
|
||||
},
|
||||
frame: {
|
||||
left,
|
||||
top,
|
||||
width: startWidth,
|
||||
height: fixed ? startHeight : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
const dx = pointer.x - startX;
|
||||
const dy = pointer.y - rect.top - (startY - origin.top);
|
||||
const proposal: Proposal = {};
|
||||
const frame: Interaction["frame"] = {
|
||||
left: startLeft,
|
||||
top: startTop,
|
||||
width: startWidth,
|
||||
height: fixed ? startHeight : undefined,
|
||||
};
|
||||
const minWidth = minColumns * pitch - inset;
|
||||
if (edge === "right" || edge === "corner") {
|
||||
const raw = clamp(
|
||||
startWidth + dx,
|
||||
minWidth,
|
||||
(TILE_COLUMNS - box.x) * pitch - inset,
|
||||
);
|
||||
proposal.w = Math.min(
|
||||
clampTileColumns((raw + inset) / pitch, minColumns),
|
||||
TILE_COLUMNS - box.x,
|
||||
);
|
||||
frame.width = raw;
|
||||
}
|
||||
if (edge === "left") {
|
||||
const right = box.x + box.w;
|
||||
const raw = clamp(startWidth - dx, minWidth, right * pitch - inset);
|
||||
proposal.w = Math.min(
|
||||
clampTileColumns((raw + inset) / pitch, minColumns),
|
||||
right,
|
||||
);
|
||||
proposal.x = right - proposal.w;
|
||||
frame.width = raw;
|
||||
frame.left = startLeft + startWidth - raw;
|
||||
}
|
||||
if (edge === "bottom" || edge === "corner") {
|
||||
let height =
|
||||
clamp(startHeight + dy, minHeight * scale, MAX_TILE_HEIGHT * scale) /
|
||||
scale;
|
||||
// Line up with the bottom of a nearby tile.
|
||||
for (const other of others) {
|
||||
const bottom = other.y + other.h - box.y;
|
||||
if (bottom >= minHeight && Math.abs(bottom - height) <= SNAP) {
|
||||
height = bottom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
proposal.h = Math.round(height);
|
||||
frame.height = proposal.h * scale;
|
||||
}
|
||||
return { id, kind, base, proposal, frame };
|
||||
};
|
||||
const update = () => {
|
||||
current = compute();
|
||||
setActive(current);
|
||||
};
|
||||
const autoScroll = () => {
|
||||
if (!scroller) return;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const step =
|
||||
pointer.y < rect.top + SCROLL_EDGE
|
||||
? -12
|
||||
: pointer.y > rect.bottom - SCROLL_EDGE
|
||||
? 12
|
||||
: 0;
|
||||
if (!step) return;
|
||||
const before = scroller.scrollTop;
|
||||
scroller.scrollTop += step;
|
||||
if (scroller.scrollTop !== before) update();
|
||||
};
|
||||
const move = (e: PointerEvent) => {
|
||||
if (e.pointerId !== event.pointerId) return;
|
||||
pointer = { x: e.clientX, y: e.clientY };
|
||||
if (
|
||||
!started &&
|
||||
Math.hypot(pointer.x - startX, pointer.y - startY) < DRAG_THRESHOLD
|
||||
)
|
||||
return;
|
||||
if (!started) {
|
||||
started = true;
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
if (scroller) timer = setInterval(autoScroll, 16);
|
||||
}
|
||||
update();
|
||||
};
|
||||
const finish = (save: boolean) => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", cancelled);
|
||||
window.removeEventListener("keydown", key);
|
||||
window.removeEventListener("blur", blur);
|
||||
clearInterval(timer);
|
||||
cancel.current = null;
|
||||
setActive(null);
|
||||
if (save && started && current) commit(base, id, current.proposal);
|
||||
};
|
||||
const up = (e: PointerEvent) => {
|
||||
if (e.pointerId === event.pointerId) finish(true);
|
||||
};
|
||||
const cancelled = (e: PointerEvent) => {
|
||||
if (e.pointerId === event.pointerId) finish(false);
|
||||
};
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
finish(false);
|
||||
};
|
||||
const blur = () => finish(false);
|
||||
cancel.current = () => finish(false);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", cancelled);
|
||||
window.addEventListener("keydown", key);
|
||||
window.addEventListener("blur", blur);
|
||||
if (started) update();
|
||||
};
|
||||
|
||||
const moveByKey = (event: ReactKeyboardEvent, id: string) => {
|
||||
const box = settled.find((b) => b.id === id);
|
||||
if (!box || !event.key.startsWith("Arrow")) return;
|
||||
const near = settled.filter(
|
||||
(b) => b.id !== id && b.x < box.x + box.w && box.x < b.x + b.w,
|
||||
);
|
||||
let proposal: Proposal | null = null;
|
||||
if (event.key === "ArrowLeft" && box.x > 0) proposal = { x: box.x - 1 };
|
||||
if (event.key === "ArrowRight" && box.x + box.w < TILE_COLUMNS)
|
||||
proposal = { x: box.x + 1 };
|
||||
if (event.key === "ArrowUp") {
|
||||
const above = near
|
||||
.filter((b) => b.y < box.y)
|
||||
.sort((a, b) => b.y - a.y)[0];
|
||||
if (above) proposal = { y: Math.max(0, above.y - 1) };
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
const below = near
|
||||
.filter((b) => b.y > box.y)
|
||||
.sort((a, b) => a.y - b.y)[0];
|
||||
if (below) proposal = { y: below.y + 1 };
|
||||
}
|
||||
event.preventDefault();
|
||||
if (proposal) commit(settledBase(), id, proposal);
|
||||
};
|
||||
|
||||
const resizeByKey = (
|
||||
event: ReactKeyboardEvent,
|
||||
id: string,
|
||||
axis: "width" | "height",
|
||||
) => {
|
||||
const box = settled.find((b) => b.id === id);
|
||||
if (!box) return;
|
||||
const keys =
|
||||
axis === "width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
|
||||
if (![...keys, "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const sign = event.key === keys[1] ? 1 : -1;
|
||||
if (axis === "width") {
|
||||
const max = TILE_COLUMNS - box.x;
|
||||
const w =
|
||||
event.key === "Home"
|
||||
? minColumns
|
||||
: event.key === "End"
|
||||
? max
|
||||
: clamp(box.w + sign, minColumns, max);
|
||||
if (w !== box.w) commit(settledBase(), id, { w });
|
||||
} else {
|
||||
const current = Math.round(box.h);
|
||||
const h =
|
||||
event.key === "Home"
|
||||
? minHeight
|
||||
: event.key === "End"
|
||||
? MAX_TILE_HEIGHT
|
||||
: clamp(
|
||||
current + sign * (event.shiftKey ? 50 : 10),
|
||||
minHeight,
|
||||
MAX_TILE_HEIGHT,
|
||||
);
|
||||
if (h !== current) commit(settledBase(), id, { h });
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (id: string) =>
|
||||
commit(
|
||||
settledBase().filter((slot) => slot.id !== id),
|
||||
null,
|
||||
{},
|
||||
);
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={seamless}>
|
||||
<div
|
||||
ref={root}
|
||||
className={cn("flex flex-col", !seamless && "gap-3", className)}
|
||||
>
|
||||
{[...settled]
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x)
|
||||
.map((box) => (
|
||||
<div
|
||||
key={box.id}
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
seamless && "border-b border-border",
|
||||
)}
|
||||
>
|
||||
{content.get(box.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const activeBox = active ? boxById.get(active.id) : undefined;
|
||||
// Edge to edge tiles sit on whole px so the lines between them are crisp.
|
||||
const snap = (value: number) => (seamless ? Math.round(value) : value);
|
||||
const leftOf = (box: TileBox) => snap(box.x * pitch);
|
||||
const widthOf = (box: TileBox) =>
|
||||
seamless
|
||||
? snap((box.x + box.w) * pitch) - snap(box.x * pitch)
|
||||
: box.w * pitch - inset;
|
||||
const bottom = tileBottom(preview, settleGap) * scale;
|
||||
// A kept bottom line sits one px under the last row.
|
||||
const height = Math.max(
|
||||
0,
|
||||
seamless ? snap(bottom) + (overflows ? 0 : 1) : bottom - inset,
|
||||
);
|
||||
const lines = seamless
|
||||
? gridLines(
|
||||
preview.map((box) => ({
|
||||
l: leftOf(box),
|
||||
t: snap(box.y * scale),
|
||||
r: leftOf(box) + widthOf(box),
|
||||
b: snap((box.y + box.h) * scale),
|
||||
})),
|
||||
snap(width),
|
||||
overflows,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={seamless}>
|
||||
<div ref={root} className={className}>
|
||||
<div
|
||||
ref={gridEl}
|
||||
className="tg-grid"
|
||||
data-edit-mode={editing}
|
||||
data-animate={editing}
|
||||
style={{ height }}
|
||||
>
|
||||
<style>{styles}</style>
|
||||
{lines.map((line) => (
|
||||
<div
|
||||
key={`${line.x},${line.y},${line.w},${line.h}`}
|
||||
data-tg-line
|
||||
aria-hidden="true"
|
||||
// A real border, so it is as thin as every other line at any
|
||||
// display scaling; a 1 px fill rounds up to two screen px.
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-[25] border-border",
|
||||
line.w === 1 ? "border-l" : "border-t",
|
||||
)}
|
||||
style={{
|
||||
left: line.x,
|
||||
top: line.y,
|
||||
width: line.w === 1 ? 0 : line.w,
|
||||
height: line.h === 1 ? 0 : line.h,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{activeBox && (
|
||||
<div
|
||||
data-tg-drop-preview={activeBox.id}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform: `translate(${leftOf(activeBox)}px, ${snap(activeBox.y * scale)}px)`,
|
||||
width: widthOf(activeBox),
|
||||
height: activeBox.h * scale,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{slots.map((slot) => {
|
||||
const box = boxById.get(slot.id)!;
|
||||
const frame = active?.id === slot.id ? active.frame : null;
|
||||
const tileHeight = frame
|
||||
? frame.height
|
||||
: seamless
|
||||
? snap((box.y + box.h) * scale) - snap(box.y * scale)
|
||||
: slot.h != null
|
||||
? slot.h * scale
|
||||
: undefined;
|
||||
return (
|
||||
<Tile
|
||||
key={slot.id}
|
||||
id={slot.id}
|
||||
box={box}
|
||||
label={tileLabel(slot.id)}
|
||||
editMode={editing}
|
||||
active={active?.id === slot.id ? active.kind : undefined}
|
||||
left={frame ? frame.left : leftOf(box)}
|
||||
top={frame ? frame.top : snap(box.y * scale)}
|
||||
width={frame ? frame.width : widthOf(box)}
|
||||
height={tileHeight}
|
||||
measure={slot.h == null}
|
||||
scale={scale}
|
||||
seamless={seamless}
|
||||
minColumns={minColumns}
|
||||
minHeight={minHeight}
|
||||
onHeight={reportHeight}
|
||||
onPointerDown={(event, edge) => begin(event, slot.id, edge)}
|
||||
onMoveKey={(event) => moveByKey(event, slot.id)}
|
||||
onResizeKey={(event, axis) => resizeByKey(event, slot.id, axis)}
|
||||
onSettings={onSettings ? () => onSettings(slot.id) : undefined}
|
||||
onRemove={() => remove(slot.id)}
|
||||
>
|
||||
{content.get(slot.id)}
|
||||
</Tile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editing && slots.length === 0 && (
|
||||
<div className="flex items-center justify-center border border-dashed border-border py-10 text-xs text-muted-foreground">
|
||||
{empty ?? t("tileGrid.empty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({
|
||||
id,
|
||||
box,
|
||||
label,
|
||||
editMode,
|
||||
active,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
measure,
|
||||
scale,
|
||||
seamless,
|
||||
minColumns,
|
||||
minHeight,
|
||||
onHeight,
|
||||
onPointerDown,
|
||||
onMoveKey,
|
||||
onResizeKey,
|
||||
onSettings,
|
||||
onRemove,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
box: TileBox;
|
||||
label: string;
|
||||
editMode: boolean;
|
||||
active?: Interaction["kind"];
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height?: number;
|
||||
/** The tile has no height of its own and reports its content's. */
|
||||
measure: boolean;
|
||||
scale: number;
|
||||
seamless: boolean;
|
||||
minColumns: number;
|
||||
minHeight: number;
|
||||
onHeight: (id: string, height: number) => void;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLElement>, edge?: Edge) => void;
|
||||
onMoveKey: (event: ReactKeyboardEvent) => void;
|
||||
onResizeKey: (event: ReactKeyboardEvent, axis: "width" | "height") => void;
|
||||
onSettings?: () => void;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const fixed = !measure;
|
||||
|
||||
// Tiles with no height of their own report their content's so the grid can
|
||||
// place them. Edge to edge, that is rounded up to a whole px.
|
||||
useLayoutEffect(() => {
|
||||
const element = seamless ? body.current : card.current;
|
||||
if (!element || fixed) return;
|
||||
const report = () => {
|
||||
const own = element.getBoundingClientRect().height;
|
||||
onHeight(id, (seamless ? Math.ceil(own) : own) / scale);
|
||||
};
|
||||
report();
|
||||
const observer = new ResizeObserver(report);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [id, fixed, scale, seamless, onHeight]);
|
||||
|
||||
const button =
|
||||
"flex size-6 items-center justify-center border border-border bg-card text-muted-foreground hover:text-foreground";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tg-tile={id}
|
||||
data-active={active}
|
||||
className="tg-tile"
|
||||
style={{
|
||||
transform: `translate(${left}px, ${top}px)`,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
onPointerDown={editMode ? (event) => onPointerDown(event) : undefined}
|
||||
>
|
||||
<div
|
||||
ref={card}
|
||||
className={cn(
|
||||
"tg-card relative flex min-h-0 min-w-0 flex-col",
|
||||
seamless && "min-h-full",
|
||||
)}
|
||||
data-tg-card={id}
|
||||
data-fixed-height={fixed}
|
||||
>
|
||||
<div
|
||||
ref={body}
|
||||
className={cn(
|
||||
"tg-content min-h-0",
|
||||
seamless && measure ? "flex-none" : "flex-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{editMode && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 z-10 border-2 border-dashed border-accent-brand/30" />
|
||||
<div className="absolute top-1 right-1 z-20 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(button, "cursor-grab active:cursor-grabbing")}
|
||||
title={t("tileGrid.move")}
|
||||
aria-label={t("tileGrid.moveTile", { tile: label })}
|
||||
onKeyDown={onMoveKey}
|
||||
data-tg-grip
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
{onSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
title={t("tileGrid.settings", { tile: label })}
|
||||
aria-label={t("tileGrid.settings", { tile: label })}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
button,
|
||||
"hover:border-destructive/40 hover:text-destructive",
|
||||
)}
|
||||
title={t("tileGrid.remove", { tile: label })}
|
||||
aria-label={t("tileGrid.remove", { tile: label })}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="tg-handle tg-edge-left"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => onPointerDown(event, "left")}
|
||||
/>
|
||||
{(["width", "height"] as const).map((axis) => (
|
||||
<div
|
||||
key={axis}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation={axis === "width" ? "vertical" : "horizontal"}
|
||||
aria-label={`${label}: ${t(axis === "width" ? "tileGrid.width" : "tileGrid.height")}`}
|
||||
aria-valuemin={axis === "width" ? minColumns : minHeight}
|
||||
aria-valuemax={
|
||||
axis === "width" ? TILE_COLUMNS : MAX_TILE_HEIGHT
|
||||
}
|
||||
aria-valuenow={axis === "width" ? box.w : Math.round(box.h)}
|
||||
title={t(
|
||||
axis === "width" ? "tileGrid.width" : "tileGrid.height",
|
||||
)}
|
||||
className={`tg-handle ${axis === "width" ? "tg-edge-right" : "tg-edge-bottom"}`}
|
||||
onPointerDown={(event) =>
|
||||
onPointerDown(event, axis === "width" ? "right" : "bottom")
|
||||
}
|
||||
onKeyDown={(event) => onResizeKey(event, axis)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
axis === "width"
|
||||
? "h-12 w-0.5 rounded-full"
|
||||
: "h-0.5 w-12 rounded-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="tg-handle tg-edge-corner"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => onPointerDown(event, "corner")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TileKind =
|
||||
"card" | "signal" | "action" | "session" | "resource" | "chart";
|
||||
|
||||
export interface TilePickerEntry {
|
||||
/** Tile id the grid gets when this entry is added. */
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading, usually the plugin name. */
|
||||
group: string;
|
||||
kind: TileKind;
|
||||
icon?: ReactNode;
|
||||
/** Already on the grid; shown checked and not added twice. */
|
||||
placed?: boolean;
|
||||
}
|
||||
|
||||
const KINDS: TileKind[] = [
|
||||
"card",
|
||||
"chart",
|
||||
"signal",
|
||||
"action",
|
||||
"resource",
|
||||
"session",
|
||||
];
|
||||
const KIND_LABELS: Record<TileKind | "all", string> = {
|
||||
all: "tileGrid.kind.all",
|
||||
card: "tileGrid.kind.card",
|
||||
chart: "tileGrid.kind.chart",
|
||||
signal: "tileGrid.kind.signal",
|
||||
action: "tileGrid.kind.action",
|
||||
resource: "tileGrid.kind.resource",
|
||||
session: "tileGrid.kind.session",
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything that can go on a grid, in a narrow column beside it while
|
||||
* editing. Search, filter by kind and fold groups, so long lists from many
|
||||
* plugins stay usable.
|
||||
*/
|
||||
export function TilePicker({
|
||||
entries,
|
||||
onAdd,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
entries: TilePickerEntry[];
|
||||
onAdd: (entry: TilePickerEntry) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [kind, setKind] = useState<TileKind | null>(null);
|
||||
const [folded, setFolded] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const kinds = useMemo(
|
||||
() => KINDS.filter((k) => entries.some((entry) => entry.kind === k)),
|
||||
[entries],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const shown = entries.filter(
|
||||
(entry) =>
|
||||
(!kind || entry.kind === kind) &&
|
||||
(!needle ||
|
||||
entry.title.toLowerCase().includes(needle) ||
|
||||
entry.group.toLowerCase().includes(needle) ||
|
||||
entry.description?.toLowerCase().includes(needle)),
|
||||
);
|
||||
const map = new Map<string, TilePickerEntry[]>();
|
||||
for (const entry of shown) {
|
||||
const list = map.get(entry.group) ?? [];
|
||||
list.push(entry);
|
||||
map.set(entry.group, list);
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [entries, query, kind]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
const toggle = (group: string) =>
|
||||
setFolded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(group)) next.delete(group);
|
||||
else next.add(group);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-72 shrink-0 flex-col border-l border-border bg-surface/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<Plus className="size-3 text-accent-brand" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("tileGrid.addTiles")}
|
||||
</span>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
title={t("tileGrid.closePicker")}
|
||||
aria-label={t("tileGrid.closePicker")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b border-border p-2">
|
||||
<label className="flex h-7 items-center gap-1.5 border border-border bg-background px-2">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("tileGrid.search")}
|
||||
aria-label={t("tileGrid.search")}
|
||||
className="min-w-0 flex-1 bg-transparent text-xs outline-none"
|
||||
/>
|
||||
</label>
|
||||
{kinds.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[null, ...kinds].map((k) => (
|
||||
<button
|
||||
key={k ?? "all"}
|
||||
type="button"
|
||||
onClick={() => setKind(k)}
|
||||
className={cn(
|
||||
"h-5 border px-1.5 text-[10px] transition-colors",
|
||||
kind === k
|
||||
? "border-accent-brand text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(KIND_LABELS[k ?? "all"])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{groups.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{t("tileGrid.noMatches")}
|
||||
</p>
|
||||
)}
|
||||
{groups.map(([group, list]) => {
|
||||
const open = searching || !folded.has(group);
|
||||
return (
|
||||
<div key={group} className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(group)}
|
||||
aria-expanded={open}
|
||||
className="flex h-7 w-full items-center gap-1.5 px-2 text-left hover:bg-hover"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{group}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/60">
|
||||
{list.length}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="pb-1">
|
||||
{list.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={entry.placed}
|
||||
onClick={() => onAdd(entry)}
|
||||
title={entry.description}
|
||||
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-hover disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3">
|
||||
{entry.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{entry.title}</span>
|
||||
{entry.description && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground">
|
||||
{entry.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{entry.placed ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<Plus className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge, EdgeOwned } from "./edge";
|
||||
|
||||
/**
|
||||
* Sections stacked edge to edge with a line under each, the column form of
|
||||
* the tile look. Cards inside drop their own border. The last line goes away
|
||||
* when the stack reaches the bottom of the screen.
|
||||
*/
|
||||
export function TileStack({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
<BottomEdge className={className} innerClassName="flex min-w-0 flex-col">
|
||||
<EdgeOwned>
|
||||
{Children.toArray(children).map((section, index) => (
|
||||
<div
|
||||
key={keyOf(section, index)}
|
||||
className="min-w-0 shrink-0 border-b border-border"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
))}
|
||||
</EdgeOwned>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(node: ReactNode, index: number): string | number {
|
||||
return isValidElement(node) && node.key !== null ? node.key : index;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { gridSizeStyle, type GridSize } from "@/components/panel-layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge } from "./edge";
|
||||
|
||||
/**
|
||||
* A fixed grid of tiles edge to edge with one line between neighbours, for
|
||||
* stat strips and card lists. `size` is tiles per row ("auto" fits as many as
|
||||
* `minWidth` allows). Use TileGrid when people should move and resize tiles.
|
||||
*/
|
||||
export function TileWall({
|
||||
size = "auto",
|
||||
minWidth = 280,
|
||||
gridClassName,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
size?: GridSize;
|
||||
minWidth?: number;
|
||||
/** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */
|
||||
gridClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tiles = Children.toArray(children);
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
{/* The grid sits a pixel wider so the right edge line is cut off. */}
|
||||
<BottomEdge className={className}>
|
||||
<div
|
||||
className={cn("-mr-px grid", gridClassName)}
|
||||
style={
|
||||
gridClassName ? undefined : gridSizeStyle(size, minWidth, "0px")
|
||||
}
|
||||
>
|
||||
{tiles.map((tile, index) => (
|
||||
<div
|
||||
key={keyOf(tile, index)}
|
||||
className="flex min-w-0 flex-col border-b border-r border-border *:flex-1"
|
||||
>
|
||||
{tile}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(node: ReactNode, index: number): string | number {
|
||||
return isValidElement(node) && node.key !== null ? node.key : index;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** True inside an edge to edge TileGrid, where the grid draws the lines. */
|
||||
export const SeamlessTileContext = createContext(false);
|
||||
|
||||
export function useInSeamlessTile(): boolean {
|
||||
return useContext(SeamlessTileContext);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function scrollParent(element: HTMLElement | null) {
|
||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True inside a TileStack or TileColumn section, which draws its own line. */
|
||||
const EdgeOwnedContext = createContext(false);
|
||||
|
||||
export function EdgeOwned({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<EdgeOwnedContext.Provider value={true}>
|
||||
{children}
|
||||
</EdgeOwnedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the element's end reaches the bottom of the visible scroll area,
|
||||
* where a closing line would sit on the screen edge and only add noise.
|
||||
*/
|
||||
function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
|
||||
const [reaches, setReaches] = useState(false);
|
||||
const reachesRef = useRef(reaches);
|
||||
reachesRef.current = reaches;
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
const scroller = scrollParent(element);
|
||||
if (!element || !scroller) return;
|
||||
// Measured without the line itself, so showing it never flips the answer.
|
||||
const update = () => {
|
||||
const end =
|
||||
element.getBoundingClientRect().bottom -
|
||||
scroller.getBoundingClientRect().top +
|
||||
scroller.scrollTop -
|
||||
(reachesRef.current ? 0 : 1);
|
||||
setReaches(end >= scroller.clientHeight - 0.5);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(scroller);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return reaches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps tiles whose last row draws a line under itself. The line stays when
|
||||
* there is room below and is clipped when the tiles reach the bottom of the
|
||||
* screen, the same rule the tile grid uses. Inside a stack section the
|
||||
* section owns the line, so it is always clipped.
|
||||
*/
|
||||
export function BottomEdge({
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const owned = useContext(EdgeOwnedContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reaches = useReachesBottom(ref);
|
||||
return (
|
||||
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
|
||||
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { TileGrid, type TileGridProps } from "./TileGrid";
|
||||
export { TileFrame } from "./TileFrame";
|
||||
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
|
||||
export { TileWall } from "./TileWall";
|
||||
export { TileStack } from "./TileStack";
|
||||
export { TileColumn } from "./TileColumn";
|
||||
export { TileEditBar } from "./TileEditBar";
|
||||
export { SeamlessTileContext, useInSeamlessTile } from "./context";
|
||||
export * from "./layout";
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* A tile on a 12 column grid. x and w are columns, y is unscaled px from the
|
||||
* top (the grid settles it), h is a fixed height in unscaled px or null for
|
||||
* the tile's natural height.
|
||||
*/
|
||||
export interface TileSlot {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h?: number | null;
|
||||
}
|
||||
|
||||
/** A tile with a resolved height, as the grid draws it. */
|
||||
export interface TileBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export const TILE_COLUMNS = 12;
|
||||
export const MIN_TILE_COLUMNS = 2;
|
||||
export const MIN_TILE_HEIGHT = 48;
|
||||
export const MAX_TILE_HEIGHT = 10000;
|
||||
/** y given to a new tile so it settles under everything else. */
|
||||
export const BOTTOM = 1_000_000;
|
||||
|
||||
export const clampTileColumns = (w: number, min = MIN_TILE_COLUMNS) =>
|
||||
Math.max(min, Math.min(TILE_COLUMNS, Math.round(w) || min));
|
||||
|
||||
const overlapsX = (a: TileBox, b: TileBox) =>
|
||||
a.x < b.x + b.w && b.x < a.x + a.w;
|
||||
|
||||
/**
|
||||
* Settles tiles top to bottom in (y, x) order. Each tile sits right under the
|
||||
* lowest tile placed before it in the columns it covers. Tiles never move
|
||||
* sideways and never jump above a tile that came before them.
|
||||
*/
|
||||
export function settleTiles(boxes: TileBox[], gap = 0): TileBox[] {
|
||||
const order = [...boxes].sort(
|
||||
(a, b) => a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : 1),
|
||||
);
|
||||
const placed: TileBox[] = [];
|
||||
for (const box of order) {
|
||||
const y = placed.reduce(
|
||||
(top, p) => (overlapsX(p, box) ? Math.max(top, p.y + p.h + gap) : top),
|
||||
0,
|
||||
);
|
||||
placed.push({ ...box, y });
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
export function tileBoxes(
|
||||
slots: TileSlot[],
|
||||
heights: Record<string, number>,
|
||||
fallback = 160,
|
||||
): TileBox[] {
|
||||
return slots.map((slot) => ({
|
||||
id: slot.id,
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
w: slot.w,
|
||||
h: slot.h ?? heights[slot.id] ?? fallback,
|
||||
}));
|
||||
}
|
||||
|
||||
export function tileBottom(boxes: TileBox[], gap = 0) {
|
||||
return boxes.reduce((max, box) => Math.max(max, box.y + box.h + gap), 0);
|
||||
}
|
||||
|
||||
/** Drops broken entries and duplicates and keeps every tile inside the grid. */
|
||||
export function sanitizeTiles(
|
||||
value: unknown,
|
||||
min = MIN_TILE_COLUMNS,
|
||||
): TileSlot[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: TileSlot[] = [];
|
||||
for (const raw of value) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const slot = raw as Record<string, unknown>;
|
||||
const id = typeof slot.id === "string" ? slot.id : "";
|
||||
if (!id || id.length > 300 || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const w = clampTileColumns(Number(slot.w), min);
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(TILE_COLUMNS - w, Math.round(Number(slot.x)) || 0),
|
||||
);
|
||||
const y = Math.max(0, Number(slot.y) || 0);
|
||||
const h =
|
||||
slot.h == null || !Number.isFinite(Number(slot.h))
|
||||
? null
|
||||
: Math.max(
|
||||
MIN_TILE_HEIGHT,
|
||||
Math.min(MAX_TILE_HEIGHT, Math.round(Number(slot.h))),
|
||||
);
|
||||
out.push({ id, x, y, w, h });
|
||||
}
|
||||
return out.slice(0, 200);
|
||||
}
|
||||
|
||||
/** Adds a tile at the bottom, at the left. */
|
||||
export function appendTile(
|
||||
slots: TileSlot[],
|
||||
tile: { id: string; w?: number; h?: number | null },
|
||||
): TileSlot[] {
|
||||
if (slots.some((slot) => slot.id === tile.id)) return slots;
|
||||
return [
|
||||
...slots,
|
||||
{
|
||||
id: tile.id,
|
||||
x: 0,
|
||||
y: BOTTOM,
|
||||
w: clampTileColumns(tile.w ?? 4),
|
||||
h: tile.h ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function removeTile(slots: TileSlot[], id: string): TileSlot[] {
|
||||
return slots.filter((slot) => slot.id !== id);
|
||||
}
|
||||
|
||||
/** Writes settled positions back, in reading order. */
|
||||
export function tilesFromBoxes(
|
||||
slots: TileSlot[],
|
||||
boxes: TileBox[],
|
||||
): TileSlot[] {
|
||||
const byId = new Map(boxes.map((box) => [box.id, box]));
|
||||
return slots
|
||||
.map((slot) => {
|
||||
const box = byId.get(slot.id);
|
||||
return box ? { ...slot, x: box.x, y: Math.round(box.y), w: box.w } : slot;
|
||||
})
|
||||
.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays tiles out left to right in rows, for layouts that only know an order
|
||||
* and a width (old dashboards, generated defaults).
|
||||
*/
|
||||
export function flowTiles(
|
||||
tiles: { id: string; w: number; h?: number | null }[],
|
||||
rowHeight = 160,
|
||||
): TileSlot[] {
|
||||
const out: TileSlot[] = [];
|
||||
let x = 0;
|
||||
let row = 0;
|
||||
for (const tile of tiles) {
|
||||
const w = clampTileColumns(tile.w);
|
||||
if (x + w > TILE_COLUMNS) {
|
||||
x = 0;
|
||||
row++;
|
||||
}
|
||||
out.push({ id: tile.id, x, y: row * rowHeight, w, h: tile.h ?? null });
|
||||
x += w;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A tile's box in whole screen px. */
|
||||
export interface PixelRect {
|
||||
l: number;
|
||||
t: number;
|
||||
r: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** A 1 px line the grid draws, as a box. */
|
||||
export interface GridLine {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function mergeSpans(spans: [number, number][]): [number, number][] {
|
||||
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [];
|
||||
for (const [start, end] of sorted) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
|
||||
else out.push([start, end]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines between edge to edge tiles, each drawn once. A line sits on the
|
||||
* first px column or row past a tile, so it covers the edge of whatever is
|
||||
* next to it. The outer left, top and right edges are left out. With
|
||||
* `openBottom` (the grid runs to the bottom of the screen) so is the line
|
||||
* along the very bottom; a column that ends higher keeps its bottom line.
|
||||
* Lines reach one px further so corners join.
|
||||
*/
|
||||
export function gridLines(
|
||||
rects: PixelRect[],
|
||||
width: number,
|
||||
openBottom = false,
|
||||
): GridLine[] {
|
||||
const lowest = Math.max(0, ...rects.map((rect) => rect.b));
|
||||
const end = openBottom ? lowest : Infinity;
|
||||
const vertical = new Map<number, [number, number][]>();
|
||||
const horizontal = new Map<number, [number, number][]>();
|
||||
const add = (
|
||||
map: Map<number, [number, number][]>,
|
||||
at: number,
|
||||
span: [number, number],
|
||||
) => {
|
||||
if (span[1] <= span[0]) return;
|
||||
map.set(at, [...(map.get(at) ?? []), span]);
|
||||
};
|
||||
for (const rect of rects) {
|
||||
if (rect.r <= rect.l || rect.b <= rect.t) continue;
|
||||
const down: [number, number] = [rect.t, Math.min(end, rect.b + 1)];
|
||||
if (rect.l > 0) add(vertical, rect.l, down);
|
||||
if (rect.r < width) add(vertical, rect.r, down);
|
||||
const across: [number, number] = [rect.l, Math.min(width, rect.r + 1)];
|
||||
if (rect.t > 0) add(horizontal, rect.t, across);
|
||||
if (rect.b < end) add(horizontal, rect.b, across);
|
||||
}
|
||||
const lines: GridLine[] = [];
|
||||
for (const [x, spans] of vertical)
|
||||
for (const [start, end] of mergeSpans(spans))
|
||||
lines.push({ x, y: start, w: 1, h: end - start });
|
||||
for (const [y, spans] of horizontal)
|
||||
for (const [start, end] of mergeSpans(spans))
|
||||
lines.push({ x: start, y, w: end - start, h: 1 });
|
||||
return lines;
|
||||
}
|
||||
@@ -179,10 +179,24 @@ export function UiPreferencesProvider({
|
||||
const merged = { ...pendingPatch.current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (key === "overrides" && value && typeof value === "object") {
|
||||
merged.overrides = {
|
||||
// Two levels deep like the server, so two keys set in one area
|
||||
// in the same burst both get sent.
|
||||
const areas = {
|
||||
...((merged.overrides as Record<string, unknown>) ?? {}),
|
||||
...(value as Record<string, unknown>),
|
||||
};
|
||||
for (const [area, keys] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
const prev = areas[area];
|
||||
areas[area] =
|
||||
keys &&
|
||||
typeof keys === "object" &&
|
||||
prev &&
|
||||
typeof prev === "object"
|
||||
? { ...prev, ...keys }
|
||||
: keys;
|
||||
}
|
||||
merged.overrides = areas;
|
||||
} else if (key === "onboarding" && value && typeof value === "object") {
|
||||
const prev = (merged.onboarding ?? {}) as Record<string, unknown>;
|
||||
const next = value as Record<string, unknown>;
|
||||
|
||||
@@ -6,6 +6,24 @@ export interface DashboardCardRenderProps {
|
||||
isVisible: boolean;
|
||||
/** The shell, e.g. to open the full-page version of the card. */
|
||||
shell: TabShellCallbacks;
|
||||
/** This copy's settings, for cards with entries. Empty otherwise. */
|
||||
config?: Record<string, unknown>;
|
||||
setConfig?: (config: Record<string, unknown>) => void;
|
||||
/** True while the user is arranging the dashboard. */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
/** One thing the add list offers from a card that can be placed many times. */
|
||||
export interface DashboardCardEntry {
|
||||
/** Stable within the card, used to mark entries already placed. */
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading in the add list. Defaults to the card title. */
|
||||
group?: string;
|
||||
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
|
||||
config: Record<string, unknown>;
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +41,10 @@ export interface RegisteredDashboardCard {
|
||||
defaultPanel?: "main" | "side";
|
||||
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */
|
||||
frame?: "framed" | "bare";
|
||||
/** Size on the 12 column dashboard grid when added. */
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
/** Lists what this card can show; each entry becomes its own tile. */
|
||||
entries?: (language: string) => Promise<DashboardCardEntry[]>;
|
||||
component: ComponentType<DashboardCardRenderProps>;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): AxiosInstance {
|
||||
}) as AxiosInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* An axios client rooted at core's /operations routes: the action catalog,
|
||||
* runs, approvals, events, signals and cross host resources.
|
||||
*/
|
||||
export function createOperationsApi(): AxiosInstance {
|
||||
return new Proxy(authApi, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
if (typeof value !== "function") return value;
|
||||
if (
|
||||
property === "get" ||
|
||||
property === "delete" ||
|
||||
property === "post" ||
|
||||
property === "put" ||
|
||||
property === "patch"
|
||||
) {
|
||||
return (url: string, ...rest: unknown[]) =>
|
||||
(value as (...args: unknown[]) => unknown).call(
|
||||
target,
|
||||
`/operations${url.startsWith("/") ? url : `/${url}`}`,
|
||||
...rest,
|
||||
);
|
||||
}
|
||||
return value.bind(target);
|
||||
},
|
||||
}) as AxiosInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw fetch on this plugin's /plugin-api mount point, for a response the
|
||||
* caller reads as a stream (server-sent events). axios buffers the whole
|
||||
|
||||
@@ -10,9 +10,13 @@ export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
|
||||
const label = title || fallback;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
|
||||
{icon && <span className="text-accent-brand shrink-0">{icon}</span>}
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
{icon && (
|
||||
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -609,6 +609,7 @@
|
||||
"hostClickBehavior": "Clicking a host",
|
||||
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
|
||||
"hostClickNewTab": "Always open a new tab",
|
||||
"hostClickHostPage": "Open the host page, when a plugin offers one",
|
||||
"hostClickFocusExisting": "Switch to the open tab",
|
||||
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
|
||||
"openInNewTab": "Open in new tab",
|
||||
@@ -1675,6 +1676,12 @@
|
||||
"title": "Columns",
|
||||
"auto": "Auto",
|
||||
"count": "{{count}} wide"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {{page}}",
|
||||
"pageOf": "Page {{page}} of {{pages}}"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -1693,5 +1700,29 @@
|
||||
"admin": "Admin",
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"tileGrid": {
|
||||
"empty": "No tiles yet. Add some from the list.",
|
||||
"move": "Drag to move",
|
||||
"moveTile": "Move {{tile}}",
|
||||
"settings": "{{tile}} settings",
|
||||
"remove": "Remove {{tile}}",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"addTiles": "Add tiles",
|
||||
"closePicker": "Close",
|
||||
"search": "Search tiles",
|
||||
"noMatches": "Nothing matches.",
|
||||
"kind": {
|
||||
"all": "All",
|
||||
"card": "Cards",
|
||||
"chart": "Charts",
|
||||
"signal": "Status",
|
||||
"action": "Actions",
|
||||
"resource": "Lists",
|
||||
"session": "Sessions"
|
||||
},
|
||||
"reset": "Reset",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
</p>
|
||||
|
||||
{isAdmin && response && (
|
||||
<div className="flex flex-col gap-1.5 border border-border p-2">
|
||||
<div className="mt-1 flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{t("looks.defaultsLabel")}
|
||||
</span>
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
type KeybindingEditorProps,
|
||||
} from "@/shell/keybinding-registry";
|
||||
import { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||
import {
|
||||
registerHostSection,
|
||||
type HostSectionRenderProps,
|
||||
} from "@/shell/host-sections";
|
||||
import {
|
||||
registerDashboardCard,
|
||||
type DashboardCardRenderProps,
|
||||
@@ -424,6 +428,21 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
registerHostSection(section) {
|
||||
return track(
|
||||
registerHostSection({
|
||||
id: section.id,
|
||||
pluginId,
|
||||
titleKey: key(section.titleKey),
|
||||
order: section.order,
|
||||
when: hostCallback(section.when ?? (() => true)) as never,
|
||||
component: scoped(
|
||||
section.component as unknown as ComponentType<HostSectionRenderProps>,
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
registerHostContextMenuItem(item) {
|
||||
return track(
|
||||
registerHostContextMenuItem({
|
||||
@@ -515,6 +534,8 @@ export function createPluginApp(
|
||||
defaultHeight: card.defaultHeight,
|
||||
defaultPanel: card.defaultPanel,
|
||||
frame: card.frame,
|
||||
defaultSize: card.defaultSize,
|
||||
entries: card.entries,
|
||||
component: scoped(
|
||||
card.component as unknown as ComponentType<DashboardCardRenderProps>,
|
||||
),
|
||||
|
||||
@@ -127,6 +127,26 @@ export {
|
||||
type GridLayout,
|
||||
type GridSlot,
|
||||
} from "@/components/card-grid/types";
|
||||
export {
|
||||
SeamlessTileContext,
|
||||
TileEditBar,
|
||||
TileFrame,
|
||||
TileGrid,
|
||||
TilePicker,
|
||||
TileColumn,
|
||||
TileStack,
|
||||
TileWall,
|
||||
useInSeamlessTile,
|
||||
appendTile,
|
||||
flowTiles,
|
||||
removeTile,
|
||||
sanitizeTiles,
|
||||
TILE_COLUMNS,
|
||||
type TileGridProps,
|
||||
type TileKind,
|
||||
type TilePickerEntry,
|
||||
type TileSlot,
|
||||
} from "@/components/tile-grid";
|
||||
export { ConnectionScreen } from "@/components/connection/ConnectionScreen";
|
||||
export { type ConnectionStatus } from "@/components/connection/connection-status";
|
||||
export {
|
||||
@@ -203,6 +223,19 @@ export { WidgetTitle } from "@/lib/widget-title";
|
||||
export { runVisibleInterval } from "@/lib/visible-interval";
|
||||
export { useIsMobile } from "@/hooks/use-mobile";
|
||||
export { EmptyState } from "@/components/empty-state";
|
||||
export { NoticePanel } from "@/components/notice-panel";
|
||||
export { LoadingState } from "@/components/loading-state";
|
||||
export {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
statusTone,
|
||||
type StatusTone,
|
||||
} from "@/components/status";
|
||||
export { StatTile } from "@/components/stat-tile";
|
||||
export { InfoGrid, type InfoItem } from "@/components/info-grid";
|
||||
export { Pager, pageOf, usePaged } from "@/components/pager";
|
||||
export { DataTable, DataTableRow } from "@/components/data-table";
|
||||
export { SideNav, SideNavItem, type SideNavEntry } from "@/components/side-nav";
|
||||
export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog";
|
||||
export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog";
|
||||
|
||||
@@ -285,8 +318,10 @@ export {
|
||||
Facts,
|
||||
GroupHeading,
|
||||
PANEL,
|
||||
PanelFrame,
|
||||
PanelSearch,
|
||||
PanelShell,
|
||||
RefreshButton,
|
||||
Segmented,
|
||||
ViewToggle,
|
||||
type GridSize,
|
||||
@@ -823,3 +858,13 @@ export {
|
||||
rightDockableIds,
|
||||
useRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
export {
|
||||
hostSectionsFor,
|
||||
type HostSectionDef,
|
||||
listHostSections,
|
||||
useHostSections,
|
||||
} from "@/shell/host-sections";
|
||||
export { HostTabEmbed } from "@/shell/HostTabEmbed";
|
||||
import { createOperationsApi } from "@/lib/plugin-transport";
|
||||
/** Core's /operations routes, with the user's own session. */
|
||||
export const operationsApi = createOperationsApi();
|
||||
@@ -471,6 +471,7 @@ export {
|
||||
resetHostContributions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
export { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||
export { registerHostSection, resetHostSections } from "@/shell/host-sections";
|
||||
export {
|
||||
RAIL_ITEMS,
|
||||
registerRailItem,
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Facts, PANEL } from "@/components/panel-layout";
|
||||
import { Facts } from "@/components/panel-layout";
|
||||
import { TileStack } from "@/components/tile-grid/TileStack";
|
||||
import { TileWall } from "@/components/tile-grid/TileWall";
|
||||
import { FakeSwitch, SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
Select,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
betaToTry,
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
hasSourceBadge,
|
||||
orderByRisk,
|
||||
reportIssueUrl,
|
||||
requestFeatureUrl,
|
||||
@@ -52,7 +54,7 @@ import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
const LINK_ROW =
|
||||
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
||||
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground";
|
||||
|
||||
export function PluginDetail({
|
||||
plugin,
|
||||
@@ -69,13 +71,16 @@ export function PluginDetail({
|
||||
const running = plugin.status === "running";
|
||||
const failed = plugin.status === "failed" || plugin.status === "blocked";
|
||||
const compatible = plugin.versions.filter((v) => v.compatible);
|
||||
const [picked, setPicked] = useState<string>(
|
||||
plugin.version ?? plugin.latestVersion ?? "",
|
||||
);
|
||||
// A local build can be a version the registry never listed.
|
||||
const startVersion =
|
||||
plugin.version && compatible.some((v) => v.version === plugin.version)
|
||||
? plugin.version
|
||||
: (plugin.latestVersion ?? compatible[0]?.version ?? "");
|
||||
const [picked, setPicked] = useState<string>(startVersion);
|
||||
|
||||
useEffect(() => {
|
||||
setPicked(plugin.version ?? plugin.latestVersion ?? "");
|
||||
}, [plugin.version, plugin.latestVersion]);
|
||||
setPicked(startVersion);
|
||||
}, [startVersion]);
|
||||
|
||||
const contributions = describeContributions(plugin.contributes);
|
||||
// Only people who can manage plugins get here, and they see admin settings
|
||||
@@ -93,10 +98,8 @@ export function PluginDetail({
|
||||
const tryableBeta = betaToTry(plugin);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`}
|
||||
>
|
||||
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
|
||||
<TileStack>
|
||||
<div className="flex flex-row flex-wrap items-start gap-3 p-3">
|
||||
<PluginIconBox
|
||||
name={plugin.icon}
|
||||
size="lg"
|
||||
@@ -109,10 +112,12 @@ export function PluginDetail({
|
||||
</span>
|
||||
<Facts className="text-[11px] text-muted-foreground/70">
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{hasSourceBadge(plugin.source) && (
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
)}
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.version ? (
|
||||
@@ -223,10 +228,10 @@ export function PluginDetail({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{plugin.installed && failed && plugin.lastError && (
|
||||
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2 bg-destructive/5 px-3 py-2.5">
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
@@ -242,7 +247,7 @@ export function PluginDetail({
|
||||
)}
|
||||
|
||||
{plugin.status === "server" && (
|
||||
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2 bg-muted/30 px-3 py-2.5">
|
||||
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.runsOnServerHint")}
|
||||
@@ -250,13 +255,13 @@ export function PluginDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3">
|
||||
<TileStack className="-mb-px border-border lg:col-span-2 lg:border-r">
|
||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||
|
||||
{plugin.features.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.features")} icon={null}>
|
||||
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 px-4 py-3 sm:grid-cols-2">
|
||||
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 py-3 sm:grid-cols-2">
|
||||
{plugin.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
@@ -277,11 +282,11 @@ export function PluginDetail({
|
||||
key={capability}
|
||||
capability={capability}
|
||||
showId
|
||||
className="px-4 py-2.5"
|
||||
className="py-2.5"
|
||||
/>
|
||||
))}
|
||||
{plugin.capabilities.length === 0 && (
|
||||
<div className="px-4 py-2.5 text-[11px] text-muted-foreground">
|
||||
<div className="py-2.5 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.consent.none")}
|
||||
</div>
|
||||
)}
|
||||
@@ -292,10 +297,7 @@ export function PluginDetail({
|
||||
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
{plugin.env.map((v) => (
|
||||
<div
|
||||
key={v.name}
|
||||
className="flex flex-col gap-0.5 px-4 py-2.5"
|
||||
>
|
||||
<div key={v.name} className="flex flex-col gap-0.5 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="font-mono text-xs font-semibold">
|
||||
{v.name}
|
||||
@@ -331,12 +333,12 @@ export function PluginDetail({
|
||||
onDelete={() => manager.removeData(plugin)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TileStack>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<TileStack className="-mb-px">
|
||||
{contributions.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.addsToTermix")} icon={null}>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{contributions.map((c) => (
|
||||
<span
|
||||
@@ -358,7 +360,7 @@ export function PluginDetail({
|
||||
|
||||
{plugin.inRegistry && compatible.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.version")} icon={null}>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{plugin.installed
|
||||
? t("plugins.manager.versionPickHintInstalled")
|
||||
@@ -414,7 +416,7 @@ export function PluginDetail({
|
||||
{plugin.installed && (
|
||||
<SectionCard title={t("plugins.manager.updates")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("plugins.manager.autoUpdate")}
|
||||
@@ -431,7 +433,7 @@ export function PluginDetail({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<FlaskConical className="size-3 text-muted-foreground" />
|
||||
@@ -455,7 +457,7 @@ export function PluginDetail({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<Pin className="size-3 text-muted-foreground" />
|
||||
@@ -565,9 +567,9 @@ export function PluginDetail({
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TileStack>
|
||||
</div>
|
||||
</div>
|
||||
</TileStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,16 +627,16 @@ function DataCard({
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<div className="px-4 py-3 text-[11px] text-muted-foreground">
|
||||
<div className="py-3 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.data.loadFailed")}
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="px-4 py-3 text-[11px] text-muted-foreground">
|
||||
<div className="py-3 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mx-3 -my-1 flex flex-col md:-mx-4">
|
||||
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
|
||||
<div className="-mx-3 -my-1 flex flex-col">
|
||||
<TileWall gridClassName="grid-cols-2 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("plugins.manager.data.tables")}
|
||||
value={`${data.tables.length}`}
|
||||
@@ -655,13 +657,13 @@ function DataCard({
|
||||
label={t("plugins.manager.data.files")}
|
||||
value={formatBytes(data.filesBytes)}
|
||||
/>
|
||||
</div>
|
||||
</TileWall>
|
||||
{data.tables.length > 0 && (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{data.tables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="flex items-center gap-2 px-4 py-1.5"
|
||||
className="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">
|
||||
{table.name}
|
||||
@@ -675,7 +677,7 @@ function DataCard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t("plugins.manager.data.migrations", {
|
||||
count: data.migrations.length,
|
||||
@@ -701,7 +703,7 @@ function Stat({
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 bg-card px-4 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 px-3 py-2.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -62,7 +62,7 @@ function Release({
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold">{row.version}</span>
|
||||
{row.version === installedVersion && (
|
||||
@@ -180,7 +180,7 @@ export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="-mx-3 -my-1 md:-mx-4">
|
||||
<div className="-mx-3 -my-1">
|
||||
<Release row={latest} installedVersion={plugin.version} />
|
||||
{showOlder && older.length > 0 && (
|
||||
<div className="max-h-96 divide-y divide-border overflow-y-auto border-t border-border">
|
||||
@@ -211,7 +211,7 @@ export function PluginVideo({
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div className="aspect-video w-full border border-border bg-black">
|
||||
<div className="aspect-video w-full bg-black">
|
||||
<iframe
|
||||
src={src}
|
||||
title={t("plugins.manager.videoTitle", { name })}
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
type PanelViewMode,
|
||||
} from "@/components/panel-layout";
|
||||
import { DataView, type DataColumn } from "@/components/data-view";
|
||||
import { StatusDot } from "@/components/status";
|
||||
import { TileStack } from "@/components/tile-grid/TileStack";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -57,6 +59,7 @@ import { PluginDetail } from "./PluginDetail";
|
||||
import {
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
hasSourceBadge,
|
||||
isSevere,
|
||||
kindsOf,
|
||||
matchesQuery,
|
||||
@@ -321,7 +324,7 @@ function Toolbar({
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="relative min-w-40 max-w-md flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -346,8 +349,13 @@ function Group({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-2.5">
|
||||
<GroupHeading title={title} count={count} />
|
||||
<section className="flex flex-col">
|
||||
<GroupHeading
|
||||
plain
|
||||
title={title}
|
||||
count={count}
|
||||
className="border-b border-border px-3 py-1.5"
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
@@ -653,7 +661,7 @@ function ActiveFilters({
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-1.5">
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-1.5 border-b border-border px-3 py-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.filters.results", { count: shown })}
|
||||
</span>
|
||||
@@ -703,7 +711,7 @@ function Pager({
|
||||
const per = Number(size);
|
||||
if (total <= Number(PAGE_SIZES[0])) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{t("plugins.manager.pager.range", {
|
||||
from: (page - 1) * per + 1,
|
||||
@@ -895,7 +903,7 @@ function InstalledSection({
|
||||
rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-col">
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
@@ -1071,10 +1079,12 @@ function InstalledCard({
|
||||
facts={
|
||||
<>
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{hasSourceBadge(plugin.source) && (
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
)}
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
<span>v{plugin.version}</span>
|
||||
@@ -1083,43 +1093,38 @@ function InstalledCard({
|
||||
}
|
||||
/>
|
||||
|
||||
{failed ? (
|
||||
<div className="flex items-center gap-2 border-t border-border bg-destructive/5 px-3 py-2">
|
||||
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
|
||||
<div className="flex items-center gap-1.5 px-3 pb-2.5">
|
||||
{failed ? (
|
||||
<span
|
||||
className="flex-1 truncate text-[11px] text-muted-foreground"
|
||||
className="flex min-w-0 items-center gap-1.5 text-[11px] text-destructive"
|
||||
title={plugin.lastError ?? undefined}
|
||||
>
|
||||
{plugin.status === "blocked"
|
||||
? t("plugins.manager.status.blocked")
|
||||
: t("plugins.manager.status.failed")}
|
||||
<TriangleAlert className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{plugin.status === "blocked"
|
||||
? t("plugins.manager.status.blocked")
|
||||
: t("plugins.manager.status.failed")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : !running ? (
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: plugin.status === "safeMode"
|
||||
? t("plugins.manager.status.safeMode")
|
||||
: t("plugins.manager.status.disabled")}
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<StatusDot tone={running ? "ok" : "idle"} className="size-1.5" />
|
||||
<span className="truncate">
|
||||
{!running
|
||||
? plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: plugin.status === "safeMode"
|
||||
? t("plugins.manager.status.safeMode")
|
||||
: t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", {
|
||||
version: plugin.pinnedVersion,
|
||||
})
|
||||
: t("plugins.manager.status.running")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-green-500" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", {
|
||||
version: plugin.pinnedVersion,
|
||||
})
|
||||
: t("plugins.manager.status.running")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1159,11 +1164,7 @@ function InstalledCard({
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
)}
|
||||
<DocsLink
|
||||
href={plugin.docs}
|
||||
variant="icon"
|
||||
className="ml-auto size-6"
|
||||
/>
|
||||
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
||||
{!plugin.defaultLook && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1171,10 +1172,7 @@ function InstalledCard({
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-destructive",
|
||||
!plugin.docs && "ml-auto",
|
||||
)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
@@ -1442,7 +1440,7 @@ function BrowseSection({
|
||||
}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-col">
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
@@ -1508,7 +1506,7 @@ function BrowseSection({
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.browseHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
@@ -1540,10 +1538,12 @@ function BrowseCard({
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{hasSourceBadge(plugin.source) && (
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
)}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||
{plugin.category && <span>{plugin.category}</span>}
|
||||
@@ -1551,7 +1551,7 @@ function BrowseCard({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2 px-3 pb-2.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
|
||||
{plugin.unsupported && !plugin.installed
|
||||
? ""
|
||||
@@ -1646,9 +1646,9 @@ function UpdatesSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<TileStack>
|
||||
{plain.length > 0 && (
|
||||
<Card className="flex-row items-center gap-3 px-3 py-2.5">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{t("plugins.manager.updatesReady", { count: plain.length })}
|
||||
{gated.length > 0 &&
|
||||
@@ -1664,20 +1664,17 @@ function UpdatesSection({
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateAll")}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...gated, ...plain, ...pinned].map((p) => (
|
||||
<UpdateRow
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{[...gated, ...plain, ...pinned].map((p) => (
|
||||
<UpdateRow
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</TileStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { capabilityRisk, formatCount, isSevere } from "./plugin-model";
|
||||
import {
|
||||
capabilityRisk,
|
||||
formatCount,
|
||||
hasSourceBadge,
|
||||
isSevere,
|
||||
} from "./plugin-model";
|
||||
|
||||
export function PluginIconBox({
|
||||
name,
|
||||
@@ -80,7 +85,7 @@ export function UnverifiedBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className="shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning"
|
||||
className="inline-block shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 leading-none text-[10px] font-bold uppercase tracking-wider text-warning"
|
||||
title={t("plugins.manager.unverifiedHint")}
|
||||
>
|
||||
{t("plugins.manager.unverified")}
|
||||
@@ -97,14 +102,14 @@ export function SourceBadge({
|
||||
registryName?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (source !== "community" && source !== "custom") return null;
|
||||
if (!hasSourceBadge(source)) return null;
|
||||
const label =
|
||||
source === "community"
|
||||
? t("plugins.manager.registries.communityBadge")
|
||||
: (registryName ?? t("plugins.manager.registries.customBadge"));
|
||||
return (
|
||||
<span
|
||||
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
className="inline-block max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 leading-none align-middle text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
title={t(`plugins.manager.registries.${source}BadgeHint`, {
|
||||
name: label,
|
||||
})}
|
||||
@@ -118,7 +123,7 @@ export function BetaBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className="shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand"
|
||||
className="inline-block shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 leading-none text-[10px] font-bold uppercase tracking-wider text-accent-brand"
|
||||
title={t("plugins.manager.beta.badgeHint")}
|
||||
>
|
||||
{t("plugins.manager.beta.badge")}
|
||||
|
||||
@@ -591,3 +591,8 @@ export function youtubeEmbedUrl(videoId: string | undefined): string | null {
|
||||
? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** True when SourceBadge draws something, so lists can skip the empty slot. */
|
||||
export function hasSourceBadge(source: string): boolean {
|
||||
return source === "community" || source === "custom";
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function HostPluginSections({
|
||||
if (plugins.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
{plugins.map((plugin) => (
|
||||
<HostPluginSection
|
||||
key={plugin.id}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
|
||||
/**
|
||||
* Draws a registered tab type inside another view, such as a host page, with
|
||||
* the same props the tab stage gives it. The embed has its own instance id, so
|
||||
* it never collides with the same tool opened as a normal tab.
|
||||
*/
|
||||
export function HostTabEmbed({
|
||||
host,
|
||||
type,
|
||||
label,
|
||||
data,
|
||||
isVisible = true,
|
||||
shell,
|
||||
}: {
|
||||
host: Host;
|
||||
type: string;
|
||||
label?: string;
|
||||
data?: Record<string, unknown>;
|
||||
isVisible?: boolean;
|
||||
shell: TabShellCallbacks;
|
||||
}) {
|
||||
const tab = useMemo<Tab>(
|
||||
() => ({
|
||||
id: `embed-${type}-${host.id}`,
|
||||
instanceId: `embed-${type}-${host.id}`,
|
||||
type,
|
||||
label: label ?? host.name ?? host.ip,
|
||||
host,
|
||||
openedAt: Date.now(),
|
||||
data,
|
||||
}),
|
||||
// A new host or tool is a new tab; label and data changes keep it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[type, host.id],
|
||||
);
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
{renderTabContent(tab, { shell, isVisible, isFocusedPane: isVisible })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||
|
||||
export interface HostSectionRenderProps {
|
||||
host: Host;
|
||||
isVisible: boolean;
|
||||
shell: TabShellCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card a plugin adds to a host page. The page itself belongs to a plugin;
|
||||
* core only keeps the list so any plugin can fill it.
|
||||
*/
|
||||
export interface HostSectionDef {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
order?: number;
|
||||
when: (host: Host) => boolean;
|
||||
component: ComponentType<HostSectionRenderProps>;
|
||||
}
|
||||
|
||||
const sections = createRegistry<HostSectionDef>(byOrderThenId);
|
||||
|
||||
export const registerHostSection = sections.register;
|
||||
export const useHostSections = sections.useList;
|
||||
export const listHostSections = sections.list;
|
||||
export const resetHostSections = sections.reset;
|
||||
|
||||
export function hostSectionsFor(
|
||||
all: HostSectionDef[],
|
||||
host: Host,
|
||||
): HostSectionDef[] {
|
||||
return all.filter((section) => {
|
||||
try {
|
||||
return section.when(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -73,9 +73,7 @@ function withTabSuspense(node: React.ReactNode) {
|
||||
function PanelTabFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center overflow-y-auto bg-background">
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full max-w-5xl">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ export function AuthEnrollmentSections({
|
||||
);
|
||||
if (sections.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{sections.map((entry) => {
|
||||
const Enrollment = entry.enrollment!;
|
||||
return (
|
||||
<div
|
||||
key={`${entry.pluginId}:${entry.id}`}
|
||||
className="flex flex-col gap-2"
|
||||
className="flex flex-col gap-2 py-3 first:pt-0"
|
||||
>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t(entry.titleKey)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickXTicks } from "../../../components/charts/x-ticks";
|
||||
|
||||
const live = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
new Date(Date.UTC(2026, 10, 4, 15, 37, i * 2)).toISOString(),
|
||||
);
|
||||
|
||||
describe("pickXTicks", () => {
|
||||
it("returns nothing without two points or width", () => {
|
||||
expect(pickXTicks(live(1), 0, 300)).toEqual([]);
|
||||
expect(pickXTicks(live(5), 8000, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses up to five labels on a wide chart", () => {
|
||||
const ts = live(60);
|
||||
expect(pickXTicks(ts, 118_000, 900)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("fits fewer labels on a narrow chart and keeps the newest", () => {
|
||||
const ts = live(60);
|
||||
const ticks = pickXTicks(ts, 118_000, 220);
|
||||
expect(ticks.length).toBeLessThan(5);
|
||||
expect(ticks[ticks.length - 1]).toBe(59);
|
||||
});
|
||||
|
||||
it("never lets labels touch", () => {
|
||||
for (const w of [120, 180, 220, 260, 300, 400, 600]) {
|
||||
for (const n of [2, 3, 4, 5, 8, 60]) {
|
||||
const ts = live(n);
|
||||
const range = (n - 1) * 2000;
|
||||
const ticks = pickXTicks(ts, range, w);
|
||||
const labelW = 11 * 6.2;
|
||||
const boxes = ticks.map((i) => {
|
||||
const x = (i / (n - 1)) * w;
|
||||
if (i === 0) return [x, x + labelW];
|
||||
if (i === n - 1) return [x - labelW, x];
|
||||
return [x - labelW / 2, x + labelW / 2];
|
||||
});
|
||||
for (let k = 1; k < boxes.length; k++) {
|
||||
expect(boxes[k][0]).toBeGreaterThanOrEqual(boxes[k - 1][1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,9 @@ describe("DataView", () => {
|
||||
empty={null}
|
||||
/>,
|
||||
);
|
||||
const grid = container.firstElementChild as HTMLElement;
|
||||
const grid = container.querySelector(".grid") as HTMLElement;
|
||||
expect(grid.className).not.toContain("md:grid-cols-2");
|
||||
expect(grid.className).not.toContain("gap-");
|
||||
expect(grid.style.gridTemplateColumns).toContain("/ 3");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { Pager, pageOf, usePaged } from "../../components/pager";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("pageOf", () => {
|
||||
it("clamps the page to what exists", () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
expect(pageOf(items, 9, 2)).toEqual({ rows: [5], page: 2, pages: 3 });
|
||||
expect(pageOf([], 0, 2)).toEqual({ rows: [], page: 0, pages: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePaged", () => {
|
||||
it("goes back to the first page when reset changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ reset }) => usePaged([1, 2, 3], 1, reset),
|
||||
{ initialProps: { reset: "a" } },
|
||||
);
|
||||
act(() => result.current.setPage(2));
|
||||
expect(result.current.rows).toEqual([3]);
|
||||
rerender({ reset: "b" });
|
||||
expect(result.current.page).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pager", () => {
|
||||
it("hides with one page", () => {
|
||||
const { container } = render(
|
||||
<Pager page={0} pages={1} onPage={() => {}} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("moves between pages", () => {
|
||||
const onPage = vi.fn();
|
||||
render(<Pager page={0} pages={3} onPage={onPage} />);
|
||||
expect(screen.getByText("panel.pager.previous")).toBeDisabled();
|
||||
fireEvent.click(screen.getByText("panel.pager.next"));
|
||||
expect(onPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { StatusBadge, StatusDot, statusTone } from "../../components/status";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("statusTone", () => {
|
||||
it("maps common state words", () => {
|
||||
expect(statusTone("Running")).toBe("ok");
|
||||
expect(statusTone("CrashLoopBackOff")).toBe("bad");
|
||||
expect(statusTone("pending approval")).toBe("warn");
|
||||
expect(statusTone("exited")).toBe("idle");
|
||||
});
|
||||
|
||||
it("treats unknown and empty states as idle", () => {
|
||||
expect(statusTone("banana")).toBe("idle");
|
||||
expect(statusTone(null)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it("shows the state with its tone", () => {
|
||||
render(<StatusBadge state="failed" />);
|
||||
expect(screen.getByText("failed")).toHaveClass("text-destructive");
|
||||
});
|
||||
|
||||
it("lets a tone win over the state", () => {
|
||||
render(
|
||||
<StatusBadge state="failed" tone="ok">
|
||||
Fine
|
||||
</StatusBadge>,
|
||||
);
|
||||
expect(screen.getByText("Fine")).toHaveClass("text-green-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusDot", () => {
|
||||
it("colors by state", () => {
|
||||
render(<StatusDot state="running" title="up" />);
|
||||
expect(screen.getByTitle("up")).toHaveClass("bg-green-500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TileGrid } from "../../../components/tile-grid/TileGrid";
|
||||
import { MetricCard } from "../../../components/metric-card";
|
||||
import type { TileSlot } from "../../../components/tile-grid/layout";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
// 1212px wide grid: one column is (1212 + 12) / 12 = 102px.
|
||||
let gridWidth = 1212;
|
||||
const PITCH = 102;
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
|
||||
beforeAll(() => {
|
||||
document.documentElement.style.fontSize = "14px";
|
||||
vi.stubGlobal(
|
||||
"PointerEvent",
|
||||
class extends MouseEvent {
|
||||
pointerId: number;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
globalThis.ResizeObserver ??= class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
|
||||
if (this.dataset.tgCard) return { height: 200, width: 0 } as DOMRect;
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: gridWidth,
|
||||
height: 0,
|
||||
right: gridWidth,
|
||||
bottom: 0,
|
||||
} as DOMRect;
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = original;
|
||||
document.documentElement.style.removeProperty("font-size");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
beforeEach(() => {
|
||||
gridWidth = 1212;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const three: TileSlot[] = [
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: null },
|
||||
{ id: "disk", x: 8, y: 0, w: 4, h: null },
|
||||
];
|
||||
|
||||
function mount(layout = three, editMode = true) {
|
||||
const changed = vi.fn();
|
||||
function Harness() {
|
||||
const [value, setValue] = useState(layout);
|
||||
return (
|
||||
<TileGrid
|
||||
slots={value}
|
||||
gap={12}
|
||||
minHeight={80}
|
||||
editMode={editMode}
|
||||
tileLabel={(id) => id}
|
||||
onChange={(next) => {
|
||||
changed(next);
|
||||
setValue(next);
|
||||
}}
|
||||
renderTile={(id) => <div data-testid={`body-${id}`}>{id}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return { ...render(<Harness />), changed };
|
||||
}
|
||||
|
||||
const tile = (id: string) =>
|
||||
document.querySelector<HTMLElement>(`[data-tg-tile="${id}"]`)!;
|
||||
const position = (element: HTMLElement) => {
|
||||
const match = /translate\((-?[\d.]+)px, (-?[\d.]+)px\)/.exec(
|
||||
element.style.transform,
|
||||
)!;
|
||||
return { x: Number(match[1]), y: Number(match[2]) };
|
||||
};
|
||||
const pointer = (
|
||||
target: Element | Window,
|
||||
type: "pointerDown" | "pointerMove" | "pointerUp",
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
fireEvent[type](target, { clientX: x, clientY: y, button: 0, pointerId: 1 });
|
||||
const slot = (slots: TileSlot[], id: string) => slots.find((s) => s.id === id)!;
|
||||
|
||||
describe("TileGrid", () => {
|
||||
it("places tiles on the 12 column grid", () => {
|
||||
mount();
|
||||
expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 });
|
||||
expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 });
|
||||
expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 });
|
||||
expect(tile("cpu").style.width).toBe(`${4 * PITCH - 12}px`);
|
||||
});
|
||||
|
||||
it("drops a dragged card exactly where the preview showed it", () => {
|
||||
const { changed } = mount();
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 10 + 4 * PITCH, 300);
|
||||
const ghost = document.querySelector<HTMLElement>(
|
||||
"[data-tg-drop-preview]",
|
||||
)!;
|
||||
const previewed = position(ghost);
|
||||
expect(previewed).toEqual({ x: 4 * PITCH, y: 212 });
|
||||
expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 });
|
||||
expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 });
|
||||
pointer(window, "pointerUp", 10 + 4 * PITCH, 300);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "cpu")).toMatchObject({ x: 4, y: 212 });
|
||||
expect(position(tile("cpu"))).toEqual(previewed);
|
||||
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 });
|
||||
expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0 });
|
||||
});
|
||||
|
||||
it("moves a card into another column without touching the rest", () => {
|
||||
const layout: TileSlot[] = [
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: null },
|
||||
];
|
||||
const { changed } = mount(layout);
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 10 + 8 * PITCH, 10);
|
||||
pointer(window, "pointerUp", 10 + 8 * PITCH, 10);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "cpu")).toMatchObject({ x: 8, y: 0 });
|
||||
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 });
|
||||
});
|
||||
|
||||
it("does nothing on a click without movement", () => {
|
||||
const { changed } = mount();
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 12, 11);
|
||||
pointer(window, "pointerUp", 12, 11);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only pushes the overlapping card when a card gets wider", () => {
|
||||
const { changed } = mount();
|
||||
const edge = tile("cpu").querySelector(".tg-edge-right")!;
|
||||
pointer(edge, "pointerDown", 400, 100);
|
||||
pointer(window, "pointerMove", 400 + 2 * PITCH, 100);
|
||||
expect(tile("cpu").dataset.active).toBe("resize");
|
||||
pointer(window, "pointerUp", 400 + 2 * PITCH, 100);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "cpu")).toMatchObject({ x: 0, y: 0, w: 6 });
|
||||
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 212 });
|
||||
expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0, w: 4 });
|
||||
});
|
||||
|
||||
it("resizes from the left edge keeping the right side in place", () => {
|
||||
const { changed } = mount();
|
||||
const edge = tile("disk").querySelector(".tg-edge-left")!;
|
||||
pointer(edge, "pointerDown", 8 * PITCH, 100);
|
||||
pointer(window, "pointerMove", 8 * PITCH + 2 * PITCH, 100);
|
||||
pointer(window, "pointerUp", 8 * PITCH + 2 * PITCH, 100);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "disk")).toMatchObject({ x: 10, w: 2 });
|
||||
});
|
||||
|
||||
it("pins the height and snaps to a neighbour's bottom", () => {
|
||||
const layout: TileSlot[] = [
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: 300 },
|
||||
];
|
||||
const { changed } = mount(layout);
|
||||
const edge = tile("memory").querySelector(".tg-edge-bottom")!;
|
||||
pointer(edge, "pointerDown", 500, 300);
|
||||
pointer(window, "pointerMove", 500, 196);
|
||||
pointer(window, "pointerUp", 500, 196);
|
||||
const next = changed.mock.calls[0][0] as TileSlot[];
|
||||
expect(slot(next, "memory").h).toBe(200);
|
||||
});
|
||||
|
||||
it("cancels on Escape without saving", () => {
|
||||
const { changed } = mount();
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
pointer(window, "pointerMove", 10, 300);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
pointer(window, "pointerUp", 10, 300);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("[data-tg-drop-preview]")).toBeNull();
|
||||
expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("resizes and moves with the keyboard", () => {
|
||||
const { changed } = mount();
|
||||
const width = tile("disk").querySelector('[aria-orientation="vertical"]')!;
|
||||
fireEvent.keyDown(width, { key: "ArrowLeft" });
|
||||
expect(slot(changed.mock.calls[0][0], "disk").w).toBe(3);
|
||||
const grip = tile("cpu").querySelector("[data-tg-grip]")!;
|
||||
fireEvent.keyDown(grip, { key: "ArrowRight" });
|
||||
expect(slot(changed.mock.calls[1][0], "cpu").x).toBe(1);
|
||||
expect(slot(changed.mock.calls[1][0], "memory")).toMatchObject({ y: 212 });
|
||||
});
|
||||
|
||||
it("removes tiles", () => {
|
||||
const { changed } = mount();
|
||||
fireEvent.click(screen.getAllByLabelText("tileGrid.remove")[0]);
|
||||
expect(changed.mock.calls[0][0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("draws each line between edge to edge tiles once", () => {
|
||||
render(
|
||||
<TileGrid
|
||||
slots={[
|
||||
{ id: "cpu", x: 0, y: 0, w: 4, h: 100 },
|
||||
{ id: "memory", x: 4, y: 0, w: 4, h: 300 },
|
||||
{ id: "disk", x: 0, y: 100, w: 4, h: 50 },
|
||||
]}
|
||||
editMode={false}
|
||||
tileLabel={(id) => id}
|
||||
onChange={() => {}}
|
||||
renderTile={(id) => <div>{id}</div>}
|
||||
/>,
|
||||
);
|
||||
// Whole px tiles that touch, with no lines of their own.
|
||||
expect(position(tile("memory")).x).toBe(404);
|
||||
expect(parseFloat(tile("cpu").style.width)).toBe(404);
|
||||
expect(parseFloat(tile("cpu").style.height)).toBe(100);
|
||||
expect(tile("cpu").querySelector(".tg-card")!.className).not.toMatch(
|
||||
/(^| )border/,
|
||||
);
|
||||
const vertical = [
|
||||
...document.querySelectorAll<HTMLElement>("[data-tg-line].border-l"),
|
||||
].map((el) => [
|
||||
parseFloat(el.style.left),
|
||||
parseFloat(el.style.top),
|
||||
parseFloat(el.style.height),
|
||||
]);
|
||||
// One line down the middle, as tall as the taller side, and one on the
|
||||
// open right edge of the middle tile. Drawn as borders so they are as
|
||||
// thin as other lines at any display scaling.
|
||||
expect(vertical).toEqual([
|
||||
[404, 0, 301],
|
||||
[808, 0, 301],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives metric cards the tile look only in an edge to edge grid", () => {
|
||||
const card = (gap?: number) =>
|
||||
render(
|
||||
<TileGrid
|
||||
slots={[{ id: "cpu", x: 0, y: 0, w: 4, h: null }]}
|
||||
gap={gap}
|
||||
editMode={false}
|
||||
tileLabel={(id) => id}
|
||||
onChange={() => {}}
|
||||
renderTile={() => (
|
||||
<MetricCard title="CPU" icon={null}>
|
||||
x
|
||||
</MetricCard>
|
||||
)}
|
||||
/>,
|
||||
).container.querySelector(".tg-content > div")!.className;
|
||||
expect(card()).not.toContain("bg-card");
|
||||
cleanup();
|
||||
expect(card(12)).toContain("border border-border");
|
||||
});
|
||||
|
||||
it("stacks cards with no edit controls on narrow screens", () => {
|
||||
gridWidth = 500;
|
||||
mount();
|
||||
expect(document.querySelector("[data-tg-tile]")).toBeNull();
|
||||
expect(screen.getByTestId("body-disk")).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole("separator")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows no edit controls outside edit mode", () => {
|
||||
mount(three, false);
|
||||
expect(screen.queryAllByRole("separator")).toHaveLength(0);
|
||||
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
|
||||
expect(document.querySelector("[data-active]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TileWall } from "../../../components/tile-grid/TileWall";
|
||||
import { TileStack } from "../../../components/tile-grid/TileStack";
|
||||
import { TileColumn } from "../../../components/tile-grid/TileColumn";
|
||||
import { MetricCard } from "../../../components/metric-card";
|
||||
import { SectionCard } from "../../../components/section-card";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const cardOf = (text: string) =>
|
||||
screen.getByText(text).closest("[data-tile-card]") as HTMLElement;
|
||||
|
||||
describe("TileWall", () => {
|
||||
it("wraps each tile in a cell with the shared lines", () => {
|
||||
render(
|
||||
<TileWall size="2">
|
||||
<span>one</span>
|
||||
{null}
|
||||
<span>two</span>
|
||||
</TileWall>,
|
||||
);
|
||||
const cell = screen.getByText("one").parentElement!;
|
||||
expect(cell).toHaveClass("border-b", "border-r");
|
||||
expect(cell.parentElement?.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops the box of cards inside it", () => {
|
||||
render(
|
||||
<TileWall>
|
||||
<MetricCard title="CPU" icon={null}>
|
||||
x
|
||||
</MetricCard>
|
||||
</TileWall>,
|
||||
);
|
||||
expect(cardOf("CPU")).not.toHaveClass("border");
|
||||
});
|
||||
|
||||
it("uses column classes in place of size", () => {
|
||||
render(
|
||||
<TileWall gridClassName="grid-cols-3">
|
||||
<span>a</span>
|
||||
</TileWall>,
|
||||
);
|
||||
const grid = screen.getByText("a").parentElement!.parentElement!;
|
||||
expect(grid).toHaveClass("grid-cols-3");
|
||||
expect(grid.style.gridTemplateColumns).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bottom line", () => {
|
||||
const edgeOf = (text: string) =>
|
||||
screen.getByText(text).closest(".overflow-clip")!.firstElementChild;
|
||||
|
||||
it("stays when the tiles do not reach the bottom of the screen", () => {
|
||||
render(
|
||||
<TileWall>
|
||||
<span>alone</span>
|
||||
</TileWall>,
|
||||
);
|
||||
expect(edgeOf("alone")).not.toHaveClass("-mb-px");
|
||||
});
|
||||
|
||||
it("is left to the section inside a stack", () => {
|
||||
render(
|
||||
<TileStack>
|
||||
<TileWall>
|
||||
<span>nested</span>
|
||||
</TileWall>
|
||||
</TileStack>,
|
||||
);
|
||||
expect(edgeOf("nested")).toHaveClass("-mb-px");
|
||||
});
|
||||
|
||||
it("is cut off once the tiles reach the bottom of the scroll area", () => {
|
||||
const rect = vi
|
||||
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||||
.mockReturnValue({ top: 0, bottom: 500 } as DOMRect);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
render(
|
||||
<div style={{ overflowY: "auto" }}>
|
||||
<TileWall>
|
||||
<span>full</span>
|
||||
</TileWall>
|
||||
</div>,
|
||||
);
|
||||
expect(edgeOf("full")).toHaveClass("-mb-px");
|
||||
rect.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TileStack", () => {
|
||||
it("draws a line under each section", () => {
|
||||
render(
|
||||
<TileStack>
|
||||
<span>first</span>
|
||||
<span>second</span>
|
||||
</TileStack>,
|
||||
);
|
||||
expect(screen.getByText("first").parentElement).toHaveClass("border-b");
|
||||
expect(screen.getByText("second").parentElement).toHaveClass("border-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cards outside a tile layout", () => {
|
||||
it("keep their border but no fill", () => {
|
||||
render(
|
||||
<SectionCard title="Shell" icon={null}>
|
||||
body
|
||||
</SectionCard>,
|
||||
);
|
||||
const card = cardOf("Shell");
|
||||
expect(card).toHaveClass("border");
|
||||
expect(card).not.toHaveClass("bg-card");
|
||||
});
|
||||
|
||||
it("drop the border inside a TileColumn", () => {
|
||||
render(
|
||||
<TileColumn>
|
||||
<div>
|
||||
<SectionCard title="Nested" icon={null}>
|
||||
body
|
||||
</SectionCard>
|
||||
</div>
|
||||
</TileColumn>,
|
||||
);
|
||||
expect(cardOf("Nested")).not.toHaveClass("border");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BOTTOM,
|
||||
appendTile,
|
||||
flowTiles,
|
||||
gridLines,
|
||||
removeTile,
|
||||
sanitizeTiles,
|
||||
settleTiles,
|
||||
tileBottom,
|
||||
tileBoxes,
|
||||
tilesFromBoxes,
|
||||
} from "../../../components/tile-grid/layout";
|
||||
|
||||
describe("tile grid layout", () => {
|
||||
it("settles tiles under the ones above them in the same columns", () => {
|
||||
const boxes = settleTiles([
|
||||
{ id: "a", x: 0, y: 0, w: 6, h: 100 },
|
||||
{ id: "b", x: 6, y: 0, w: 6, h: 50 },
|
||||
{ id: "c", x: 6, y: 500, w: 6, h: 50 },
|
||||
{ id: "d", x: 0, y: 900, w: 12, h: 20 },
|
||||
]);
|
||||
const y = Object.fromEntries(boxes.map((box) => [box.id, box.y]));
|
||||
expect(y).toEqual({ a: 0, b: 0, c: 50, d: 100 });
|
||||
expect(tileBottom(boxes)).toBe(120);
|
||||
});
|
||||
|
||||
it("overlaps by the gap when it is negative, for shared borders", () => {
|
||||
const boxes = settleTiles(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 12, h: 100 },
|
||||
{ id: "b", x: 0, y: 1, w: 12, h: 100 },
|
||||
],
|
||||
-1,
|
||||
);
|
||||
expect(boxes[1].y).toBe(99);
|
||||
});
|
||||
|
||||
it("uses measured heights for tiles without a fixed one", () => {
|
||||
const boxes = tileBoxes(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 4, h: null },
|
||||
{ id: "b", x: 0, y: 0, w: 4, h: 30 },
|
||||
{ id: "c", x: 0, y: 0, w: 4 },
|
||||
],
|
||||
{ a: 77 },
|
||||
10,
|
||||
);
|
||||
expect(boxes.map((box) => box.h)).toEqual([77, 30, 10]);
|
||||
});
|
||||
|
||||
it("appends at the bottom once and removes by id", () => {
|
||||
const start = [{ id: "a", x: 0, y: 0, w: 4, h: null }];
|
||||
const added = appendTile(start, { id: "b", w: 30 });
|
||||
expect(added[1]).toEqual({ id: "b", x: 0, y: BOTTOM, w: 12, h: null });
|
||||
expect(appendTile(added, { id: "b" })).toBe(added);
|
||||
expect(removeTile(added, "a").map((slot) => slot.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("writes settled positions back in reading order", () => {
|
||||
const slots = [
|
||||
{ id: "b", x: 6, y: 300, w: 6, h: 10 },
|
||||
{ id: "a", x: 0, y: 0, w: 6, h: 10 },
|
||||
];
|
||||
const out = tilesFromBoxes(slots, settleTiles(tileBoxes(slots, {})));
|
||||
expect(out.map((slot) => [slot.id, slot.y])).toEqual([
|
||||
["a", 0],
|
||||
["b", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("cleans saved layouts", () => {
|
||||
expect(sanitizeTiles("nope")).toEqual([]);
|
||||
expect(
|
||||
sanitizeTiles([
|
||||
{ id: "a", x: 11, y: -5, w: 6, h: 3 },
|
||||
{ id: "a", x: 0, y: 0, w: 4 },
|
||||
{ x: 0 },
|
||||
null,
|
||||
{ id: "b", x: "x", y: 10, w: 1, h: "big" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "a", x: 6, y: 0, w: 6, h: 48 },
|
||||
{ id: "b", x: 0, y: 10, w: 2, h: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows tiles into rows by width", () => {
|
||||
const out = flowTiles([
|
||||
{ id: "a", w: 8 },
|
||||
{ id: "b", w: 4 },
|
||||
{ id: "c", w: 6 },
|
||||
]);
|
||||
expect(out.map((slot) => [slot.id, slot.x, slot.y])).toEqual([
|
||||
["a", 0, 0],
|
||||
["b", 8, 0],
|
||||
["c", 0, 160],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gridLines", () => {
|
||||
it("draws a shared edge once, as long as the longer side", () => {
|
||||
const lines = gridLines(
|
||||
[
|
||||
{ l: 0, t: 0, r: 100, b: 50 },
|
||||
{ l: 100, t: 0, r: 200, b: 120 },
|
||||
],
|
||||
200,
|
||||
);
|
||||
expect(lines.filter((line) => line.w === 1)).toEqual([
|
||||
{ x: 100, y: 0, w: 1, h: 121 },
|
||||
]);
|
||||
expect(lines.filter((line) => line.h === 1)).toEqual([
|
||||
{ x: 0, y: 50, w: 101, h: 1 },
|
||||
{ x: 100, y: 120, w: 100, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out the outer left, top and right edges and keeps bottoms", () => {
|
||||
expect(gridLines([{ l: 0, t: 0, r: 200, b: 80 }], 200)).toEqual([
|
||||
{ x: 0, y: 80, w: 200, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the very bottom line when the grid runs to the bottom of the screen", () => {
|
||||
const lines = gridLines(
|
||||
[
|
||||
{ l: 0, t: 0, r: 100, b: 50 },
|
||||
{ l: 100, t: 0, r: 200, b: 120 },
|
||||
],
|
||||
200,
|
||||
true,
|
||||
);
|
||||
// The shorter column still gets its bottom line.
|
||||
expect(lines.filter((line) => line.h === 1)).toEqual([
|
||||
{ x: 0, y: 50, w: 101, h: 1 },
|
||||
]);
|
||||
expect(lines.filter((line) => line.w === 1)).toEqual([
|
||||
{ x: 100, y: 0, w: 1, h: 120 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins rows into one line across tiles", () => {
|
||||
const lines = gridLines(
|
||||
[
|
||||
{ l: 0, t: 0, r: 100, b: 50 },
|
||||
{ l: 100, t: 0, r: 200, b: 50 },
|
||||
{ l: 0, t: 50, r: 200, b: 90 },
|
||||
],
|
||||
200,
|
||||
);
|
||||
expect(lines.filter((line) => line.h === 1 && line.y === 50)).toEqual([
|
||||
{ x: 0, y: 50, w: 200, h: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,30 @@ describe("UiPreferencesProvider", () => {
|
||||
expect(result.current?.preferences.overrides).toEqual({});
|
||||
});
|
||||
|
||||
it("sends every key set in one area within a burst", async () => {
|
||||
vi.useFakeTimers();
|
||||
api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
|
||||
api.getUiPreferences.mockResolvedValue({
|
||||
...defaultUiPreferences(),
|
||||
overrides: { "plugin:x": { grid: ["a"], slots: ["b"] } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
|
||||
await vi.waitFor(() => expect(result.current?.loaded).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current?.setPluginOverride("x", "grid", null);
|
||||
result.current?.setPluginOverride("x", "slots", null);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
expect(api.saveUiPreferences).toHaveBeenCalledWith({
|
||||
overrides: { "plugin:x": { grid: null, slots: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes an area once its last override is cleared", async () => {
|
||||
api.getUiPreferences.mockResolvedValue({
|
||||
...defaultUiPreferences(),
|
||||
|
||||
Reference in new issue
Block a user