From 19bdd589f3304baaef468a887d21bf1ddf9f04c8 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 18:57:18 -0600 Subject: [PATCH] feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0 --- CHANGELOG.md | 3 +- packages/plugin-sdk/CHANGELOG.md | 10 +- packages/plugin-sdk/PLUGIN-GUIDE.md | 23 ++- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/ui.api.txt | 31 +++- scripts/check-sdk-ui-exports.cjs | 25 +++ src/ui/components/card.tsx | 7 +- src/ui/components/data-table.tsx | 57 +++++++ src/ui/components/data-view.tsx | 140 ++++++++--------- src/ui/components/info-grid.tsx | 41 +++++ src/ui/components/loading-state.tsx | 44 ++++++ src/ui/components/metric-card.tsx | 3 +- src/ui/components/notice-panel.tsx | 52 +++++++ src/ui/components/pager.tsx | 73 +++++++++ src/ui/components/panel-layout.tsx | 66 +++++++- src/ui/components/section-card.tsx | 19 ++- src/ui/components/side-nav.tsx | 127 ++++++++++++++++ src/ui/components/stat-tile.tsx | 87 +++++++++++ src/ui/components/status.tsx | 125 +++++++++++++++ src/ui/components/surface/surface-scope.tsx | 11 ++ src/ui/components/tab-strip.tsx | 2 +- src/ui/components/tile-grid/TileColumn.tsx | 27 ++++ src/ui/components/tile-grid/TileEditBar.tsx | 56 +++++++ src/ui/components/tile-grid/TileGrid.tsx | 9 +- src/ui/components/tile-grid/TileStack.tsx | 37 +++++ src/ui/components/tile-grid/TileWall.tsx | 53 +++++++ src/ui/components/tile-grid/edge.tsx | 86 +++++++++++ src/ui/components/tile-grid/index.ts | 5 + src/ui/lib/widget-title.tsx | 10 +- src/ui/locales/en.json | 10 +- src/ui/plugin-host/sdk-ui.ts | 21 +++ src/ui/plugins/PluginDetail.tsx | 68 ++++----- src/ui/plugins/PluginReleaseNotes.tsx | 2 +- src/ui/plugins/PluginsTab.tsx | 120 +++++++-------- src/ui/settings/HostPluginSections.tsx | 2 +- src/ui/shell/tabUtils.tsx | 4 +- src/ui/tests/components/data-view.test.tsx | 3 +- src/ui/tests/components/pager.test.tsx | 56 +++++++ src/ui/tests/components/status.test.tsx | 43 ++++++ .../components/tile-grid/TileGrid.test.tsx | 2 +- .../components/tile-grid/TileWall.test.tsx | 142 ++++++++++++++++++ 41 files changed, 1497 insertions(+), 207 deletions(-) create mode 100644 src/ui/components/data-table.tsx create mode 100644 src/ui/components/info-grid.tsx create mode 100644 src/ui/components/loading-state.tsx create mode 100644 src/ui/components/notice-panel.tsx create mode 100644 src/ui/components/pager.tsx create mode 100644 src/ui/components/side-nav.tsx create mode 100644 src/ui/components/stat-tile.tsx create mode 100644 src/ui/components/status.tsx create mode 100644 src/ui/components/tile-grid/TileColumn.tsx create mode 100644 src/ui/components/tile-grid/TileEditBar.tsx create mode 100644 src/ui/components/tile-grid/TileStack.tsx create mode 100644 src/ui/components/tile-grid/TileWall.tsx create mode 100644 src/ui/components/tile-grid/edge.tsx create mode 100644 src/ui/tests/components/pager.test.tsx create mode 100644 src/ui/tests/components/status.test.tsx create mode 100644 src/ui/tests/components/tile-grid/TileWall.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e041ac6..c22935d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ ### Changed -- Plugin cards placed on a tile grid take the dashboard look, with shared lines and no gaps +- 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 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index edb69328f..29149b129 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.26 +## 1.1.0 Plugin API 1.15.0. @@ -9,6 +9,14 @@ Plugin API 1.15.0. - `/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 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 621bd94f8..c334d0103 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.26 | Plugin API 1.15.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,15 +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`. +- 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`. Needs Termix 26.11.1. + 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`. Pass `columns` and `onColumns` to `ViewToggle` and the same +- 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. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index d72578dec..31698dcab 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.26", + "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": { diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 60021fcf0..2d483d8e3 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145" DEFAULT_FOLDER_ICON (value): "folder" DashboardCardId (type): DashboardCardId DataColumn (type): DataColumn +DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes & TableHTMLAttributes & { containerClassName?: string; }) => Element +DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes & HTMLAttributes & { active?: boolean; }) => Element DataView (value): ({ 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[]; 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 @@ -166,7 +168,9 @@ HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: H 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; 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; scroll?: boolean; bare?: boolean; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal Input (value): ({ className, type, ...props }: DetailedHTMLProps, HTMLInputElement>) => Element Kbd (value): ForwardRefExoticComponent> KbdKey (value): { ({ className, ...props }: HTMLAttributes): Element; displayName: string; } @@ -180,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes 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, "title" | "children" | "onClick">) => Element ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit, "children"> & { ref?: Ref; }) => 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 @@ -201,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 @@ -209,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; 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 @@ -236,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): ({ 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 @@ -248,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus SSH_AUTH_PROTOCOL (value): "ssh" ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes) => Element ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes) => Element +SeamlessTileContext (value): Context 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): ({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element @@ -277,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps) => Element SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => 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, HTMLDivElement>) => Element Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes) => Element SortKey (type): SortKey @@ -286,6 +299,10 @@ 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 @@ -306,6 +323,8 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d Textarea (value): ForwardRefExoticComponent> 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 @@ -313,6 +332,8 @@ 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 @@ -503,6 +524,7 @@ normalizeLanguageCode (value): (language?: string) => string notifySyncChanged (value): () => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void operationsApi (value): AxiosInstance +pageOf (value): (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 @@ -573,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 @@ -618,6 +641,7 @@ 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 @@ -625,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): (items: T[], size: number, reset?: unknown) => { setPage: Dispatch>; rows: T[]; page: number; pages: number; } usePaletteEntries (value): () => PaletteEntryDef[] usePaletteGroups (value): () => PaletteGroupDef[] usePanels (value): () => PanelDef[] diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 0b78339cd..ffb4de9e6 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,6 +19,31 @@ 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", diff --git a/src/ui/components/card.tsx b/src/ui/components/card.tsx index 2d30a89a9..4a873fe73 100644 --- a/src/ui/components/card.tsx +++ b/src/ui/components/card.tsx @@ -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 (
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} diff --git a/src/ui/components/data-table.tsx b/src/ui/components/data-table.tsx new file mode 100644 index 000000000..8ae33841d --- /dev/null +++ b/src/ui/components/data-table.tsx @@ -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 ( +
+ + {children} +
+
+ ); +} + +/** A body row that highlights on hover, and when active. */ +export function DataTableRow({ + active = false, + className, + onClick, + ...props +}: React.ComponentProps<"tr"> & { active?: boolean }) { + return ( + + ); +} diff --git a/src/ui/components/data-view.tsx b/src/ui/components/data-view.tsx index 4d317a196..21e19abb3 100644 --- a/src/ui/components/data-view.tsx +++ b/src/ui/components/data-view.tsx @@ -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({ items, view, @@ -132,21 +135,18 @@ export function DataView({ if (view === "grid") { return ( -
{items.map((item) => (
{renderCard(item)}
))} -
+ ); } @@ -154,68 +154,70 @@ export function DataView({ const rowHeight = density === "compact" ? "min-h-7" : "min-h-9"; return ( -
+
- {listColumns.map((column) => ( - - {column.header} - - ))} -
- -
- {items.map((item) => { - const active = isRowActive?.(item) ?? false; - return ( -
onRowClick(item) : undefined} +
+ {listColumns.map((column) => ( + - {listColumns.map((column) => ( -
- {column.cell(item)} -
- ))} -
- ); - })} + {column.header} + + ))} +
+ +
+ {items.map((item) => { + const active = isRowActive?.(item) ?? false; + return ( +
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) => ( +
+ {column.cell(item)} +
+ ))} +
+ ); + })} +
-
+ ); } diff --git a/src/ui/components/info-grid.tsx b/src/ui/components/info-grid.tsx new file mode 100644 index 000000000..52fbffcd0 --- /dev/null +++ b/src/ui/components/info-grid.tsx @@ -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 ( +
+ {shown.map((item, index) => ( +
+
{item.label}
+
+ {item.value} +
+
+ ))} +
+ ); +} diff --git a/src/ui/components/loading-state.tsx b/src/ui/components/loading-state.tsx new file mode 100644 index 000000000..25914e449 --- /dev/null +++ b/src/ui/components/loading-state.tsx @@ -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 ( +
+ + {text} +
+ ); + } + return ( +
+ + {text} +
+ ); +} diff --git a/src/ui/components/metric-card.tsx b/src/ui/components/metric-card.tsx index 72431c7f1..47d9916bc 100644 --- a/src/ui/components/metric-card.tsx +++ b/src/ui/components/metric-card.tsx @@ -35,9 +35,10 @@ export function MetricCard({ const tiled = useInSeamlessTile(); return (
+ {Icon && ( + + )} +

{title}

+ {children && ( +
+ {children} +
+ )} + {action &&
{action}
} +
+ ); +} diff --git a/src/ui/components/pager.tsx b/src/ui/components/pager.tsx new file mode 100644 index 000000000..810b1a77f --- /dev/null +++ b/src/ui/components/pager.tsx @@ -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( + 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(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 ( +
+ + + {pages !== undefined + ? t("panel.pager.pageOf", { page: page + 1, pages }) + : t("panel.pager.page", { page: page + 1 })} + + +
+ ); +} diff --git a/src/ui/components/panel-layout.tsx b/src/ui/components/panel-layout.tsx index de28a1905..9a09ea04a 100644 --- a/src/ui/components/panel-layout.tsx +++ b/src/ui/components/panel-layout.tsx @@ -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, @@ -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 ( + + ); +} + +/** + * 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 ( +
+
+ {children} +
+
+ ); +} + export function PanelSearch({ value, onChange, @@ -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 (
@@ -530,7 +590,11 @@ export function GroupHeading({ {count} )} -
+ {noRule ? ( +
+ ) : ( +
+ )} {action}
); diff --git a/src/ui/components/section-card.tsx b/src/ui/components/section-card.tsx index 8fa7877f8..ae442b309 100644 --- a/src/ui/components/section-card.tsx +++ b/src/ui/components/section-card.tsx @@ -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 (
-
- {icon} +
+ + {icon} + {title} {action &&
{action}
}
-
{children}
+
{children}
); } diff --git a/src/ui/components/side-nav.tsx b/src/ui/components/side-nav.tsx new file mode 100644 index 000000000..d45057325 --- /dev/null +++ b/src/ui/components/side-nav.tsx @@ -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 ( + <> + +
+ onSelect(event.target.value)} + className="h-8 w-full px-2.5 text-xs" + > + {entries.map((entry) => ( + + ))} + +
+ + ); +} + +export function SideNavItem({ + entry, + active, + onClick, +}: { + entry: SideNavEntry; + active: boolean; + onClick: () => void; +}) { + const Icon = entry.icon; + return ( +
+ + {entry.onPopOut && ( + + )} +
+ ); +} diff --git a/src/ui/components/stat-tile.tsx b/src/ui/components/stat-tile.tsx new file mode 100644 index 000000000..a463d1c3b --- /dev/null +++ b/src/ui/components/stat-tile.tsx @@ -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 = ( +
+
+ + {value} + + {unit && {unit}} +
+ {percent !== undefined && } + {hint && ( + + {hint} + + )} + {children} +
+ ); + return ( + + {onClick ? ( + + ) : ( + body + )} + + ); +} diff --git a/src/ui/components/status.tsx b/src/ui/components/status.tsx new file mode 100644 index 000000000..41257cd6f --- /dev/null +++ b/src/ui/components/status.tsx @@ -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 = { + 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 = { + 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 ( + + ); +} + +/** 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 ( + + {children ?? state} + + ); +} diff --git a/src/ui/components/surface/surface-scope.tsx b/src/ui/components/surface/surface-scope.tsx index 8aced0893..2398594aa 100644 --- a/src/ui/components/surface/surface-scope.tsx +++ b/src/ui/components/surface/surface-scope.tsx @@ -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 ? ( + + {children} + ) : (
diff --git a/src/ui/components/tile-grid/TileColumn.tsx b/src/ui/components/tile-grid/TileColumn.tsx new file mode 100644 index 000000000..6f413f664 --- /dev/null +++ b/src/ui/components/tile-grid/TileColumn.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/ui/components/tile-grid/TileEditBar.tsx b/src/ui/components/tile-grid/TileEditBar.tsx new file mode 100644 index 000000000..596efca1e --- /dev/null +++ b/src/ui/components/tile-grid/TileEditBar.tsx @@ -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 ( +
+ {children && ( +
+ {children} +
+ )} +
+ {onReset && ( + + )} + {onAdd && ( + + )} + +
+
+ ); +} diff --git a/src/ui/components/tile-grid/TileGrid.tsx b/src/ui/components/tile-grid/TileGrid.tsx index c459ed5d7..0875dc98e 100644 --- a/src/ui/components/tile-grid/TileGrid.tsx +++ b/src/ui/components/tile-grid/TileGrid.tsx @@ -28,6 +28,7 @@ import { } from "./layout"; import { SeamlessTileContext } from "./context"; import { gridLines } from "./layout"; +import { scrollParent } from "./edge"; type Edge = "left" | "right" | "bottom" | "corner"; interface Proposal { @@ -83,14 +84,6 @@ function propose(base: TileSlot[], id: string, proposal: Proposal) { return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot)); } -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; -} - const NO_DRAG = "button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle"; diff --git a/src/ui/components/tile-grid/TileStack.tsx b/src/ui/components/tile-grid/TileStack.tsx new file mode 100644 index 000000000..2a2c45e7c --- /dev/null +++ b/src/ui/components/tile-grid/TileStack.tsx @@ -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 ( + + + + {Children.toArray(children).map((section, index) => ( +
+ {section} +
+ ))} +
+
+
+ ); +} + +function keyOf(node: ReactNode, index: number): string | number { + return isValidElement(node) && node.key !== null ? node.key : index; +} diff --git a/src/ui/components/tile-grid/TileWall.tsx b/src/ui/components/tile-grid/TileWall.tsx new file mode 100644 index 000000000..eb6fc0c81 --- /dev/null +++ b/src/ui/components/tile-grid/TileWall.tsx @@ -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 ( + + {/* The grid sits a pixel wider so the right edge line is cut off. */} + +
+ {tiles.map((tile, index) => ( +
+ {tile} +
+ ))} +
+
+
+ ); +} + +function keyOf(node: ReactNode, index: number): string | number { + return isValidElement(node) && node.key !== null ? node.key : index; +} diff --git a/src/ui/components/tile-grid/edge.tsx b/src/ui/components/tile-grid/edge.tsx new file mode 100644 index 000000000..9c35c3a43 --- /dev/null +++ b/src/ui/components/tile-grid/edge.tsx @@ -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 ( + + {children} + + ); +} + +/** + * 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) { + 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(null); + const reaches = useReachesBottom(ref); + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/ui/components/tile-grid/index.ts b/src/ui/components/tile-grid/index.ts index 4f1db2c21..165c920fa 100644 --- a/src/ui/components/tile-grid/index.ts +++ b/src/ui/components/tile-grid/index.ts @@ -1,4 +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"; diff --git a/src/ui/lib/widget-title.tsx b/src/ui/lib/widget-title.tsx index 514c8349e..067f2c452 100644 --- a/src/ui/lib/widget-title.tsx +++ b/src/ui/lib/widget-title.tsx @@ -10,9 +10,13 @@ export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) { const label = title || fallback; if (!label) return null; return ( -
- {icon && {icon}} - +
+ {icon && ( + + {icon} + + )} + {label}
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 1d22c8e27..ae3a744cc 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1676,6 +1676,12 @@ "title": "Columns", "auto": "Auto", "count": "{{count}} wide" + }, + "pager": { + "previous": "Previous", + "next": "Next", + "page": "Page {{page}}", + "pageOf": "Page {{page}} of {{pages}}" } }, "fields": { @@ -1715,6 +1721,8 @@ "action": "Actions", "resource": "Lists", "session": "Sessions" - } + }, + "reset": "Reset", + "done": "Done" } } diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 843707321..6cee5ccd3 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -128,9 +128,15 @@ export { type GridSlot, } from "@/components/card-grid/types"; export { + SeamlessTileContext, + TileEditBar, TileFrame, TileGrid, TilePicker, + TileColumn, + TileStack, + TileWall, + useInSeamlessTile, appendTile, flowTiles, removeTile, @@ -217,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"; @@ -299,8 +318,10 @@ export { Facts, GroupHeading, PANEL, + PanelFrame, PanelSearch, PanelShell, + RefreshButton, Segmented, ViewToggle, type GridSize, diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 0bf16932f..2bb177626 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -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, @@ -52,7 +53,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, @@ -93,10 +94,8 @@ export function PluginDetail({ const tryableBeta = betaToTry(plugin); return ( -
- + +
)}
-
+
{plugin.installed && failed && plugin.lastError && ( -
+
@@ -242,7 +241,7 @@ export function PluginDetail({ )} {plugin.status === "server" && ( -
+
{t("plugins.manager.runsOnServerHint")} @@ -250,13 +249,13 @@ export function PluginDetail({
)} -
-
+
+ {plugin.features.length > 0 && ( -
    +
      {plugin.features.map((feature) => (
    • ))} {plugin.capabilities.length === 0 && ( -
      +
      {t("plugins.manager.consent.none")}
      )} @@ -292,10 +291,7 @@ export function PluginDetail({
      {plugin.env.map((v) => ( -
      +
      {v.name} @@ -331,12 +327,12 @@ export function PluginDetail({ onDelete={() => manager.removeData(plugin)} /> )} -
      + -
      + {contributions.length > 0 && ( -
      +
      {contributions.map((c) => ( 0 && ( -
      +
      {plugin.installed ? t("plugins.manager.versionPickHintInstalled") @@ -414,7 +410,7 @@ export function PluginDetail({ {plugin.installed && (
      -
      +
      {t("plugins.manager.autoUpdate")} @@ -431,7 +427,7 @@ export function PluginDetail({ } />
      -
      +
      @@ -455,7 +451,7 @@ export function PluginDetail({ } />
      -
      +
      @@ -565,9 +561,9 @@ export function PluginDetail({ )} -
      +
      -
      + ); } @@ -625,16 +621,16 @@ function DataCard({ } > {error ? ( -
      +
      {t("plugins.manager.data.loadFailed")}
      ) : !data ? ( -
      +
      {t("plugins.manager.loading")}
      ) : ( -
      -
      +
      + -
      + {data.tables.length > 0 && (
      {data.tables.map((table) => (
      {table.name} @@ -675,7 +671,7 @@ function DataCard({ ))}
      )} -
      +
      {t("plugins.manager.data.migrations", { count: data.migrations.length, @@ -701,7 +697,7 @@ function Stat({ hint?: string; }) { return ( -
      +
      {label} diff --git a/src/ui/plugins/PluginReleaseNotes.tsx b/src/ui/plugins/PluginReleaseNotes.tsx index 621285462..f23c9fdc0 100644 --- a/src/ui/plugins/PluginReleaseNotes.tsx +++ b/src/ui/plugins/PluginReleaseNotes.tsx @@ -62,7 +62,7 @@ function Release({ .filter((group) => group.items.length > 0); return ( -
      +
      {row.version} {row.version === installedVersion && ( diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 519843dac..7823510f1 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -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 { @@ -321,7 +323,7 @@ function Toolbar({ children?: React.ReactNode; }) { return ( -
      +
      - +
      + {children}
      ); @@ -653,7 +660,7 @@ function ActiveFilters({ }); } return ( -
      +
      {t("plugins.manager.filters.results", { count: shown })} @@ -703,7 +710,7 @@ function Pager({ const per = Number(size); if (total <= Number(PAGE_SIZES[0])) return null; return ( -
      +
      {t("plugins.manager.pager.range", { from: (page - 1) * per + 1, @@ -895,7 +902,7 @@ function InstalledSection({ rail={} > {(filtersButton) => ( -
      +
      - {failed ? ( -
      - +
      + {failed ? ( - {plugin.status === "blocked" - ? t("plugins.manager.status.blocked") - : t("plugins.manager.status.failed")} + + + {plugin.status === "blocked" + ? t("plugins.manager.status.blocked") + : t("plugins.manager.status.failed")} + -
      - ) : !running ? ( -
      - - - {plugin.status === "server" - ? t("plugins.manager.status.server") - : plugin.status === "safeMode" - ? t("plugins.manager.status.safeMode") - : t("plugins.manager.status.disabled")} + ) : ( + + + + {!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")} + -
      - ) : ( -
      - - - {plugin.pinnedVersion - ? t("plugins.manager.pinnedTo", { - version: plugin.pinnedVersion, - }) - : t("plugins.manager.status.running")} - -
      - )} - -
      + )} +
      {plugin.updateAvailable && !plugin.pinnedVersion && !failed && ( - +
      )} - -
      - {[...gated, ...plain, ...pinned].map((p) => ( - onOpen(p.id)} - /> - ))} -
      -
      + {[...gated, ...plain, ...pinned].map((p) => ( + onOpen(p.id)} + /> + ))} + ); } diff --git a/src/ui/settings/HostPluginSections.tsx b/src/ui/settings/HostPluginSections.tsx index d4fb2057b..7b702d159 100644 --- a/src/ui/settings/HostPluginSections.tsx +++ b/src/ui/settings/HostPluginSections.tsx @@ -66,7 +66,7 @@ export function HostPluginSections({ if (plugins.length === 0) return null; return ( -
      +
      {plugins.map((plugin) => ( -
      - {children} -
      +
      {children}
      ); } diff --git a/src/ui/tests/components/data-view.test.tsx b/src/ui/tests/components/data-view.test.tsx index f7a41fe80..dc6287c89 100644 --- a/src/ui/tests/components/data-view.test.tsx +++ b/src/ui/tests/components/data-view.test.tsx @@ -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"); }); diff --git a/src/ui/tests/components/pager.test.tsx b/src/ui/tests/components/pager.test.tsx new file mode 100644 index 000000000..d8a3f5b9d --- /dev/null +++ b/src/ui/tests/components/pager.test.tsx @@ -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( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("moves between pages", () => { + const onPage = vi.fn(); + render(); + expect(screen.getByText("panel.pager.previous")).toBeDisabled(); + fireEvent.click(screen.getByText("panel.pager.next")); + expect(onPage).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/ui/tests/components/status.test.tsx b/src/ui/tests/components/status.test.tsx new file mode 100644 index 000000000..42113c4d2 --- /dev/null +++ b/src/ui/tests/components/status.test.tsx @@ -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(); + expect(screen.getByText("failed")).toHaveClass("text-destructive"); + }); + + it("lets a tone win over the state", () => { + render( + + Fine + , + ); + expect(screen.getByText("Fine")).toHaveClass("text-green-500"); + }); +}); + +describe("StatusDot", () => { + it("colors by state", () => { + render(); + expect(screen.getByTitle("up")).toHaveClass("bg-green-500"); + }); +}); diff --git a/src/ui/tests/components/tile-grid/TileGrid.test.tsx b/src/ui/tests/components/tile-grid/TileGrid.test.tsx index 833591097..a71bcdd3f 100644 --- a/src/ui/tests/components/tile-grid/TileGrid.test.tsx +++ b/src/ui/tests/components/tile-grid/TileGrid.test.tsx @@ -279,7 +279,7 @@ describe("TileGrid", () => { ).container.querySelector(".tg-content > div")!.className; expect(card()).not.toContain("bg-card"); cleanup(); - expect(card(12)).toContain("border border-border bg-card"); + expect(card(12)).toContain("border border-border"); }); it("stacks cards with no edit controls on narrow screens", () => { diff --git a/src/ui/tests/components/tile-grid/TileWall.test.tsx b/src/ui/tests/components/tile-grid/TileWall.test.tsx new file mode 100644 index 000000000..44af98eea --- /dev/null +++ b/src/ui/tests/components/tile-grid/TileWall.test.tsx @@ -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( + + one + {null} + two + , + ); + 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( + + + x + + , + ); + expect(cardOf("CPU")).not.toHaveClass("border"); + }); + + it("uses column classes in place of size", () => { + render( + + a + , + ); + 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( + + alone + , + ); + expect(edgeOf("alone")).not.toHaveClass("-mb-px"); + }); + + it("is left to the section inside a stack", () => { + render( + + + nested + + , + ); + 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( +
      + + full + +
      , + ); + expect(edgeOf("full")).toHaveClass("-mb-px"); + rect.mockRestore(); + vi.unstubAllGlobals(); + }); +}); + +describe("TileStack", () => { + it("draws a line under each section", () => { + render( + + first + second + , + ); + 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( + + body + , + ); + const card = cardOf("Shell"); + expect(card).toHaveClass("border"); + expect(card).not.toHaveClass("bg-card"); + }); + + it("drop the border inside a TileColumn", () => { + render( + +
      + + body + +
      +
      , + ); + expect(cardOf("Nested")).not.toHaveClass("border"); + }); +});