Compare commits

...
Author SHA1 Message Date
LukeGus 5f332aa5c3 feat: add an auto or fixed columns picker to card grids 2026-10-21 21:35:25 -05:00
18 changed files with 231 additions and 83 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 26.10.5
### Changed
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
## 26.10.4
### Fixed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.4",
"version": "26.10.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.4",
"version": "26.10.5",
"hasInstallScript": true,
"workspaces": [
"packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.4",
"version": "26.10.5",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 1.0.20
Plugin API 1.12.0.
### Added
- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6
- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type
- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns`
## 1.0.19
Plugin API 1.11.0.
+4 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.19 | Plugin API 1.11.0
SDK 1.0.20 | Plugin API 1.12.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -643,6 +643,9 @@ exports with props is `ui.api.txt` in the Termix repo
`PanePrompt` for small prompts.
- 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
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.
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.19",
"version": "1.0.20",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+24 -3
View File
@@ -1711,28 +1711,49 @@ export function usePluginUiPreferences<
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
}
type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
const PANEL_GRID_SIZES: readonly string[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
/**
* Grid or list and row density for one of this plugin's views, kept in its
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
* keys in contributes.uiPresets so each preset level can pick a default.
* Grid or list, row density and cards per row for one of this plugin's
* views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
* and `<prefix>columns`. Declare those keys in contributes.uiPresets so each
* preset level can pick a default. Hand columns to ViewToggle and DataView.
*/
export function usePluginPanelView(prefix = ""): {
view: "grid" | "list";
density: "comfortable" | "compact";
compact: boolean;
columns: PanelGridSize;
setView: (view: "grid" | "list") => void;
setDensity: (density: "comfortable" | "compact") => void;
setColumns: (columns: PanelGridSize) => void;
} {
const { values, set } = usePluginUiPreferences();
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
const density =
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
const saved = values[`${prefix}columns`];
const columns =
typeof saved === "string" && PANEL_GRID_SIZES.includes(saved)
? (saved as PanelGridSize)
: "auto";
return {
view,
density,
compact: density === "compact",
columns,
setView: (next) => set(`${prefix}viewMode`, next),
setDensity: (next) => set(`${prefix}density`, next),
setColumns: (next) => set(`${prefix}columns`, next),
};
}
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.11.0";
export const PLUGIN_API_VERSION = "1.12.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+3 -2
View File
@@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T>
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
DesktopUpdateMode (type): DesktopUpdateMode
@@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
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
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
@@ -321,7 +322,7 @@ UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
actionOrder (value): (action: HostActionDef) => number
+1
View File
@@ -151,6 +151,7 @@ const ALLOWED = [
"GridCardCatalogEntry",
"GridColSpan",
"GridColumns",
"GridSize",
"GridLayout",
"GridSlot",
"GroupHeading",
+21 -2
View File
@@ -2,6 +2,8 @@ import type React from "react";
import { cn } from "@/lib/utils";
import {
PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
@@ -67,6 +69,8 @@ export function DataView<T>({
getKey,
renderCard,
columns,
size,
minCardWidth,
listColumns,
onRowClick,
isRowActive,
@@ -78,7 +82,12 @@ export function DataView<T>({
density?: PanelDensity;
getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode;
/** Breakpoint columns, used when no size is given. */
columns: GridColumns;
/** Columns the user picked with ViewToggle. Wins over columns. */
size?: GridSize;
/** Narrowest card in auto mode, in px. */
minCardWidth?: number;
listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean;
@@ -93,9 +102,19 @@ export function DataView<T>({
if (view === "grid") {
return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}>
<div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
>
{items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div>
<div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
))}
</div>
);
+82 -3
View File
@@ -1,11 +1,26 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import {
ArrowLeft,
Columns3,
Grid3X3,
List,
Rows3,
Search,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
@@ -19,6 +34,37 @@ export const PANEL = {
export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact";
/** Cards per row. "auto" fits as many as the pane has room for. */
export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
export const GRID_SIZES: readonly GridSize[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
// Sized off the pane's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
export function gridSizeStyle(
size: GridSize,
minCardWidth = 320,
gap = "0.5rem",
): React.CSSProperties {
if (size === "auto") {
return {
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
};
}
const n = Number(size);
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`,
};
}
/** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({
chrome = true,
@@ -308,20 +354,25 @@ export function Segmented<T extends string>({
);
}
/** Grid or list, plus the compact rows toggle. */
/** Grid or list, plus the compact rows toggle and the cards per row picker. */
export function ViewToggle({
view,
onView,
density,
onDensity,
columns,
onColumns,
}: {
view: PanelViewMode;
onView: (next: PanelViewMode) => void;
density?: PanelDensity;
onDensity?: (next: PanelDensity) => void;
/** Cards per row in grid view. Pass with onColumns to show the picker. */
columns?: GridSize;
onColumns?: (next: GridSize) => void;
}) {
const { t } = useTranslation();
return (
const toggle = (
<div className="flex items-center border border-border">
<ToggleButton
active={view === "grid"}
@@ -356,6 +407,34 @@ export function ViewToggle({
)}
</div>
);
if (!columns || !onColumns || view !== "grid") return toggle;
return (
<div className="flex items-center gap-2">
<Select value={columns} onValueChange={(v) => onColumns(v as GridSize)}>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("panel.columns.title")}
aria-label={t("panel.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("panel.columns.auto")
: t("panel.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
{toggle}
</div>
);
}
function ToggleButton({
+6 -6
View File
@@ -1325,11 +1325,6 @@
"updated": "Recently updated",
"category": "Category"
},
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"table": {
"name": "Name",
"description": "Description",
@@ -1654,7 +1649,12 @@
"gridView": "Grid",
"listView": "List",
"compactRows": "Compact rows",
"comfortableRows": "Comfortable rows"
"comfortableRows": "Comfortable rows",
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
}
},
"fields": {
"secretSaved": "Saved. Type to replace it."
+1
View File
@@ -289,6 +289,7 @@ export {
PanelShell,
Segmented,
ViewToggle,
type GridSize,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
+19 -55
View File
@@ -5,7 +5,6 @@ import {
Check,
ChevronLeft,
ChevronRight,
Columns3,
Download,
Globe,
Library,
@@ -37,7 +36,9 @@ import {
GroupHeading,
PANEL,
PanelShell,
GRID_SIZES,
ViewToggle,
type GridSize,
type PanelViewMode,
} from "@/components/panel-layout";
import { DataView, type DataColumn } from "@/components/data-view";
@@ -72,9 +73,6 @@ import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates" | "registries";
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number];
interface Layout {
view: PanelViewMode;
columns: GridSize;
@@ -364,54 +362,16 @@ function LayoutControls({
onView: (next: PanelViewMode) => void;
onColumns: (next: GridSize) => void;
}) {
const { t } = useTranslation();
return (
<>
{layout.view === "grid" && (
<Select
value={layout.columns}
onValueChange={(v) => onColumns(v as GridSize)}
>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("plugins.manager.columns.title")}
aria-label={t("plugins.manager.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("plugins.manager.columns.auto")
: t("plugins.manager.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ViewToggle view={layout.view} onView={onView} />
</>
<ViewToggle
view={layout.view}
onView={onView}
columns={layout.columns}
onColumns={onColumns}
/>
);
}
// Sized off the tab's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
function gridStyle(columns: GridSize): React.CSSProperties {
if (columns === "auto") {
return {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))",
};
}
const n = Number(columns);
const gaps = (n - 1) * 12;
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`,
};
}
function PluginCollection({
plugins,
layout,
@@ -440,13 +400,17 @@ function PluginCollection({
);
}
return (
<div className="grid gap-3" style={gridStyle(layout.columns)}>
{plugins.map((p) => (
<div key={p.id} className="flex flex-col *:flex-1">
{renderCard(p)}
</div>
))}
</div>
<DataView
items={plugins}
view="grid"
getKey={(p) => p.id}
renderCard={renderCard}
columns={{}}
size={layout.columns}
minCardWidth={400}
listColumns={listColumns}
empty={null}
/>
);
}
@@ -34,6 +34,24 @@ describe("DataView", () => {
expect(screen.queryByText("Name")).toBeNull();
});
it("uses the picked size over breakpoint columns", () => {
const { container } = render(
<DataView
items={items}
view="grid"
getKey={(item) => item.id}
renderCard={(item) => <div>card {item.name}</div>}
columns={{ base: 1, md: 2 }}
size="3"
listColumns={[]}
empty={null}
/>,
);
const grid = container.firstElementChild as HTMLElement;
expect(grid.className).not.toContain("md:grid-cols-2");
expect(grid.style.gridTemplateColumns).toContain("/ 3");
});
it("renders a header and clickable rows in list mode", () => {
const onRowClick = vi.fn();
renderView("list", onRowClick);
@@ -11,6 +11,7 @@ import {
PanelShell,
Segmented,
ViewToggle,
gridSizeStyle,
} from "@/components/panel-layout";
afterEach(cleanup);
@@ -72,6 +73,34 @@ describe("ViewToggle", () => {
fireEvent.click(screen.getByLabelText("panel.compactRows"));
expect(onDensity).toHaveBeenCalledWith("compact");
});
it("shows the columns picker only in grid view", () => {
const props = { onView: vi.fn(), columns: "auto" as const };
const { rerender } = render(
<ViewToggle view="grid" {...props} onColumns={vi.fn()} />,
);
expect(screen.getByLabelText("panel.columns.title")).toBeTruthy();
rerender(<ViewToggle view="list" {...props} onColumns={vi.fn()} />);
expect(screen.queryByLabelText("panel.columns.title")).toBeNull();
rerender(<ViewToggle view="grid" {...props} />);
expect(screen.queryByLabelText("panel.columns.title")).toBeNull();
});
});
describe("gridSizeStyle", () => {
it("fits auto cards to the pane", () => {
expect(gridSizeStyle("auto", 280).gridTemplateColumns).toBe(
"repeat(auto-fill, minmax(min(100%, 280px), 1fr))",
);
});
it("splits the width for a fixed count", () => {
expect(gridSizeStyle("4").gridTemplateColumns).toContain(
"(100% - 3 * 0.5rem) / 4",
);
});
});
describe("Facts", () => {
+2 -6
View File
@@ -183,17 +183,13 @@ describe("PluginsTab", () => {
it("switches to a table and remembers it", async () => {
await renderTab();
expect(screen.queryByRole("table")).toBeNull();
expect(
screen.getByLabelText(en.plugins.manager.columns.title),
).toBeTruthy();
expect(screen.getByLabelText(en.panel.columns.title)).toBeTruthy();
fireEvent.click(screen.getByLabelText(en.panel.listView));
expect(screen.getByRole("table")).toBeTruthy();
expect(screen.getByText(en.plugins.manager.table.version)).toBeTruthy();
expect(
screen.queryByLabelText(en.plugins.manager.columns.title),
).toBeNull();
expect(screen.queryByLabelText(en.panel.columns.title)).toBeNull();
expect(localStorage.getItem("pluginsTab.view")).toBe("list");
cleanup();