mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
feat: shared toolbar, footer and host dot pieces for plugin UI
This commit is contained in:
1 parent
3c6229671c
commit
020f30541a
25 files changed
+539
-39
No files matched your search
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.2
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `/ui` exports `ToolbarBand` (a toolbar row outside the PanelShell toolbar), `ToolbarSelect` (a small select for those rows) and `TOOLBAR_FIT` (the classes that make every control in a row 32px)
|
||||
- `PANEL.section`, the one inner layout for a SectionCard body
|
||||
- `TextAreaField` takes `fill`, so a config or log editor uses the rest of the height
|
||||
- `registerHostBadge` takes `useDot`. Every plugin's dots merge into one dot on the host row, with the worst tone and each reason in its tooltip. `/ui` exports `HostDotGroup` for layouts that draw host rows
|
||||
|
||||
### Changed
|
||||
|
||||
- Controls in the PanelShell toolbar, tab band and footer are all 32px, so small buttons line up with search boxes and selects
|
||||
- `Segmented` and `ViewToggle` match the height of inputs and buttons, and `Segmented` keeps its natural width
|
||||
- `FormFooter` buttons are full height
|
||||
- `PANEL.body` is `p-3`, the same padding `InlineView` gives a form
|
||||
- A body with a pinned footer no longer draws a closing line just above the footer's own line
|
||||
- The placeholder for a plugin view that is still starting uses `LoadingState`
|
||||
- `termix-plugin validate` warns about UI drift: raw loading spinners, small back buttons, native selects, stretched filters, filled headers, capped log views and footers that scroll away
|
||||
- A `ToolbarBand` right inside a `TileStack` leaves the line under it to the stack
|
||||
- `ListBadge` and `StatusBadge` keep their own width in a table cell instead of filling it
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.1.1 | Plugin API 1.15.0
|
||||
SDK 1.1.2 | 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.
|
||||
@@ -782,9 +782,27 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
`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.
|
||||
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes and
|
||||
keeps its natural width, never stretched across the pane. 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.
|
||||
- Toolbars: search, filters and buttons go in `PanelShell`'s `toolbar` slot, or in a `ToolbarBand` for a second
|
||||
row. Both make every control in the row 32px, so a `size="sm"` button lines up with a search box. Use
|
||||
`ToolbarSelect` for a small select there, never a native `<select>`. Don't build your own row with its own
|
||||
padding.
|
||||
- Padding: loose content in a page body (a hint, a single field) takes `PANEL.body`. A `SectionCard` body uses
|
||||
`PANEL.section` for its fields.
|
||||
- Headers have no background. Label a group with `GroupHeading`, never a row with `bg-muted`.
|
||||
- Sub views: a detail or edit view opens as an `InlineView`, which has the one back button. Anywhere else use
|
||||
`BackButton` in `PanelShell`'s `leading` slot. Never make a smaller back button.
|
||||
- Save and Add always sit pinned at the bottom: pass `FormFooter` to `InlineView` or `PanelShell` as `footer`.
|
||||
Inside a tab body, wrap the form in `<PanelShell chrome={false} footer={...}>`.
|
||||
- Loading: `LoadingState` for anything still loading (`compact` in cards and rows). A spinner on its own is
|
||||
only for a button or an activity like "working".
|
||||
- Config and log editors fill the rest of the view: `TextAreaField fill`, or `min-h-0 flex-1` on a log, never a
|
||||
fixed number of rows or a `max-h-[60vh]`.
|
||||
- Host row dots: report a problem with `registerHostBadge({ useDot })` instead of drawing a dot. The layout
|
||||
merges every plugin's dots into one.
|
||||
- `termix-plugin validate` lists drift from these rules under `ui`. They are warnings, so fix what applies.
|
||||
- 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
|
||||
|
||||
@@ -51,6 +51,9 @@ export async function validate({ cwd }) {
|
||||
for (const warning of docsWarnings(cwd, raw)) {
|
||||
console.warn(` warn ${warning}`);
|
||||
}
|
||||
for (const warning of uiWarnings(cwd)) {
|
||||
console.warn(` ui ${warning}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -152,6 +155,92 @@ function walkSources(dir, out = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether line i sits inside a still open Button within the lines before it. */
|
||||
function insideButton(lines, i, reach = 12) {
|
||||
const before = lines.slice(Math.max(0, i - reach), i + 1).join("\n");
|
||||
const open = before.lastIndexOf("<Button");
|
||||
const openLower = before.lastIndexOf("<button");
|
||||
const start = Math.max(open, openLower);
|
||||
if (start < 0) return false;
|
||||
return !/<\/(Button|button)>/.test(before.slice(start));
|
||||
}
|
||||
|
||||
const UI_RULES = [
|
||||
{
|
||||
test: (line) => /animate-spin|<Loader2\b/.test(line),
|
||||
// Spinners inside a button, and activity spinners like "working", are fine.
|
||||
skip: (lines, i) =>
|
||||
insideButton(lines, i) ||
|
||||
!/loading|Loading/.test(lines.slice(Math.max(0, i - 4), i + 4).join(" ")),
|
||||
message: "raw spinner while loading, use LoadingState",
|
||||
},
|
||||
{
|
||||
test: (line) => /<(ArrowLeft|ChevronLeft)\b/.test(line),
|
||||
// Only real back buttons, not previous page or month arrows.
|
||||
skip: (lines, i) =>
|
||||
!insideButton(lines, i, 6) ||
|
||||
!/back|onBack|close\b/i.test(
|
||||
lines.slice(Math.max(0, i - 6), i + 4).join(" "),
|
||||
),
|
||||
message:
|
||||
"hand made back button, use BackButton in PanelShell leading or InlineView",
|
||||
},
|
||||
{
|
||||
test: (line) => /<select\b/.test(line),
|
||||
message: "native select, use Select or SelectField",
|
||||
},
|
||||
{
|
||||
test: (line) => /\[&>button\]:flex-1/.test(line),
|
||||
message: "stretched Segmented, keep filters at their natural width",
|
||||
},
|
||||
{
|
||||
test: (line) =>
|
||||
/(^|[\s"'`])bg-(muted|card|accent)(\/\d+)?(?=[\s"'`]|$)/.test(line) &&
|
||||
/\bborder-b\b/.test(line) &&
|
||||
/(uppercase|GroupHeading|py-1\.5)/.test(line),
|
||||
message: "filled section header, headers have no background",
|
||||
},
|
||||
{
|
||||
test: (line) => /max-h-\[\d+vh\]/.test(line),
|
||||
message: "viewer capped by screen height, let it fill the view",
|
||||
},
|
||||
{
|
||||
test: (line) => /<FormFooter\b/.test(line),
|
||||
skip: (lines, i) =>
|
||||
/footer\s*=/i.test(lines.slice(Math.max(0, i - 4), i + 1).join(" ")),
|
||||
message:
|
||||
"FormFooter in the body, pass it to an InlineView or PanelShell footer (PanelShell chrome={false} inside a tab) so it stays pinned",
|
||||
},
|
||||
{
|
||||
test: (line) =>
|
||||
/\{t\(["'][\w.]*[Ll]oading["']\)\}/.test(line) && !/label=/.test(line),
|
||||
message: "plain loading text, use LoadingState",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Drift from the shared UI kit in src/frontend: raw spinners, hand made back
|
||||
* buttons, native selects, stretched filters, filled headers and footers that
|
||||
* scroll away. Warnings only, since some cases are on purpose.
|
||||
*/
|
||||
export function uiWarnings(cwd) {
|
||||
const warnings = [];
|
||||
for (const file of walkSources(path.join(cwd, "src", "frontend"))) {
|
||||
if (!file.endsWith(".tsx")) continue;
|
||||
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
||||
const rel = path.relative(cwd, file).replace(/\\/g, "/");
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s*(\/\/|\*|import\b)/.test(line)) return;
|
||||
for (const rule of UI_RULES) {
|
||||
if (rule.test(line) && !(rule.skip && rule.skip(lines, i))) {
|
||||
warnings.push(`${rel}:${i + 1} ${rule.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs gaps that do not block a release: no docs/index.md, or a process.env
|
||||
* read the manifest does not list in env.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -656,7 +656,7 @@ export interface HostProtocolContribution {
|
||||
quickConnect?: { showDomain?: boolean };
|
||||
}
|
||||
|
||||
export interface HostBadgeContribution {
|
||||
export interface HostRowContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<{ host: PluginHostRecord }>;
|
||||
@@ -664,11 +664,32 @@ export interface HostBadgeContribution {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** A problem shown as the host row's status dot. */
|
||||
export interface HostDot {
|
||||
tone: "bad" | "warn";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface HostBadgeContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
/** Drawn as is on the row. Leave out for a badge that is only a dot. */
|
||||
component?: ComponentType<{ host: PluginHostRecord }>;
|
||||
/**
|
||||
* A hook that returns a problem or null. Every plugin's dots merge into one
|
||||
* dot on the row, with the worst tone and each label in its tooltip, so two
|
||||
* plugins reporting the same host never draw two dots. Core 26.11.1+.
|
||||
*/
|
||||
useDot?: (host: PluginHostRecord) => HostDot | null;
|
||||
/** Lower comes first. Ties sort by id. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** The dot or stripe that shows a host's status on its row. */
|
||||
export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||
export type HostStatusIndicatorContribution = HostRowContribution;
|
||||
|
||||
/** A control in the host editor's toolbar. */
|
||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||
export type HostEditorToolbarItemContribution = HostRowContribution;
|
||||
|
||||
export interface HostSectionProps {
|
||||
host: PluginHostRecord;
|
||||
|
||||
@@ -149,6 +149,7 @@ HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||
HostDefaultsLevel (type): HostDefaultsLevel
|
||||
HostDefaultsTarget (type): HostDefaultsTarget
|
||||
HostDensity (type): HostDensity
|
||||
HostDotGroup (value): ({ host, badges, className, }: { host: Host; badges: HostBadgeDef[]; className?: string; }) => Element
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
@@ -210,7 +211,7 @@ NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }:
|
||||
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
|
||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PANEL (value): { readonly body: "p-3"; readonly section: "flex flex-col gap-3 py-3"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
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"
|
||||
@@ -309,6 +310,7 @@ SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }:
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TILE_COLUMNS (value): 12
|
||||
TOOLBAR_FIT (value): "[&_[data-slot=button][data-size=sm]]:h-8 [&_[data-slot=button][data-size=xs]]:h-8 [&_[data-slot=button][data-size=icon-sm]]:size-8 [&_[data-slot=button][data-size=icon-xs]]:size-8 [&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-8"
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -318,7 +320,7 @@ TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing,
|
||||
TabStripItem (type): TabStripItem
|
||||
TabType (type): TabType
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, fill, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; fill?: boolean; className?: string; }) => Element
|
||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
@@ -335,6 +337,8 @@ 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
|
||||
ToolbarBand (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
ToolbarSelect (value): <T extends string>({ value, onChange, options, label, disabled, className, }: { value: T; onChange: (value: NoInfer<T>) => void; options: { value: NoInfer<T>; label: string; }[]; label: string; disabled?: boolean; className?: string; }) => Element
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
|
||||
Reference in new issue
Block a user