mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-05 18:15:24 +00:00
feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0
This commit is contained in:
1 parent
1867be3f58
commit
19bdd589f3
41 files changed
+1497
-207
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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<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; }
|
||||
@@ -180,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
|
||||
@@ -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<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
|
||||
@@ -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): <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
|
||||
@@ -248,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
|
||||
@@ -277,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
|
||||
@@ -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 & 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
|
||||
@@ -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): <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
|
||||
@@ -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): <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[]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,10 @@ export function MetricCard({
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-tile-card=""
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden",
|
||||
!tiled && "border border-border bg-card",
|
||||
!tiled && "border border-border",
|
||||
)}
|
||||
>
|
||||
<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,
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<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"
|
||||
@@ -223,10 +222,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 +241,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 +249,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 +276,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 +291,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 +327,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 +354,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 +410,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 +427,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 +451,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 +561,9 @@ export function PluginDetail({
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TileStack>
|
||||
</div>
|
||||
</div>
|
||||
</TileStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,16 +621,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 +651,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 +671,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 py-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t("plugins.manager.data.migrations", {
|
||||
count: data.migrations.length,
|
||||
@@ -701,7 +697,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 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold">{row.version}</span>
|
||||
{row.version === installedVersion && (
|
||||
|
||||
@@ -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 (
|
||||
<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 +348,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 +660,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 +710,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 +902,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}
|
||||
@@ -1083,43 +1090,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 +1161,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 +1169,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 +1437,7 @@ function BrowseSection({
|
||||
}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-col">
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
@@ -1551,7 +1546,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 +1641,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 +1659,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user