diff --git a/.prettierignore b/.prettierignore index 9d9f404b7..f1e9db6f4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -23,3 +23,5 @@ drizzle/ # Helm templates contain Go template syntax, not plain YAML charts/*/templates/ + +src/ui/settings/settings-search-index.ts diff --git a/package.json b/package.json index 3c14bcf3d..89515051e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-test-locations.cjs", + "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs", "lint:fix": "eslint --fix .", "type-check": "npm run build:sdk && tsc -b --force", "test": "vitest run", diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 109373cac..721d60123 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -210,6 +210,21 @@ export interface ShellApi { openRailView: (id: string) => void; /** Closes a rail view wherever it is shown. */ closeRailView: (id: string) => void; + /** + * Asks a yes or no question over the main area, for code that runs outside + * a panel or tab (a keybinding, a toolbar action). Inside one, prefer + * useConfirm from @termix/plugin-sdk/ui, which asks over that surface. + */ + confirm?: (options: ConfirmRequest) => Promise; +} + +export interface ConfirmRequest { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + /** Red confirm button. Defaults to true. */ + destructive?: boolean; } export interface TabsApi extends Pick< @@ -260,6 +275,11 @@ export interface RailItemContribution { /** Desktop app only. */ electronOnly?: boolean; separatorAfter?: boolean; + /** + * Which band of the rail it sits in: things you keep (objects), things you + * do (tools) or the instance itself (system). Default "tools". + */ + group?: "objects" | "tools" | "system"; /** Hidden without being unregistered, e.g. while a feature is switched off. */ hidden?: boolean; /** @@ -293,8 +313,11 @@ export interface PanelProps { /** Whether the panel is the one currently shown. */ active: boolean; shell: ShellApi; - /** Tells the shell the panel is editing, which widens the sidebar. */ - setEditing: (editing: boolean) => void; + /** + * Tells the shell the panel is editing, which widens the sidebar. "wide" + * widens it further for tables. InlineView does this on its own. + */ + setEditing: (editing: boolean | "wide") => void; /** Type of the focused tab, if any. */ activeTabType?: string; /** Where the panel is rendered. */ @@ -323,6 +346,8 @@ export interface TabHandle { /** Repaint only. The shell calls it every time the tab becomes active. */ refresh?: () => void; notifyResize?: () => void; + /** Asked before the tab closes; resolve false to keep it open. */ + confirmClose?: () => boolean | Promise; [key: string]: unknown; } @@ -629,6 +654,11 @@ export interface DashboardCardContribution { defaultHeight?: number; /** Which column a preset places this card in by default. Defaults to "main". */ defaultPanel?: "main" | "side"; + /** + * "framed" draws a titled header over the card, "bare" lets it run edge to + * edge, for a strip of numbers. Defaults to "framed". + */ + frame?: "framed" | "bare"; component: ComponentType; } @@ -909,6 +939,12 @@ export interface TermixApp extends TermixAppInfo { options?: { origin?: unknown }, ) => Promise; tabs: TabsApi; + /** + * Asks a yes or no question over the main area. For code that runs outside + * a panel or tab; inside one, useConfirm from @termix/plugin-sdk/ui asks + * over that surface instead. + */ + confirm: (options: ConfirmRequest) => Promise; /** The desktop app, when the frontend runs in it. */ desktop: DesktopApi; /** @@ -1333,6 +1369,31 @@ export function usePluginUiPreferences< return bridge.usePluginUiPreferences(bridge.usePluginId()) as never; } +/** + * Grid or list and row density for one of this plugin's views, kept in its + * Appearance area as `viewMode` and `density`. Declare those + * keys in contributes.uiPresets so each preset level can pick a default. + */ +export function usePluginPanelView(prefix = ""): { + view: "grid" | "list"; + density: "comfortable" | "compact"; + compact: boolean; + setView: (view: "grid" | "list") => void; + setDensity: (density: "comfortable" | "compact") => void; +} { + const { values, set } = usePluginUiPreferences(); + const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid"; + const density = + values[`${prefix}density`] === "compact" ? "compact" : "comfortable"; + return { + view, + density, + compact: density === "compact", + setView: (next) => set(`${prefix}viewMode`, next), + setDensity: (next) => set(`${prefix}density`, next), + }; +} + export function useTabs(): TabsApi { return requireHost().useTabs(); } diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index b61b169ee..eada930e3 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -13,6 +13,7 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes) => Element AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes) => Element AlertTitle (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes & HTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element BarSeriesItem (type): BarSeriesItem @@ -27,17 +28,25 @@ CardDescription (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element CardHeader (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +CardMasonry (value): ({ columns, stack, className, children, }: { columns: number; stack?: boolean; className?: string; children: ReactNode; }) => Element CardTitle (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes) => Element ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record; when?: Record; }) => Element +ConfirmOptions (type): ConfirmOptions +ConnectionGate (value): (props: ConnectionGateProps) => Element +ConnectionGateController (type): ConnectionGateController +ConnectionGateProps (type): ConnectionGateProps ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element ConnectionOrigin (type): ConnectionOrigin -ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element +ConnectionScreen (value): ({ status, message, detail, errorMessage, errorDetail, unavailable, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element ConnectionStage (type): ConnectionStage ConnectionStatus (type): ConnectionStatus +ConnectionUnavailable (type): ConnectionUnavailable CustomKeybinding (type): CustomKeybinding +DataColumn (type): DataColumn +DataView (value): ({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element Dialog (value): ({ ...props }: DialogProps) => Element DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes) => Element DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes & { showCloseButton?: boolean; }) => Element @@ -63,20 +72,27 @@ DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes) => Element DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes & { inset?: boolean; }) => Element DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes) => Element +EditingWidth (type): EditingWidth EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element FOLDER_COLORS (value): string[] +Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element +Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element +FieldPair (value): ({ children }: { children: ReactNode; }) => Element FullScreenAppPhase (type): FullScreenAppPhase FullScreenAppWrapper (value): FC GridCardCatalogEntry (type): GridCardCatalogEntry GridColSpan (type): GridColSpan +GridColumns (type): GridColumns GridLayout (type): GridLayout GridSlot (type): GridSlot +GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element HostFeatureFieldsProps (type): HostFeatureFieldsProps HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element +InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal Input (value): ({ className, type, ...props }: DetailedHTMLProps, HTMLInputElement>) => Element Kbd (value): ForwardRefExoticComponent> KbdKey (value): { ({ className, ...props }: HTMLAttributes): Element; displayName: string; } @@ -93,8 +109,19 @@ MFAPromptMode (type): MFAPromptMode ManagerCardError (type): ManagerCardError ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element +Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; 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 +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"; } +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" +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 +PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; className?: string; }) => Element +PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element +PanelViewMode (type): PanelViewMode PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element PasswordInput (value): ForwardRefExoticComponent> PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode @@ -106,14 +133,18 @@ PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes Element RadialGaugeProps (type): RadialGaugeProps RecentActivityItem (type): RecentActivityItem +Repeater (value): ({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element RobustClipboardProvider (value): typeof RobustClipboardProvider SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes) => Element ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes) => Element -SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element +SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element +SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element +Segmented (value): ({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes) => Element +SelectField (value): ({ label, value, onChange, options, hint, error, disabled, className, placeholder, }: { label: string; value: T; onChange: (value: T) => void; options: readonly { value: T; label: string; }[]; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; placeholder?: string; }) => Element SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes) => Element SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes) => Element SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes) => Element @@ -123,7 +154,7 @@ SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAtt SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes & { size?: "default" | "sm"; }) => Element SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes) => Element Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes) => Element -SettingRow (value): ({ label, badge, description, defaultKey, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; children: ReactNode; }) => Element +SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element Sheet (value): ({ ...props }: DialogProps) => Element SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes) => Element SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element @@ -137,8 +168,16 @@ Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderP Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element SparklineProps (type): SparklineProps StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element +SurfaceKind (type): SurfaceKind +SurfaceScope (value): ({ kind, onEditingChange, className, children, }: { kind?: SurfaceKind; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element Switch (value): ({ className, ...props }: SwitchProps & RefAttributes) => 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 TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element +TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element +TabStripItem (type): TabStripItem +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 +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 (type): TextareaProps Tooltip (value): ({ ...props }: TooltipProps) => Element @@ -146,7 +185,9 @@ TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipC TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes) => Element UptimeInfo (type): UptimeInfo +UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element VersionInfo (type): VersionInfo +ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element WebSocketConnectionTarget (type): WebSocketConnectionTarget WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise @@ -177,12 +218,16 @@ resolveRemoteHostId (value): (syncId: string) => Promise runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise, options?: AdaptiveTaskOptions) => boolean runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void +usageBarColor (value): (percent: number) => string +usageColor (value): (percent: number) => string useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void useAppTheme (value): () => ThemeProviderState -useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise; } +useConfirm (value): () => (options: ConfirmOptions) => Promise +useConnectionGate (value): () => ConnectionGateController useConnectionLog (value): () => ConnectionLogContextType useIsDefaultsEditor (value): () => boolean useIsMobile (value): () => boolean useOptionalConnectionLog (value): () => ConnectionLogContextType +useSurfaceKind (value): () => SurfaceKind useTabs (value): () => TabContextType useTabsSafe (value): () => TabContextType diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 3bce4d9a9..ca20bec7a 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -34,6 +34,7 @@ const ALLOWED = [ "AlertDialogTitle", "AlertDialogTrigger", "AlertTitle", + "BackButton", "Badge", "BarSeries", "BarSeriesItem", @@ -48,17 +49,25 @@ const ALLOWED = [ "CardFooter", "CardGridCanvas", "CardHeader", + "CardMasonry", "CardTitle", "Checkbox", "ColumnCountStepper", "ComponentSlot", + "ConfirmOptions", + "ConnectionGate", + "ConnectionGateController", + "ConnectionGateProps", "ConnectionLogPanel", "ConnectionLogProvider", "ConnectionOrigin", "ConnectionScreen", "ConnectionStage", "ConnectionStatus", + "ConnectionUnavailable", "CustomKeybinding", + "DataColumn", + "DataView", "Dialog", "DialogClose", "DialogContent", @@ -84,20 +93,27 @@ const ALLOWED = [ "DropdownMenuSubContent", "DropdownMenuSubTrigger", "DropdownMenuTrigger", + "EditingWidth", "EmptyState", "FOLDER_COLORS", + "Facts", "FakeSwitch", + "Field", + "FieldPair", "FullScreenAppPhase", "FullScreenAppWrapper", "GridCardCatalogEntry", "GridColSpan", + "GridColumns", "GridLayout", "GridSlot", + "GroupHeading", "HostDefaultBadge", "HostDefaultField", "HostFeatureFields", "HostFeatureFieldsProps", "HostKeyVerificationDialog", + "InlineView", "Input", "Kbd", "KbdKey", @@ -114,8 +130,19 @@ const ALLOWED = [ "ManagerCardError", "ManagerCardShell", "ManagerSearch", + "Meter", "MetricCard", "MiniStat", + "NumberField", + "PANEL", + "PanePrompt", + "PROMPT_BUTTON", + "PROMPT_DESTRUCTIVE_BUTTON", + "PROMPT_PRIMARY_BUTTON", + "PanelDensity", + "PanelSearch", + "PanelShell", + "PanelViewMode", "PassphraseDialog", "PasswordInput", "PluginComponent", @@ -127,14 +154,18 @@ const ALLOWED = [ "RadialGauge", "RadialGaugeProps", "RecentActivityItem", + "Repeater", "RobustClipboardProvider", "SSHAuthDialog", "ScrollArea", "ScrollBar", + "SecretField", "SectionCard", + "Segmented", "Select", "Select2", "SelectContent", + "SelectField", "SelectGroup", "SelectItem", "SelectLabel", @@ -158,8 +189,16 @@ const ALLOWED = [ "Sparkline", "SparklineProps", "StatRow", + "SurfaceKind", + "SurfaceScope", "Switch", + "SwitchRow", "TOTPDialog", + "TabStrip", + "TabStripItem", + "TagInput", + "TextAreaField", + "TextField", "Textarea", "TextareaProps", "Tooltip", @@ -167,7 +206,9 @@ const ALLOWED = [ "TooltipProvider", "TooltipTrigger", "UptimeInfo", + "UsagePair", "VersionInfo", + "ViewToggle", "WebSocketConnectionTarget", "WidgetTitle", "buildOriginWsUrl", @@ -198,13 +239,17 @@ const ALLOWED = [ "runAdaptiveBackgroundTask", "runAdaptivePolling", "runVisibleInterval", + "usageBarColor", + "usageColor", "useAdaptivePolling", "useAppTheme", - "useConfirmation", + "useConfirm", + "useConnectionGate", "useConnectionLog", "useIsDefaultsEditor", "useIsMobile", "useOptionalConnectionLog", + "useSurfaceKind", "useTabs", "useTabsSafe", ]; diff --git a/scripts/generate-settings-search-index.cjs b/scripts/generate-settings-search-index.cjs new file mode 100644 index 000000000..beeefc3dd --- /dev/null +++ b/scripts/generate-settings-search-index.cjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * Builds src/ui/settings/settings-search-index.ts: the i18n keys each core + * settings section shows, so the Settings search can match on any label in + * a section without mounting it. Run it after changing a settings section. + * + * --check fails when the committed file is out of date. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); +const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar"); +const OUT = path.join( + ROOT, + "src", + "ui", + "settings", + "settings-search-index.ts", +); + +function keysIn(text) { + return [ + ...new Set([...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => m[1])), + ]; +} + +function read(file) { + return fs.readFileSync(path.join(SIDEBAR, file), "utf8"); +} + +/** The body of one exported or local function, by name. */ +function functionBody(source, name) { + const start = source.search(new RegExp(`function ${name}\\b`)); + if (start < 0) throw new Error(`No function ${name}`); + const next = source.slice(start + 1).search(/\n(export )?function \w+/); + return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next); +} + +/** Profile sections, split on their AccordionSection ids. */ +function profileSections() { + const source = read("UserProfilePanel.tsx"); + const lines = source.split("\n"); + const starts = []; + lines.forEach((line, i) => { + if (/^\s{6} { + const end = k + 1 < starts.length ? starts[k + 1] : lines.length; + const chunk = lines.slice(start, end).join("\n"); + const id = (chunk.match(/\bid="([^"]+)"/) || [])[1]; + if (id) out[id] = keysIn(chunk); + }); + out.interface = [ + ...new Set([ + ...(out.interface ?? []), + ...keysIn(read("InterfacePresetSettings.tsx")), + ]), + ]; + out.security = [ + ...new Set([ + ...(out.security ?? []), + ...keysIn(read("AuthEnrollmentSections.tsx")), + ...keysIn(functionBody(source, "PasswordChangeSection")), + ]), + ]; + out["api-keys"] = [ + ...new Set([ + ...(out["api-keys"] ?? []), + ...keysIn(functionBody(source, "NewApiKeyDialog")), + ]), + ]; + out.keybindings = keysIn( + fs.readFileSync( + path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"), + "utf8", + ), + ); + return out; +} + +function adminSections() { + const settings = read("AdminSettingsSections.tsx"); + const management = read("AdminManagementSections.tsx"); + return { + "admin-general": keysIn( + functionBody(settings, "AdminGeneralSettingsSection"), + ), + "admin-users": [ + ...new Set([ + ...keysIn(functionBody(management, "AdminUsersSection")), + ...keysIn(read("AdminUserDialogs.tsx")), + ]), + ], + "admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")), + "admin-roles": keysIn(functionBody(management, "AdminRolesSection")), + "admin-host-defaults": [ + ...new Set([ + ...keysIn(functionBody(settings, "AdminHostDefaultsSection")), + ...keysIn(read("AdminHostTags.tsx")), + ]), + ], + "admin-branding": keysIn(read("AdminBrandingSection.tsx")), + "admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")), + "admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")), + "admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")), + "admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")), + }; +} + +function render() { + const index = { ...profileSections(), ...adminSections() }; + const body = Object.entries(index) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([id, keys]) => + ` ${JSON.stringify(id)}: [\n${keys + .sort() + .map((key) => ` ${JSON.stringify(key)},`) + .join("\n")}\n ],`, + ) + .join("\n"); + return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record = {\n${body}\n};\n`; +} + +const next = render(); +if (process.argv.includes("--check")) { + const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : ""; + if (current !== next) { + console.error( + "src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs", + ); + process.exit(1); + } +} else { + fs.writeFileSync(OUT, next); + console.log(`Wrote ${path.relative(ROOT, OUT)}`); +} diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 6350dd02a..26abe1f16 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -146,6 +146,7 @@ export type HostFolder = { type KnownTabType = | "dashboard" | "host-manager" + | "settings" | "user-profile" | "admin-settings" | "split-screen"; @@ -189,6 +190,8 @@ export type Tab = { refresh?: () => void; getApplicationCursorKeysMode?: () => boolean; focus?: () => void; + /** Asked before the tab closes; resolve false to keep it open. */ + confirmClose?: () => boolean | Promise; } | null>; }; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index cdb92313c..cfe3c3549 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -3,21 +3,8 @@ import type { TabHandle } from "@termix/plugin-sdk/frontend"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; -import { Separator } from "@/components/separator"; -import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; -import { - ChevronLeft, - ChevronRight, - Columns2, - Maximize2, - Minimize2, - PanelRight, - RotateCcw, - Rows2, - SquareArrowOutUpRight, - X, -} from "lucide-react"; +import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react"; import { useState, useRef, @@ -32,7 +19,7 @@ import { import { createPortal } from "react-dom"; import { useIsMobile } from "@/hooks/use-mobile"; import { resetPermissionsCache } from "@/hooks/use-permissions"; -import { MobileBottomBar } from "@/shell/MobileBottomBar"; +import { MobileBar } from "@/shell/MobileBar"; import { AppRail, type RailView } from "@/sidebar/AppRail"; import { ComponentSlot } from "@/shell/ActionSlot"; import { @@ -42,7 +29,17 @@ import { rightDockableIds, useRailItems, } from "@/sidebar/rail-items"; -import { MultiPanelHint } from "@/sidebar/MultiPanelHint"; +import { + DOCK_DEFAULT_WIDTH, + DockPanel, + DockReopenStrip, +} from "@/shell/DockPanel"; +import { + SurfaceScope, + useConfirm, + type ConfirmOptions, + type EditingWidth, +} from "@/components/surface/surface-scope"; import { OnboardingDialog } from "@/onboarding/OnboardingDialog"; import { UI_ONBOARDING_VERSION } from "@/types/ui-preferences"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; @@ -55,6 +52,12 @@ import { import { renderTabContent } from "@/shell/tabUtils"; import { TabBar } from "@/shell/TabBar"; import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs"; +import { canCloseTab } from "@/shell/tab-close-guards"; +import { + MANAGE_REQUEST_EVENT, + requestFromLegacyEvent, + requestManage, +} from "@/manage/manage-requests"; import { dispatchCtrlW, createCommandPaletteShortcutMatcher, @@ -87,21 +90,11 @@ const QuickConnectPanel = lazy(() => })), ); -const UserProfilePanel = lazy(() => - import("@/sidebar/UserProfilePanel").then((m) => ({ - default: m.UserProfilePanel, - })), -); const SyncPanel = lazy(() => import("@/settings/sync/SyncPanel").then((m) => ({ default: m.SyncPanel, })), ); -const AdminSettingsPanel = lazy(() => - import("@/sidebar/AdminSettingsPanel").then((m) => ({ - default: m.AdminSettingsPanel, - })), -); const CredentialsPanel = lazy(() => import("@/sidebar/CredentialsPanel").then((m) => ({ default: m.CredentialsPanel, @@ -157,7 +150,7 @@ import { } from "@/shell/shell-layout"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; import { useSyncStatus } from "@/hooks/use-sync-status"; -import { rem, remScale } from "@/lib/rem"; +import { remScale } from "@/lib/rem"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { sshHostToHost } from "@/sidebar/HostManagerData"; @@ -249,6 +242,19 @@ import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; export { buildHostTree } from "@/sidebar/build-host-tree"; export { tabIcon, renderTabContent } from "@/shell/tabUtils"; +/** Hands the main area's confirm to code outside it, like closeTab. */ +function ShellConfirmCapture({ + confirmRef, +}: { + confirmRef: React.MutableRefObject< + ((options: ConfirmOptions) => Promise) | null + >; +}) { + const confirm = useConfirm(); + confirmRef.current = confirm; + return null; +} + // ─── AppShell ──────────────────────────────────────────────────────────────── export function AppShell({ @@ -340,30 +346,75 @@ export function AppShell({ const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); - // Host defaults open in the host manager, from anywhere (the admin panel, - // a folder's menu). + // Anything that asks to add or edit a host, a credential or host defaults + // ends up in the Manage tab, whichever event it still speaks. useEffect(() => { + const names = [ + "host-manager:add-host", + "host-manager:edit-host", + "host-manager:add-credential", + "host-manager:show-credentials", + "host-manager:edit-defaults", + "termix:open-host-defaults", + ]; const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - setSidebarOpen(true); - setRailView("hosts"); - setTimeout(() => { - window.dispatchEvent( - new CustomEvent("host-manager:edit-defaults", { detail }), - ); - }, 0); + const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail); + if (request) requestManage(request); + }; + const open = () => openSingletonTabRef.current("host-manager"); + for (const name of names) window.addEventListener(name, handler); + window.addEventListener(MANAGE_REQUEST_EVENT, open); + return () => { + for (const name of names) window.removeEventListener(name, handler); + window.removeEventListener(MANAGE_REQUEST_EVENT, open); }; - window.addEventListener("termix:open-host-defaults", handler); - return () => - window.removeEventListener("termix:open-host-defaults", handler); }, []); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_sidebarWidth"); return saved ? parseInt(saved, 10) : 291; }); const [sidebarDragging, setSidebarDragging] = useState(false); - const [sidebarEditing, setSidebarEditing] = useState(false); - const [settingsFullscreen, setSettingsFullscreen] = useState(false); + // How far each panel wants the sidebar widened: what the panel says itself, + // and what its open inline views say. + const [panelEditing, setPanelEditing] = useState< + Record + >({}); + const [scopeEditing, setScopeEditing] = useState< + Record + >({}); + const setPanelEditingFor = useCallback( + (view: string, editing: EditingWidth) => + setPanelEditing((prev) => + prev[view] === editing ? prev : { ...prev, [view]: editing }, + ), + [], + ); + const setScopeEditingFor = useCallback( + (view: string, editing: EditingWidth) => + setScopeEditing((prev) => + prev[view] === editing ? prev : { ...prev, [view]: editing }, + ), + [], + ); + const editingSetters = useRef( + new Map void>(), + ); + const panelEditingSetter = (view: string) => { + const key = `panel:${view}`; + if (!editingSetters.current.has(key)) + editingSetters.current.set(key, (e) => setPanelEditingFor(view, e)); + return editingSetters.current.get(key)!; + }; + const scopeEditingSetter = (view: string) => { + const key = `scope:${view}`; + if (!editingSetters.current.has(key)) + editingSetters.current.set(key, (e) => setScopeEditingFor(view, e)); + return editingSetters.current.get(key)!; + }; + // The root confirm, for questions the shell itself asks (closing a tab). + const shellConfirmRef = useRef< + ((options: ConfirmOptions) => Promise) | null + >(null); // Right dock — a second panel column so reference panels like history can // stay visible while the left sidebar is used for something else. @@ -414,22 +465,6 @@ export function AppShell({ }, [tabs]); const isMobile = useIsMobile(); - const isSettingsView = - railView === "user-profile" || railView === "admin-settings"; - - useEffect(() => { - if (!settingsFullscreen) return; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setSettingsFullscreen(false); - }; - window.addEventListener("keydown", closeOnEscape); - return () => window.removeEventListener("keydown", closeOnEscape); - }, [settingsFullscreen]); - - useEffect(() => { - if (!isSettingsView) setSettingsFullscreen(false); - }, [isSettingsView]); - const sidebarOpenBeforeMobile = useRef(sidebarOpen); useEffect(() => { if (isMobile) { @@ -1663,41 +1698,31 @@ export function AppShell({ if (getTabType(type)?.multiInstance) { return openMultiInstanceTab(type, { host, data, label: tabLabel }); } - if (type === "host-manager") { - if (pendingEvent === "host-manager:add-credential") { - setSidebarOpen(true); - setRailView("credentials"); - setTimeout( - () => - window.dispatchEvent( - new CustomEvent("host-manager:add-credential"), - ), - 0, - ); - } else if (pendingEvent === "host-manager:show-credentials") { - setSidebarOpen(true); - setRailView("credentials"); - } else { - setSidebarOpen(true); - setRailView("hosts"); - if (pendingEvent) { - setTimeout( - () => window.dispatchEvent(new CustomEvent(pendingEvent)), - 0, - ); - } - } - return; + if (type === "host-manager" && pendingEvent) { + const request = requestFromLegacyEvent(pendingEvent); + if (request) requestManage(request); } + // Profile and admin are pages of the one Settings tab now. if (type === "user-profile" || type === "admin-settings") { - setSidebarEditing(false); - setRailView(type as RailView); - setSidebarOpen(true); - return; + return openSingletonTab( + "settings", + undefined, + undefined, + { + section: + typeof data?.section === "string" + ? data.section + : type === "admin-settings" + ? "admin-general" + : "account", + }, + tabLabel, + ); } const id = type; const singletonLabels: Partial> = { - "host-manager": t("nav.hostManager"), + "host-manager": t("nav.manage"), + settings: t("nav.settings"), }; // A plugin tab names itself; promoted rail panels reuse the rail's own // label so the two stay in sync. @@ -1748,6 +1773,9 @@ export function AppShell({ [t], ); + const openSingletonTabRef = useRef(openSingletonTab); + openSingletonTabRef.current = openSingletonTab; + const getTabCloseLabel = useCallback((tab: Tab) => { return tab.customLabel || tab.label || tab.host?.name || String(tab.id); }, []); @@ -1776,6 +1804,8 @@ export function AppShell({ }; }, [hasActiveConnection]); + const doCloseTabRef = useRef<(id: string) => void>(() => {}); + doCloseTabRef.current = doCloseTab; function doCloseTab(id: string) { const tabToClose = tabs.find((t) => t.id === id); if (tabToClose?.terminalRef?.current?.disconnect) { @@ -1842,35 +1872,23 @@ export function AppShell({ else handle.refresh?.(); } - function closeTab(id: string) { - const tab = tabs.find((t) => t.id === id); + async function closeTab(id: string) { + const tab = tabsRef.current.find((t) => t.id === id); + const guard = tab?.terminalRef?.current?.confirmClose; + if (guard && !(await guard())) return; + if (!(await canCloseTab(id))) return; const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; if (tab && confirmEnabled && isActiveConnectionTab(tab)) { - const closeLabel = getTabCloseLabel(tab); - const toastId = `close-tab-${id}`; - toast(t("nav.confirmCloseHost", { host: closeLabel }), { - id: toastId, - duration: 8000, - action: { - label: t("nav.close"), - onClick: () => { - toast.dismiss(toastId); - doCloseTab(id); - }, - }, - cancel: { - label: t("nav.cancel"), - onClick: () => toast.dismiss(toastId), - }, - }); - return; + const ask = shellConfirmRef.current; + const ok = ask + ? await ask({ + title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }), + confirmLabel: t("nav.close"), + }) + : true; + if (!ok) return; } - - if (tab && isSessionTabType(tab.type) && confirmEnabled) { - toast.dismiss(`close-tab-${id}`); - } - - doCloseTab(id); + doCloseTabRef.current(id); } // In a split, closing acts on the focused pane: its session, or the pane @@ -2266,21 +2284,14 @@ export function AppShell({ } else { // A panel lives in one dock at a time, so the left dock reclaims it. if (rightRailView === view) setRightRailView(null); - if (view !== railView) setSidebarEditing(false); - if (view !== railView) setSettingsFullscreen(false); setRailView(view); setSidebarOpen(true); } } function editHostInManager(host: Host) { - setSidebarOpen(true); - setRailView("hosts"); - setTimeout(() => { - window.dispatchEvent( - new CustomEvent("host-manager:edit-host", { detail: host.id }), - ); - }, 0); + requestManage({ kind: "host", hostId: host.id }); + if (isMobile) setSidebarOpen(false); } const onSidebarMouseDown = useCallback( @@ -2611,18 +2622,13 @@ export function AppShell({ setRailView((prev) => (prev === id ? "hosts" : prev)); setRightRailView((prev) => (prev === id ? null : prev)); }, - openHostEditor: (draft) => { - setSidebarOpen(true); - setRailView("hosts"); - setTimeout( - () => - window.dispatchEvent( - new CustomEvent("host-manager:add-host", { detail: draft }), - ), - 0, - ); - }, + openHostEditor: (draft) => + requestManage({ kind: "host", hostId: null, draft }), saveQuickConnect: saveQuickConnectHost, + confirm: (options) => + shellConfirmRef.current + ? shellConfirmRef.current(options) + : Promise.resolve(false), }; // Tabs get one stable bag that forwards to the latest callbacks, so a shell @@ -2645,6 +2651,7 @@ export function AppShell({ shellImplRef.current.openHostEditor?.(...args), saveQuickConnect: (...args) => shellImplRef.current.saveQuickConnect!(...args), + confirm: (...args) => shellImplRef.current.confirm!(...args), }), [], ); @@ -2675,14 +2682,34 @@ export function AppShell({ setShellHosts(allHosts, hostsLoaded); }, [allHosts, hostsLoaded]); + // Every panel gets its own surface, so its inline views and confirms stay + // inside it. Only the left dock widens for them. + const panelScope = ( + view: string, + owned: boolean, + shown: boolean, + children: React.ReactNode, + className = "", + ) => ( + + {children} + + ); + const renderSidebarPanels = (railView: RailView, owned = true) => ( }>
{owned && ( <> -
+ {panelScope( + "hosts", + owned, + railView === "hosts", { connectHost(host, type, options); @@ -2691,30 +2718,34 @@ export function AppShell({ onEditHost={editHostInManager} hostTree={realHostTree ?? undefined} loading={hostsLoading} - onEditingChange={setSidebarEditing} + onEditingChange={panelEditingSetter("hosts")} active={railView === "hosts"} - /> -
- -
+ />, + )} + {panelScope( + "credentials", + owned, + railView === "credentials", -
+ />, + )} )} - {railView === "quick-connect" && ( - { - openTab(host, type); - if (isMobile) setSidebarOpen(false); - }} - /> - )} + {railView === "quick-connect" && + panelScope( + "quick-connect", + owned, + true, + { + openTab(host, type); + if (isMobile) setSidebarOpen(false); + }} + />, + )} {registeredPanels.map((panel) => { const shown = railView === panel.id; @@ -2722,24 +2753,26 @@ export function AppShell({ // copies never fight over the same state. if (panel.keepMounted ? !owned : !shown) return null; const Panel = panel.component; - return ( -
+ return panelScope( + panel.id, + owned, + shown, +
tab.id === targetTerminalTabId, )} active={shown} shell={panelShell} - setEditing={setSidebarEditing} + setEditing={ + owned ? panelEditingSetter(panel.id) : () => undefined + } activeTabType={ tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined } placement={owned ? "left" : "right"} /> -
+
, ); })} @@ -2747,80 +2780,63 @@ export function AppShell({ )} - {railView === "connections" && ( -
- { - setActiveTabId(tabId); - if (isMobile) setSidebarOpen(false); - }} - onCloseTab={closeTab} - onReopenTab={(record, restoredSessionId) => { - const host = record.hostId - ? allHosts.find((h) => h.id === String(record.hostId)) - : undefined; - if (!host && !getTabType(record.tabType)?.hostless) return; - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== record.id), - ); - if (host) { - const effectiveSessionId = - restoredSessionId ?? record.backendSessionId ?? null; - openTab(host, record.tabType as TabType, { - instanceId: record.id, - restoredSessionId: effectiveSessionId, - savedLabel: record.label, - }); - } else { - openSingletonTab(record.tabType as TabType); - } - if (isMobile) setSidebarOpen(false); - }} - onForgetBackground={(recordId) => { - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== recordId), - ); - }} - onRenameTab={renameTab} - onReorderTabs={setTabs} - /> -
- )} + {railView === "connections" && + panelScope( + "connections", + owned, + true, +
+ { + setActiveTabId(tabId); + if (isMobile) setSidebarOpen(false); + }} + onCloseTab={closeTab} + onReopenTab={(record, restoredSessionId) => { + const host = record.hostId + ? allHosts.find((h) => h.id === String(record.hostId)) + : undefined; + if (!host && !getTabType(record.tabType)?.hostless) return; + setBackgroundTabRecords((prev) => + prev.filter((r) => r.id !== record.id), + ); + if (host) { + const effectiveSessionId = + restoredSessionId ?? record.backendSessionId ?? null; + openTab(host, record.tabType as TabType, { + instanceId: record.id, + restoredSessionId: effectiveSessionId, + savedLabel: record.label, + }); + } else { + openSingletonTab(record.tabType as TabType); + } + if (isMobile) setSidebarOpen(false); + }} + onForgetBackground={(recordId) => { + setBackgroundTabRecords((prev) => + prev.filter((r) => r.id !== recordId), + ); + }} + onRenameTab={renameTab} + onReorderTabs={setTabs} + /> +
, + )} - {railView === "user-profile" && ( -
- - setUserPrefs((current) => ({ ...current, ...updates })) - } - /> -
- )} - - {railView === "sync" && ( -
- -
- )} - - {railView === "admin-settings" && showAdminUI && ( -
- { - connectHost(host); - if (isMobile) setSidebarOpen(false); - }} - /> -
- )} + {railView === "sync" && + panelScope( + "sync", + owned, + true, +
+ +
, + )}
); @@ -2904,107 +2920,29 @@ export function AppShell({ const sidebarPanelContent = renderSidebarPanels(railView); - // Sidebar header — shared - const sidebarHeader = ( -
- - {sidebarTitle(railView)} - - {!isMobile && promotableIds().includes(railView) && ( - <> - - - - )} - {!isMobile && rightDockableIds().includes(railView) && ( - <> - - - - )} - {!isMobile && ( - <> - - - - )} - {isSettingsView && ( - <> - - - - )} - - -
- ); + const settingsTabProps = { + username, + isAdmin: showAdminUI, + onLogout, + userPrefs, + onPrefsChange: (updates: Partial) => + setUserPrefs((current) => ({ ...current, ...updates })), + onOpenHostTab: (host: Host) => connectHost(host), + }; - const sidebarHint = !isMobile && ( - openSingletonTab(railView as TabType)} - onOpenInRightDock={() => openInRightDock(railView)} - /> - ); + const leftEditing: EditingWidth = (() => { + const values = [panelEditing[railView], scopeEditing[railView]]; + if (values.includes("wide")) return "wide"; + return values.some(Boolean); + })(); + + const dockProps = (view: RailView) => ({ + title: sidebarTitle(view), + onOpenAsTab: + !isMobile && promotableIds().includes(view) + ? () => openSingletonTab(view as TabType) + : undefined, + }); return ( @@ -3013,45 +2951,41 @@ export function AppShell({ style={{ height: "100dvh" }} >
- {/* Skinny icon rail — desktop only, hidden on mobile */} - {!settingsFullscreen && ( - - )} + {/* Skinny icon rail, desktop only */} + openSingletonTab("settings")} + onOpenPalette={() => setCommandPaletteOpen(true)} + onLogout={onLogout} + /> {/* Desktop: inline resizable sidebar */} {!isMobile && ( -
setSidebarOpen(false)} + onMoveToRightDock={ + rightDockableIds().includes(railView) + ? () => openInRightDock(railView) + : undefined + } + onResetWidth={() => setSidebarWidth(DOCK_DEFAULT_WIDTH)} > - {sidebarHeader} - {sidebarHint} {sidebarPanelContent} - - {sidebarOpen && !sidebarEditing && !settingsFullscreen && ( -
- )} -
+ )} {/* Mobile: sidebar as overlay sheet */} @@ -3060,150 +2994,142 @@ export function AppShell({ - {sidebarHeader} - {sidebarPanelContent} + setSidebarOpen(false)} + > + {sidebarPanelContent} + )} {/* Main content area */}
{!isMobile && !sidebarOpen && ( - + setSidebarOpen(true)} /> )} -
- -
- {activeSplit && ( -
- - renderEmptyPane(activeSplit.id, paneId, paneIndex) - } - /> -
- )} - + + +
+ +
+ {activeSplit && ( +
+ + renderEmptyPane(activeSplit.id, paneId, paneIndex) + } + /> +
+ )} + - {/* Normal-view container. Tab nodes are appended here (or to pane elements) + {/* Normal-view container. Tab nodes are appended here (or to pane elements) by the DOM-placement effect above. React portals each tab's content into its stable per-tab node so the component is never remounted. Hidden while a split is active. */} -
- {tabsByPortalOrder.map((tab) => { - const tabNode = getTabNode( - tab.id, - !!getTabType(tab.type)?.ownBackground, - ); - const paneId = paneIdByTabId.get(tab.id); - const inPane = !!paneId; - const activeInline = !inPane && tab.id === activeTabId; - const isFocusedPane = inPane - ? paneId === focusedPaneId - : activeInline; - return createPortal( - renderTabContent(tab, { - shell: shellCallbacks, - panelTargetTab: terminalTabs.find( - (t) => t.id === targetTerminalTabId, - ), - isVisible: inPane || activeInline, - isFocusedPane, - inSplit: inPane, - }), - tabNode, - tab.id, - ); - })} +
+ {tabsByPortalOrder.map((tab) => { + const tabNode = getTabNode( + tab.id, + !!getTabType(tab.type)?.ownBackground, + ); + const paneId = paneIdByTabId.get(tab.id); + const inPane = !!paneId; + const activeInline = !inPane && tab.id === activeTabId; + const isFocusedPane = inPane + ? paneId === focusedPaneId + : activeInline; + return createPortal( + + {renderTabContent(tab, { + shell: shellCallbacks, + settings: settingsTabProps, + panelTargetTab: terminalTabs.find( + (t) => t.id === targetTerminalTabId, + ), + isVisible: inPane || activeInline, + isFocusedPane, + inSplit: inPane, + })} + , + tabNode, + tab.id, + ); + })} +
-
+
{/* Bottom nav bar — mobile only */} - openSingletonTab(type)} + onOpenSettings={() => openSingletonTab("settings")} + onOpenPalette={() => setCommandPaletteOpen(true)} + onLogout={() => onLogout({ manual: true })} />
{/* Right dock — desktop only, holds a second reference panel */} - {!isMobile && rightRailView && !settingsFullscreen && ( -
setRightRailView(null)} + onResetWidth={() => setRightSidebarWidth(DOCK_DEFAULT_WIDTH)} > -
- - {sidebarTitle(rightRailView)} - - - -
- {renderSidebarPanels(rightRailView, false)} - -
-
+ )}
@@ -3219,6 +3145,15 @@ export function AppShell({ terminalTabs={terminalTabs} activeTabId={activeTabId} onOpenPanel={(view) => handleRailClick(view as RailView)} + onEditHost={editHostInManager} + onOpenSettings={(section) => + openSingletonTab( + "settings", + undefined, + undefined, + section ? { section } : undefined, + ) + } onOpenTab={(type, label, pendingEvent) => { if ( [ diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index cc86e1137..1f3bcf1ff 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -48,6 +48,7 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry"; import { startPreLoginPlugins } from "@/plugin-host/loader"; import { Checkbox } from "@/components/checkbox"; import { useBranding } from "@/contexts/BrandingContext"; +import { BrandPanel, CompactBrand } from "./auth-bits"; import { changeAppLanguage, normalizeLanguageCode, @@ -1228,39 +1229,26 @@ export function Auth({ onLogin }: AuthProps) { return (
-
-
-
- - TERMIX - -
- - {t("auth.tagline")} - -
-
+
-
+
+

{desktopManualLogoutActive - ? "Local desktop signed out" - : "Local desktop session unavailable"} + ? t("auth.localDesktopSignedOut") + : t("auth.localDesktopUnavailable")}

{desktopManualLogoutActive - ? "You signed out of the local desktop session. Continue locally to use this device's embedded Termix server again." - : "Termix could not create a local desktop session. Retry the embedded local session instead of registering a new account."} + ? t("auth.localDesktopSignedOutDesc") + : t("auth.localDesktopUnavailableDesc")}

- {/* Left decorative panel */} -
-
-
- {branding.logo && ( - - )} - - {branding.appName} - -
- - {branding.tagline || t("auth.tagline")} - -
-
+ {/* Right panel */}
-
+
+ {/* TOTP view */} {view === "second-factor" && (
diff --git a/src/ui/auth/auth-bits.tsx b/src/ui/auth/auth-bits.tsx new file mode 100644 index 000000000..5b9d72ef3 --- /dev/null +++ b/src/ui/auth/auth-bits.tsx @@ -0,0 +1,58 @@ +/** The left half of the sign-in screen: brand mark, tagline, a rule. */ +export function BrandPanel({ + logo, + appName, + tagline, +}: { + logo?: string | null; + appName: string; + tagline: string; +}) { + return ( +
+
+
+ +
+ {logo && } + + {appName} + +
+ +
+

+ {tagline} +

+
+
+ +
+
+ ); +} + +/** The brand line shown above the form when the brand panel is hidden. */ +export function CompactBrand({ + logo, + appName, +}: { + logo?: string | null; + appName: string; +}) { + return ( +
+ {logo && } + + {appName} + +
+ ); +} diff --git a/src/ui/components/card-masonry.tsx b/src/ui/components/card-masonry.tsx new file mode 100644 index 000000000..91f61f2f2 --- /dev/null +++ b/src/ui/components/card-masonry.tsx @@ -0,0 +1,105 @@ +/* eslint-disable react-refresh/only-export-components */ +import { + Children, + Fragment, + isValidElement, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type React from "react"; +import { PANEL } from "@/components/panel-layout"; +import { cn } from "@/lib/utils"; + +/** Column counts per container width tier, for each column preference. */ +const COLUMN_STEPS: Record = { + 1: [1, 1, 1, 1], + 2: [1, 2, 2, 2], + 3: [1, 2, 3, 3], + 4: [1, 2, 3, 4], +}; + +const TIERS = { md: 640, lg: 960, xl: 1240 }; + +/** How many columns fit a container of this width for a column preference. */ +export function masonryColumns(width: number, columns: number): number { + const steps = COLUMN_STEPS[columns] ?? COLUMN_STEPS[3]; + if (width >= TIERS.xl) return steps[3]; + if (width >= TIERS.lg) return steps[2]; + if (width >= TIERS.md) return steps[1]; + return steps[0]; +} + +function flattenChildren(children: React.ReactNode): React.ReactNode[] { + return Children.toArray(children).flatMap((child) => + isValidElement(child) && child.type === Fragment + ? flattenChildren( + (child.props as { children?: React.ReactNode }).children, + ) + : [child], + ); +} + +/** + * Cards dealt round-robin into columns that stack tight. The assignment only + * depends on child order and column count, so live updates never reshuffle + * cards between columns. Width comes from the container, not the window, so + * split panes and docks get the right count. + */ +export function CardMasonry({ + columns, + stack = false, + className, + children, +}: { + columns: number; + /** Force one full-width column, for cards holding wide tables. */ + stack?: boolean; + className?: string; + children: React.ReactNode; +}) { + const ref = useRef(null); + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setWidth(el.clientWidth); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => setWidth(el.clientWidth)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const count = stack ? 1 : masonryColumns(width, columns); + const items = useMemo(() => flattenChildren(children), [children]); + const buckets = useMemo(() => { + const next: React.ReactNode[][] = Array.from({ length: count }, () => []); + items.forEach((child, i) => next[i % count].push(child)); + return next; + }, [items, count]); + + return ( +
+ {count === 1 + ? items + : buckets.map((bucket, i) => ( +
+ {bucket} +
+ ))} +
+ ); +} diff --git a/src/ui/components/connection/ConnectionGate.tsx b/src/ui/components/connection/ConnectionGate.tsx new file mode 100644 index 000000000..e7349da8c --- /dev/null +++ b/src/ui/components/connection/ConnectionGate.tsx @@ -0,0 +1,267 @@ +/* eslint-disable react-refresh/only-export-components */ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import type React from "react"; +import { useConnectionRetry } from "@termix/plugin-sdk/frontend"; +import { + ConnectionLogProvider, + useConnectionLog, +} from "@/ssh/connection-log/ConnectionLogContext"; +import { + ConnectionScreen, + type ConnectionUnavailable, +} from "@/components/connection/ConnectionScreen"; +import type { ConnectionStatus } from "@/components/connection/connection-status"; +import type { ConnectionStage } from "@/types/connection-log"; +import { getErrorMessage } from "@/lib/error-message"; + +function messageOf(err: unknown): string { + return typeof err === "string" ? err : getErrorMessage(err); +} + +type LogFn = (message: string, stage?: ConnectionStage) => void; + +export interface ConnectionGateController { + status: ConnectionStatus; + attempt: number; + maxAttempts: number; + nextRetryInMs: number | null; + error: string | null; + unavailable: ConnectionUnavailable | null; + log: { info: LogFn; success: LogFn; warning: LogFn; error: LogFn }; + markConnected: () => void; + /** It did not come up. Logs the reason and schedules a retry. */ + markFailed: (error?: unknown) => void; + /** It was up and dropped. No automatic retry. */ + markDisconnected: (error?: unknown) => void; + /** It cannot run here, like a feature that is off for this host. */ + markUnavailable: (info: ConnectionUnavailable) => void; + retryNow: () => void; + reset: () => void; +} + +export interface ConnectionGateProps { + /** Opens the connection. Throwing or rejecting counts as a failure. */ + connect: (gate: ConnectionGateController) => void | Promise; + message: string; + detail?: string; + disconnectedMessage?: string; + retry?: { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + autoStart?: boolean; + enabled?: boolean; + }; + /** Keep children mounted under the screen, for a terminal or display. */ + keepMounted?: boolean; + backgroundColor?: string; + extraActions?: React.ReactNode; + className?: string; + children: + React.ReactNode | ((gate: ConnectionGateController) => React.ReactNode); +} + +const GateContext = createContext(null); + +/** The controller of the ConnectionGate this renders inside. */ +export function useConnectionGate(): ConnectionGateController { + const gate = useContext(GateContext); + if (!gate) { + throw new Error("useConnectionGate must be used inside a ConnectionGate"); + } + return gate; +} + +/** + * One connection lifecycle for every tab: connecting, failed with retry and + * a log, dropped, or unavailable. Children render once it is up. + */ +export function ConnectionGate(props: ConnectionGateProps) { + return ( + + + + ); +} + +function Gate({ + connect, + message, + detail, + disconnectedMessage, + retry, + keepMounted, + backgroundColor, + extraActions, + className, + children, +}: ConnectionGateProps) { + const { addLog, clearLogs } = useConnectionLog(); + const [error, setError] = useState(null); + const [unavailable, setUnavailable] = useState( + null, + ); + const [dropped, setDropped] = useState(false); + const maxAttempts = retry?.maxAttempts ?? 8; + const gateRef = useRef(null); + + const write = useCallback( + (type: "info" | "success" | "warning" | "error"): LogFn => + (text, stage = "connection") => + addLog({ type, stage, message: text }), + [addLog], + ); + + const retryState = useConnectionRetry({ + connect: async () => { + setError(null); + setUnavailable(null); + setDropped(false); + try { + await connect(gateRef.current!); + } catch (err) { + gateRef.current?.markFailed(err); + } + }, + maxAttempts, + baseDelayMs: retry?.baseDelayMs, + maxDelayMs: retry?.maxDelayMs, + autoStart: retry?.autoStart, + enabled: retry?.enabled, + }); + + const { + markConnected: retryConnected, + markFailed: retryFailed, + retryNow: retryAgain, + reset: retryReset, + } = retryState; + + const log = useMemo( + () => ({ + info: write("info"), + success: write("success"), + warning: write("warning"), + error: write("error"), + }), + [write], + ); + + const markFailed = useCallback( + (err?: unknown) => { + if (err !== undefined) { + const text = messageOf(err); + setError(text); + log.error(text, "error"); + } + retryFailed(); + }, + [log, retryFailed], + ); + + const markDisconnected = useCallback( + (err?: unknown) => { + if (err !== undefined) { + const text = messageOf(err); + setError(text); + log.warning(text, "connection"); + } + setDropped(true); + }, + [log], + ); + + const markUnavailable = useCallback((info: ConnectionUnavailable) => { + setUnavailable(info); + }, []); + + const retryNow = useCallback(() => { + clearLogs(); + setError(null); + setUnavailable(null); + setDropped(false); + retryAgain(); + }, [clearLogs, retryAgain]); + + const reset = useCallback(() => { + setError(null); + setUnavailable(null); + setDropped(false); + retryReset(); + }, [retryReset]); + + let status: ConnectionStatus = retryState.status; + if (dropped) status = "disconnected"; + + const controller = useMemo( + () => ({ + status, + attempt: retryState.attempt, + maxAttempts, + nextRetryInMs: retryState.nextRetryInMs, + error, + unavailable, + log, + markConnected: () => { + setDropped(false); + setError(null); + retryConnected(); + }, + markFailed, + markDisconnected, + markUnavailable, + retryNow, + reset, + }), + [ + status, + retryState.attempt, + retryState.nextRetryInMs, + maxAttempts, + error, + unavailable, + log, + retryConnected, + markFailed, + markDisconnected, + markUnavailable, + retryNow, + reset, + ], + ); + gateRef.current = controller; + + const up = status === "connected" && !unavailable; + const content = + typeof children === "function" ? children(controller) : children; + + return ( + +
+ {(up || keepMounted) && content} + +
+
+ ); +} diff --git a/src/ui/components/connection/ConnectionLogPanel.tsx b/src/ui/components/connection/ConnectionLogPanel.tsx index 7a1f17c48..55ba209d5 100644 --- a/src/ui/components/connection/ConnectionLogPanel.tsx +++ b/src/ui/components/connection/ConnectionLogPanel.tsx @@ -1,18 +1,10 @@ -import React, { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx"; import { useTranslation } from "react-i18next"; import { copyToClipboard } from "@/lib/clipboard.ts"; import { Button } from "@/components/button.tsx"; import { cn } from "@/lib/utils.ts"; -import { - ChevronDown, - ChevronUp, - Copy, - Info, - CheckCircle2, - AlertTriangle, - XCircle, -} from "lucide-react"; +import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react"; import { toast } from "sonner"; interface ConnectionLogPanelProps { @@ -23,8 +15,23 @@ interface ConnectionLogPanelProps { className?: string; } -const COLLAPSED_HEIGHT = "h-[140px]"; -const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]"; +const COLLAPSED_HEIGHT = "h-[136px]"; +const EXPANDED_HEIGHT = "h-[46%] min-h-[220px]"; + +/** A dot per line, so the log reads as one column of text. */ +const DOT: Record = { + info: "bg-muted-foreground/50", + success: "bg-accent-brand", + warning: "bg-warning", + error: "bg-destructive", +}; + +const TEXT: Record = { + info: "text-foreground-secondary", + success: "text-foreground", + warning: "text-warning", + error: "text-destructive", +}; export function ConnectionLogPanel({ isConnecting, @@ -39,6 +46,7 @@ export function ConnectionLogPanel({ connectionLog ?? {}; const lastLogRef = useRef(null); const [manuallyCollapsed, setManuallyCollapsed] = useState(false); + const [copied, setCopied] = useState(false); useEffect(() => { if (hasConnectionError && setIsExpanded) { @@ -56,7 +64,7 @@ export function ConnectionLogPanel({ useEffect(() => { if (lastLogRef.current) { - lastLogRef.current.scrollIntoView({ block: "end" }); + lastLogRef.current.scrollIntoView?.({ block: "end" }); } }, [logs]); @@ -88,113 +96,101 @@ export function ConnectionLogPanel({ .join("\n"); const ok = await copyToClipboard(logsText); - if (ok) toast.success(t("sshAuth.connectionLogCopied")); - else toast.error(t("sshAuth.connectionLogCopyFailed")); - }; - - const getIcon = (type: string) => { - switch (type) { - case "info": - return ; - case "success": - return ; - case "warning": - return ; - case "error": - return ; - default: - return ; - } - }; - - const getTextColor = (type: string) => { - switch (type) { - case "info": - return "text-blue-400"; - case "success": - return "text-green-400"; - case "warning": - return "text-yellow-400"; - case "error": - return "text-red-400"; - default: - return "text-muted-foreground"; + if (ok) { + setCopied(true); + setTimeout(() => setCopied(false), 1400); + toast.success(t("sshAuth.connectionLogCopied")); + } else { + toast.error(t("sshAuth.connectionLogCopyFailed")); } }; return (
-
- + + + + {logs.length} + + {logs.length > 0 && ( )}
-
-
- {logs.length === 0 ? ( -
- {isConnecting - ? t("sshAuth.connectionLogWaiting") - : t("sshAuth.connectionLogEmpty")} -
- ) : ( -
- {logs.map((log, index) => ( -
+ {logs.length === 0 ? ( +

+ {isConnecting + ? t("sshAuth.connectionLogWaiting") + : t("sshAuth.connectionLogEmpty")} +

+ ) : ( +
+ {logs.map((log, index) => ( +
+ + {log.timestamp.toLocaleTimeString([], { hour12: false })} + + + - - {log.timestamp.toLocaleTimeString()} - - {getIcon(log.type)} - - {log.message} - -
- ))} -
- )} -
+ {log.message} + +
+ ))} +
+ )}
); diff --git a/src/ui/components/connection/ConnectionScreen.tsx b/src/ui/components/connection/ConnectionScreen.tsx index 6e6e04e79..e7b6e5f3d 100644 --- a/src/ui/components/connection/ConnectionScreen.tsx +++ b/src/ui/components/connection/ConnectionScreen.tsx @@ -2,13 +2,29 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils.ts"; import { Button } from "@/components/button.tsx"; -import { Loader2, RefreshCw } from "lucide-react"; +import { RefreshCw } from "lucide-react"; import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx"; import type { ConnectionStatus } from "@/components/connection/connection-status.ts"; -interface ConnectionScreenProps { +export interface ConnectionUnavailable { + title: string; + hint?: string; + /** Another way forward, like opening the host editor. */ + action?: React.ReactNode; +} + +export interface ConnectionScreenProps { status: ConnectionStatus; + /** Headline while connecting. */ message?: string; + /** Second line under the headline, usually user@host:port. */ + detail?: string; + /** Headline once it has failed and is not retrying on its own. */ + errorMessage?: string; + /** The reason it failed, shown under the headline. */ + errorDetail?: string | null; + /** Shown instead of a failure when the feature cannot run here. */ + unavailable?: ConnectionUnavailable | null; backgroundColor?: string; attempt?: number; maxAttempts?: number; @@ -22,9 +38,14 @@ interface ConnectionScreenProps { className?: string; } +/** The one screen every connection shows until it is up. */ export function ConnectionScreen({ status, message, + detail, + errorMessage, + errorDetail, + unavailable, backgroundColor, attempt = 0, maxAttempts = 0, @@ -39,83 +60,165 @@ export function ConnectionScreen({ }: ConnectionScreenProps) { const { t } = useTranslation(); - if (status === "connected" && !emptyState) { + if (status === "connected" && !emptyState && !unavailable) { return null; } - const showSpinner = status === "connecting"; - const showRetryButton = status === "disconnected" && !!onManualRetry; - const showLog = status !== "connected"; + const connecting = status === "connecting" && !unavailable; + const failed = status === "error" || status === "disconnected"; + const retrying = + status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0; + const showRetryButton = (failed || !!unavailable) && !!onManualRetry; + const showLog = !emptyState && (status !== "connected" || !!unavailable); + + let headline = message; + if (unavailable) headline = unavailable.title; + else if (status === "disconnected") + headline = disconnectedMessage || t("connection.disconnected"); + else if (retrying) headline = t("connection.failedRetrying"); + else if (status === "error") + headline = errorMessage || t("connection.failed"); + + const showCountdown = attempt > 0 && (connecting || retrying); return (
-
+
{emptyState ? ( emptyState ) : ( -
- {showSpinner && ( -
); } + +/** A thin arc while working, a still ring with a dot once it stops. */ +function ConnectionMark({ + state, +}: { + state: "busy" | "failed" | "unavailable"; +}) { + return ( + + ); +} diff --git a/src/ui/components/data-view.tsx b/src/ui/components/data-view.tsx new file mode 100644 index 000000000..e2dac1fa6 --- /dev/null +++ b/src/ui/components/data-view.tsx @@ -0,0 +1,162 @@ +import type React from "react"; +import { cn } from "@/lib/utils"; +import { + PANEL, + type PanelDensity, + type PanelViewMode, +} from "@/components/panel-layout"; + +export interface DataColumn { + key: string; + header: string; + /** Any grid track: "1fr", "120px", "minmax(0,2fr)". */ + width: string; + cell: (item: T) => React.ReactNode; + /** Dropped below md, for columns that are nice rather than necessary. */ + hideBelow?: "md" | "lg"; + align?: "start" | "end"; +} + +export interface GridColumns { + base?: number; + md?: number; + lg?: number; + xl?: number; +} + +// Spelled out so Tailwind can see every class. +const COLS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", +}; +const MD_COLS: Record = { + 1: "md:grid-cols-1", + 2: "md:grid-cols-2", + 3: "md:grid-cols-3", + 4: "md:grid-cols-4", +}; +const LG_COLS: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", +}; +const XL_COLS: Record = { + 1: "xl:grid-cols-1", + 2: "xl:grid-cols-2", + 3: "xl:grid-cols-3", + 4: "xl:grid-cols-4", +}; + +function gridClass(columns: GridColumns): string { + return cn( + COLS[columns.base ?? 1], + columns.md && MD_COLS[columns.md], + columns.lg && LG_COLS[columns.lg], + columns.xl && XL_COLS[columns.xl], + ); +} + +/** One collection as a card grid or a dense row table. */ +export function DataView({ + items, + view, + density, + getKey, + renderCard, + columns, + listColumns, + onRowClick, + isRowActive, + empty, + className, +}: { + items: T[]; + view: PanelViewMode; + density?: PanelDensity; + getKey: (item: T) => string; + renderCard: (item: T) => React.ReactNode; + columns: GridColumns; + listColumns: DataColumn[]; + onRowClick?: (item: T) => void; + isRowActive?: (item: T) => boolean; + empty: React.ReactNode; + className?: string; +}) { + if (items.length === 0) { + return ( +
{empty}
+ ); + } + + if (view === "grid") { + return ( +
+ {items.map((item) => ( +
{renderCard(item)}
+ ))} +
+ ); + } + + const template = listColumns.map((c) => c.width).join(" "); + const rowHeight = density === "compact" ? "h-7" : "h-9"; + + return ( +
+
+ {listColumns.map((column) => ( + + {column.header} + + ))} +
+ +
+ {items.map((item) => { + const active = isRowActive?.(item) ?? false; + return ( +
onRowClick(item) : undefined} + className={cn( + "grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0", + rowHeight, + onRowClick && "cursor-pointer", + active ? "bg-accent-brand/10" : "hover:bg-muted/40", + )} + style={{ gridTemplateColumns: template }} + > + {listColumns.map((column) => ( +
+ {column.cell(item)} +
+ ))} +
+ ); + })} +
+
+ ); +} diff --git a/src/ui/components/form-fields.tsx b/src/ui/components/form-fields.tsx new file mode 100644 index 000000000..33efb9abf --- /dev/null +++ b/src/ui/components/form-fields.tsx @@ -0,0 +1,480 @@ +/* eslint-disable react-refresh/only-export-components */ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, X } from "lucide-react"; +import { Input } from "@/components/input"; +import { PasswordInput } from "@/components/password-input"; +import { Button } from "@/components/button"; +import { SettingRow, FakeSwitch } from "@/components/section-card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; +import { cn } from "@/lib/utils"; + +/** Label + control + optional hint, the shape every field shares. */ +export function Field({ + label, + hint, + error, + aside, + htmlFor, + className, + children, +}: { + label: string; + hint?: ReactNode; + error?: string; + aside?: ReactNode; + htmlFor?: string; + className?: string; + children: ReactNode; +}) { + return ( +
+ {(label || aside) && ( +
+ + {aside && {aside}} +
+ )} + {children} + {error ? ( + {error} + ) : hint ? ( + {hint} + ) : null} +
+ ); +} + +export function TextField({ + 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; +}) { + return ( + + onChange(e.target.value)} + /> + + ); +} + +export function NumberField({ + 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; +}) { + return ( + + onChange(Number(e.target.value))} + /> + + ); +} + +/** Values the server sends in place of a stored secret. */ +export const SECRET_SENTINELS = [ + "existing_password", + "existing_key", + "existing_key_password", + "existing_sudo_password", + "existing_rdp_password", + "existing_vnc_password", + "existing_telnet_password", +]; + +export function isSecretSentinel(value: string): boolean { + return SECRET_SENTINELS.includes(value); +} + +export function SecretField({ + 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; +}) { + const { t } = useTranslation(); + const saved = isSecretSentinel(value); + return ( + + onChange(e.target.value)} + /> + + ); +} + +/** A Radix dropdown, so its list uses the theme tokens. */ +export function SelectField({ + label, + value, + onChange, + options, + hint, + error, + disabled, + className, + placeholder, +}: { + label: string; + value: T; + onChange: (value: T) => void; + options: readonly { value: T; label: string }[]; + hint?: ReactNode; + error?: string; + disabled?: boolean; + className?: string; + placeholder?: string; +}) { + // Radix treats "" as no selection, so empty options get a stand-in value. + const EMPTY = "__empty__"; + return ( + + + + ); +} + +export function TextAreaField({ + label, + value, + onChange, + placeholder, + rows = 3, + 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; +}) { + return ( + +