feat: move the classic layout into its own bundled plugin

This commit is contained in:
LukeGus committed 2026-10-18 17:25:59 -05:00
1 parent 86161d66e2
commit d049588f7f
234 files changed
+3066 -76205

No files matched your search

-1
View File
@@ -11,4 +11,3 @@ db
*.min.css
openapi.json
drizzle/
src/ui/settings/settings-search-index.ts
+2
View File
@@ -8,9 +8,11 @@
- Plugins can add layouts and themes
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
- Hovering a theme in Settings previews it
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
### Changed
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
## 26.10.2
+7
View File
@@ -28,6 +28,13 @@
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
},
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
"defaultLook": true
},
{
"id": "docker",
"source": "tmxplug",
+1 -1
View File
@@ -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-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.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-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
+2
View File
@@ -11,6 +11,8 @@ Plugin API 1.4.0.
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
### Fixed
+9 -1
View File
@@ -546,7 +546,15 @@ export function activate(app: TermixApp) {
keeps its connection. The command palette, overlays and onboarding stay in core.
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
screen where people can switch, reset, update or turn it off. `?safe-layout=1` opens it any time.
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
shipped. `?safe-layout=1` opens it any time.
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
+406
View File
@@ -1,5 +1,10 @@
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
APP_VERSION (value): string
AccessRecord (type): AccessRecord
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
ActiveSessionInfo (type): ActiveSessionInfo
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
AdminSection (type): AdminSection
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
@@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
ApiKey (type): ApiKey
AuditLog (type): AuditLog
AuditLogFilters (type): AuditLogFilters
AuthEnrollmentSections (value): () => Element
AuthOverrideProtocol (type): string
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & 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
BarSeriesProps (type): BarSeriesProps
BrandingSettings (type): BrandingSettings
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
ButtonProps (type): ButtonProps
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
CORE_NAMESPACE (value): "core"
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
@@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
ConfirmOptions (type): ConfirmOptions
ConnectionGate (value): (props: ConnectionGateProps) => Element
@@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
ConnectionStage (type): ConnectionStage
ConnectionStatus (type): ConnectionStatus
ConnectionUnavailable (type): ConnectionUnavailable
CreatedApiKey (type): CreatedApiKey
Credential (type): Credential
CredentialDensity (type): CredentialDensity
CredentialPermissionLevel (type): CredentialPermissionLevel
CredentialRowFields (type): CredentialRowFields
CredentialSidebarFilterState (type): CredentialSidebarFilterState
CredentialSidebarPreferences (type): CredentialSidebarPreferences
CredentialSortKey (type): CredentialSortKey
CredentialTrayTrigger (type): CredentialTrayTrigger
CustomKeybinding (type): CustomKeybinding
DASHBOARD_CARDS (value): DashboardCardConfig[]
DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T>
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
DesktopUpdateMode (type): DesktopUpdateMode
DesktopUpdateState (type): DesktopUpdateState
Dialog (value): ({ ...props }: DialogProps) => Element
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
@@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
DocsLinkProps (type): DocsLinkProps
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
@@ -77,11 +115,17 @@ DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: Dropd
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
EditingWidth (type): EditingWidth
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
ExportPayload (type): ExportPayload
FOLDER_COLORS (value): string[]
FONT_SIZES (value): { id: FontSizeId; label: 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
FieldGroup (type): FieldGroup
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
FolderAccessRule (type): FolderAccessRule
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
FontSizeId (type): FontSizeId
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
FullScreenAppPhase (type): FullScreenAppPhase
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
@@ -91,11 +135,33 @@ GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
Host (type): Host
HostActionDef (type): HostActionDef
HostClickBehavior (type): HostClickBehavior
HostData (type): HostData
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostDefaultsChange (type): HostDefaultsChange
HostDefaultsContext (value): Context<HostDefaultsContextValue>
HostDefaultsContextValue (type): HostDefaultsContextValue
HostDefaultsLevel (type): HostDefaultsLevel
HostDefaultsTarget (type): HostDefaultsTarget
HostDensity (type): HostDensity
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostFolder (type): HostFolder
HostGroupKey (type): HostGroupKey
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields
HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences
HostTrayTrigger (type): HostTrayTrigger
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
@@ -103,10 +169,14 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KeyCombo (type): KeyCombo
KeybindingAction (type): KeybindingAction
KeybindingActionDef (type): KeybindingActionDef
KeybindingActionType (type): string
LANGUAGES (value): readonly { code: string; label: string; }[]
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
@@ -114,7 +184,12 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
ListRowTone (type): ListRowTone
LogContext (type): LogContext
LogEntry (type): LogEntry
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
MANAGE_REQUEST_EVENT (value): "manage:request"
MAX_PANES (value): 8
MFAPromptMode (type): MFAPromptMode
ManageMode (type): ManageMode
ManageRequest (type): ManageRequest
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
@@ -122,10 +197,13 @@ Meter (value): ({ percent, className, }: { percent: number; className?: string;
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
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"; }
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"
PaletteItemDef (type): PaletteItemDef
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
PanelDensity (type): PanelDensity
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
@@ -134,18 +212,35 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
PanelViewMode (type): PanelViewMode
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
PermissionCatalogEntry (type): PermissionCatalogEntry
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
PluginSettingsErrors (type): PluginSettingsErrors
PluginSettingsField (type): PluginSettingsField
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
PluginSummary (type): PluginSummary
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
Popover (value): ({ ...props }: PopoverProps) => Element
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ProxyNode (type): ProxyNode
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup
RailItemDef (type): RailItemDef
RecentActivityItem (type): RecentActivityItem
ReleaseInfo (type): ReleaseInfo
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
ResolvedHostDefault (type): ResolvedHostDefault
RobustClipboardProvider (value): typeof RobustClipboardProvider
Role (type): Role
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
SSHHost (type): Host
SSHHostData (type): HostData
SSHHostWithStatus (type): SSHHostWithStatus
SSH_AUTH_PROTOCOL (value): "ssh"
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
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
@@ -165,6 +260,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
SharePermissionLevel (type): SharePermissionLevel
ShareTarget (type): ShareTarget
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
@@ -176,70 +274,378 @@ SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButt
ShellConfirmCapture (value): () => null
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
SortKey (type): SortKey
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
SparklineProps (type): SparklineProps
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
SplitOpenTarget (type): SplitOpenTarget
SplitSummary (type): SplitSummary
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
StatusValue (type): StatusValue
SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
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
SyncBasicAuth (type): SyncBasicAuth
SyncConflictItem (type): SyncConflictItem
SyncEntityInfo (type): SyncEntityInfo
SyncErrorItem (type): SyncErrorItem
SyncProxyHeader (type): SyncProxyHeader
SyncStatus (type): SyncStatus
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks
TabSplitAction (type): TabSplitAction
TabStage (value): ({ className }: { className?: string; }) => 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
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
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
ThemeId (type): string
TlsStatus (type): TlsStatus
ToolsTab (type): ToolsTab
Tooltip (value): ({ ...props }: TooltipProps) => Element
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
UI_AREA_KEYS (value): UiAreaKey[]
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
UiAreaKey (type): UiAreaKey
UiFontId (type): UiFontId
UiPreset (type): UiPreset
UpdateChannelState (type): UpdateChannelState
UptimeInfo (type): UptimeInfo
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
UserPreferences (type): UserPreferences
UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
actionOrder (value): (action: HostActionDef) => number
activityTarget (value): (type: string) => ActivityTarget
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
applyAccentColor (value): (colorValue: string) => void
applyFontSize (value): (id: FontSizeId) => void
applyPresetSideEffects (value): (preset: "advanced" | "simple" | "balanced") => Promise<void>
applyUiFont (value): (id: UiFontId) => void
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
buildExportPayload (value): (raw: ExportPayload, selected: Set<string>, groups: Set<FieldGroup>, withCredentials: boolean, pluginSecrets?: PluginSecretKeys) => ExportPayload
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
bulkImportSSHHosts (value): (hosts: HostData[], overwrite?: boolean, credentials?: Record<string, unknown>[]) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
canJoinSplit (value): (tab: Tab) => boolean
changeAppLanguage (value): (language: string) => Promise<string>
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
createSSHHost (value): (hostData: HostData) => Promise<Host>
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
defaultCredentialRowFields (value): () => CredentialRowFields
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
defaultHostRowFields (value): () => HostRowFields
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
deleteOpenTab (value): (instanceId: string) => Promise<void>
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
deployCredentialToHost (value): (credentialId: number, targetHostId: number) => Promise<Record<string, unknown>>
desktopUpdates (value): () => DesktopUpdatesBridge
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
endSplitDrag (value): (commit: boolean) => void
exportAllSSHHosts (value): { (): Promise<{ hosts: Host[]; }>; (options: { share: true; }): Promise<{ version: string; exportedAt: string; credentials: Record<string, unknown>[]; hosts: Host[]; }>; }
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
findCoreHostDefault (value): (key: string) => CoreHostDefault
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
generateKeyPair (value): (keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", keySize?: number, passphrase?: string) => Promise<GeneratedKeyPair>
generatePublicKeyFromPrivate (value): (privateKey: string, keyPassword?: string) => Promise<GeneratedPublicKey>
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
getAuditForwarding (value): () => Promise<AuditForwardingSettings>
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
getBasePath (value): () => string
getBranding (value): () => Promise<BrandingSettings>
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
getDefaultConnectionTab (value): (host: Host) => TabType
getDeveloperMode (value): () => Promise<DeveloperModeState>
getDeviceId (value): () => string
getErrorMessage (value): (error: unknown, fallback?: string) => string
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
getFolderDefaultsId (value): (name: string) => Promise<number>
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
getHostEditorSection (value): (id: string) => HostEditorSectionDef
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
getHostTags (value): () => Promise<string[]>
getLiveHostStatus (value): (hostId: number) => StatusValue
getLogLevel (value): () => Promise<{ level: string; }>
getNotificationPrivateEndpoints (value): () => Promise<string[]>
getPanel (value): (id: string) => PanelDef
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
getPasswordResetAllowed (value): () => Promise<boolean>
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
getPlugins (value): () => Promise<PluginSummary[]>
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
getRoles (value): () => Promise<{ roles: Role[]; }>
getSSHFolders (value): () => Promise<SSHFolder[]>
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
getSessionTimeoutMinutes (value): () => Promise<number>
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
getSyncErrors (value): () => Promise<SyncErrorItem[]>
getTabType (value): (id: string) => TabTypeDef
getTlsStatus (value): () => Promise<TlsStatus>
getUpdateChannel (value): () => Promise<UpdateChannelState>
getUptime (value): () => Promise<UptimeInfo>
getUserInfo (value): () => Promise<UserInfo>
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
getUserPreferences (value): () => Promise<UserPreferences>
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostKey (value): (host: Record<string, unknown>) => string
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
importSSHConfigHosts (value): (content: string, overwrite?: boolean) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
invalidateHostsAndStatusCaches (value): () => void
invalidateServerStatusCache (value): () => void
isCoreRailView (value): (id: string) => boolean
isElectron (value): () => boolean
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
isMacPlatform (value): () => boolean
isPersistentTabType (value): (type: string) => boolean
isPrerelease (value): (version: string) => boolean
isSessionTabType (value): (type: string) => boolean
isSplitDragging (value): () => boolean
isSplitTab (value): (tab: Tab) => tab is SplitTab
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
mapCredentials (value): (res: unknown) => Credential[]
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
markTabSurfaceUsed (value): (type: TabType) => void
maskSecrets (value): (payload: ExportPayload, pluginSecrets?: PluginSecretKeys) => ExportPayload
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
moveSplitDrag (value): (x: number, y: number) => void
normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
pluginSecretKeys (value): (summaries: { id: string; contributes?: { settings?: { host?: { fields?: { key: string; secretKeys?: string[]; }[]; }; }; }; }[]) => PluginSecretKeys
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
preloadTabSurface (value): (type: TabType) => void
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
promotableIds (value): () => string[]
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
railItemLabel (value): (id: string, t: (key: string) => string) => string
readFromClipboard (value): () => Promise<string>
recordHostActionPreference (value): (hostId: string, action: TabType) => void
refreshSyncStatus (value): () => void
registerLayout (value): (item: RegisteredLayout) => () => void
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
releaseUrlFrom (value): (info: VersionInfo) => string
rem (value): (px: number) => string
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
reportCoreIssueUrl (value): (termixVersion?: string) => string
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
requestManage (value): (request: ManageRequest) => void
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
resetRecentActivity (value): () => Promise<{ message: string; }>
resetUserSecondFactors (value): (userId: string) => Promise<void>
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number>
retrySyncErrors (value): () => Promise<SyncStatus>
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number) => Promise<void>
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
rightDockableIds (value): () => string[]
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
saveHostTags (value): (tags: string[]) => Promise<string[]>
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
setDeveloperMode (value): (enabled: boolean) => Promise<void>
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
setSyncStatus (value): (next: SyncStatus) => void
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host
syncNow (value): () => Promise<SyncStatus>
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
updateAuditForwarding (value): (url: string, token?: string) => Promise<{ url: string; hasToken: boolean; }>
updateBranding (value): (update: BrandingSettingsUpdate) => Promise<BrandingSettings>
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
updateLogLevel (value): (level: string) => Promise<void>
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
usageBarColor (value): (percent: number) => string
usageColor (value): (percent: number) => string
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
useAppTheme (value): () => ThemeProviderState
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
useBranding (value): () => BrandingContextValue
useChooseTheme (value): () => (themeId: string) => void
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
useConnectionGate (value): () => ConnectionGateController
useConnectionLog (value): () => ConnectionLogContextType
useDesktopUpdate (value): () => DesktopUpdateState
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
useHostActions (value): () => HostActionDef[]
useHostBadges (value): () => HostBadgeDef[]
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[]
useHostProtocols (value): () => HostProtocolDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useInstalledPlugins (value): () => PluginSummary[]
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
useKeybindingActions (value): () => KeybindingActionDef[]
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
useOptionalConnectionLog (value): () => ConnectionLogContextType
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
usePaletteEntries (value): () => PaletteEntryDef[]
usePaletteGroups (value): () => PaletteGroupDef[]
usePanels (value): () => PanelDef[]
usePermissions (value): () => PermissionsState
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
usePluginHostSections (value): () => PluginSummary[]
usePluginStore (value): () => StoreState
useRailItems (value): () => RailItemDef[]
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
useServerStatus (value): () => ServerStatusContextType
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
useShellController (value): () => ShellController
useSplitTargets (value): () => SplitTargetsSnapshot
useSshAuthEditors (value): () => SshAuthEditorDef[]
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
useStatusColorScheme (value): () => StatusColorScheme
useSurfaceKind (value): () => SurfaceKind
useSyncStatus (value): () => any
useTabHandlesVersion (value): () => number
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType
useTheme (value): () => ThemeProviderState
useThemeOptions (value): () => ThemeOption[]
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
useUiPreferencesContext (value): () => UiPreferencesContextValue
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
+1
View File
@@ -33,6 +33,7 @@ const OFFICIAL_IDS = [
"ai",
"alerts",
"automations",
"classic-layout",
"docker",
"file-manager",
"fleets",
+407 -1
View File
@@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [
"ACCENT_PRESET_COLORS",
"APP_VERSION",
"AccessRecord",
"ActionSlot",
"ActiveSessionInfo",
"AddButton",
"AdminSection",
"Alert",
"AlertDescription",
"AlertDialog",
@@ -35,14 +40,23 @@ const ALLOWED = [
"AlertDialogTitle",
"AlertDialogTrigger",
"AlertTitle",
"ApiKey",
"AuditLog",
"AuditLogFilters",
"AuthEnrollmentSections",
"AuthOverrideProtocol",
"BackButton",
"Badge",
"BarSeries",
"BarSeriesItem",
"BarSeriesProps",
"BrandingSettings",
"BrowserSignInDialog",
"Button",
"ButtonProps",
"CORE_HOST_DEFAULTS",
"CORE_NAMESPACE",
"CREDENTIAL_ROW_FIELD_KEYS",
"Card",
"CardAction",
"CardContent",
@@ -54,6 +68,12 @@ const ALLOWED = [
"CardTitle",
"Checkbox",
"ColumnCountStepper",
"Command",
"CommandEmpty",
"CommandGroup",
"CommandItem",
"CommandList",
"CommandPrimitive",
"ComponentSlot",
"ConfirmOptions",
"ConnectionGate",
@@ -66,9 +86,26 @@ const ALLOWED = [
"ConnectionStage",
"ConnectionStatus",
"ConnectionUnavailable",
"CreatedApiKey",
"Credential",
"CredentialDensity",
"CredentialPermissionLevel",
"CredentialRowFields",
"CredentialSidebarFilterState",
"CredentialSidebarPreferences",
"CredentialSortKey",
"CredentialTrayTrigger",
"CustomKeybinding",
"DASHBOARD_CARDS",
"DEFAULT_FOLDER_COLOR",
"DEFAULT_FOLDER_ICON",
"DashboardCardId",
"DataColumn",
"DataView",
"DefaultOverrides",
"DefaultsOnly",
"DesktopUpdateMode",
"DesktopUpdateState",
"Dialog",
"DialogClose",
"DialogContent",
@@ -81,6 +118,7 @@ const ALLOWED = [
"DialogTrigger",
"DocsLink",
"DocsLinkProps",
"DocsPage",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
@@ -98,11 +136,17 @@ const ALLOWED = [
"DropdownMenuTrigger",
"EditingWidth",
"EmptyState",
"ExportPayload",
"FOLDER_COLORS",
"FONT_SIZES",
"Facts",
"FakeSwitch",
"Field",
"FieldGroup",
"FieldPair",
"FolderAccessRule",
"FolderIconEl",
"FontSizeId",
"FormFooter",
"FullScreenAppPhase",
"FullScreenAppWrapper",
@@ -112,11 +156,33 @@ const ALLOWED = [
"GridLayout",
"GridSlot",
"GroupHeading",
"HexColorPicker",
"Host",
"HostActionDef",
"HostClickBehavior",
"HostData",
"HostDefaultBadge",
"HostDefaultField",
"HostDefaultsChange",
"HostDefaultsContext",
"HostDefaultsContextValue",
"HostDefaultsLevel",
"HostDefaultsTarget",
"HostDensity",
"HostFeatureFields",
"HostFeatureFieldsProps",
"HostFolder",
"HostGroupKey",
"HostKeyVerificationDialog",
"HostOnly",
"HostProtocolAuthInput",
"HostProtocolDef",
"HostProtocols",
"HostRowFields",
"HostSidebarFilterState",
"HostSidebarPreferences",
"HostTrayTrigger",
"IconPicker",
"InlineView",
"Input",
"Kbd",
@@ -124,10 +190,14 @@ const ALLOWED = [
"KbdSeparator",
"KeyCombo",
"KeybindingAction",
"KeybindingActionDef",
"KeybindingActionType",
"LANGUAGES",
"Label",
"LineChart",
"LineChartSeries",
"LinkServerDialog",
"LinkedAccountInfo",
"ListBadge",
"ListRow",
"ListRowAction",
@@ -135,7 +205,12 @@ const ALLOWED = [
"ListRowTone",
"LogContext",
"LogEntry",
"LookSettings",
"MANAGE_REQUEST_EVENT",
"MAX_PANES",
"MFAPromptMode",
"ManageMode",
"ManageRequest",
"ManagerCardError",
"ManagerCardShell",
"ManagerSearch",
@@ -143,11 +218,14 @@ const ALLOWED = [
"MetricCard",
"MiniStat",
"NumberField",
"OPEN_PLUGINS_EVENT",
"OpenTabRecord",
"PANEL",
"PanePrompt",
"PROMPT_BUTTON",
"PROMPT_DESTRUCTIVE_BUTTON",
"PROMPT_PRIMARY_BUTTON",
"PaletteItemDef",
"PanePrompt",
"PanelDensity",
"PanelList",
"PanelSearch",
@@ -155,18 +233,35 @@ const ALLOWED = [
"PanelViewMode",
"PassphraseDialog",
"PasswordInput",
"PermissionCatalogEntry",
"PluginComponent",
"PluginIcon",
"PluginSettingsErrors",
"PluginSettingsField",
"PluginSettingsValidationError",
"PluginSummary",
"PluginViewPlaceholder",
"Popover",
"PopoverAnchor",
"PopoverContent",
"PopoverTrigger",
"ProxyNode",
"ProxySettingsFields",
"RadialGauge",
"RadialGaugeProps",
"RailGroup",
"RailItemDef",
"RecentActivityItem",
"ReleaseInfo",
"Repeater",
"ResolvedHostDefault",
"RobustClipboardProvider",
"Role",
"SSHAuthDialog",
"SSHHost",
"SSHHostData",
"SSHHostWithStatus",
"SSH_AUTH_PROTOCOL",
"ScrollArea",
"ScrollBar",
"SecretField",
@@ -186,6 +281,9 @@ const ALLOWED = [
"SelectValue",
"Separator",
"SettingRow",
"SettingsFieldRow",
"SharePermissionLevel",
"ShareTarget",
"Sheet",
"SheetClose",
"SheetContent",
@@ -197,73 +295,381 @@ const ALLOWED = [
"ShellConfirmCapture",
"Skeleton",
"Slider",
"SortKey",
"Sparkline",
"SparklineProps",
"SplitLayoutMenu",
"SplitOpenTarget",
"SplitSummary",
"StatRow",
"StatusValue",
"SurfaceKind",
"SurfaceScope",
"Switch",
"SwitchRow",
"SyncBasicAuth",
"SyncConflictItem",
"SyncEntityInfo",
"SyncErrorItem",
"SyncProxyHeader",
"SyncStatus",
"TOTPDialog",
"Tab",
"TabShellCallbacks",
"TabSplitAction",
"TabStage",
"TabStrip",
"TabStripItem",
"TabType",
"TagInput",
"TextAreaField",
"TextField",
"Textarea",
"TextareaProps",
"ThemeId",
"TlsStatus",
"ToolsTab",
"Tooltip",
"TooltipContent",
"TooltipProvider",
"TooltipTrigger",
"UI_AREA_KEYS",
"UI_FONTS",
"UiAreaKey",
"UiFontId",
"UiPreset",
"UpdateChannelState",
"UptimeInfo",
"UsagePair",
"UserPreferences",
"UserRole",
"UserSecondFactorSummary",
"VersionBadge",
"VersionInfo",
"ViewToggle",
"WebSocketConnectionTarget",
"WidgetTitle",
"actionOrder",
"activityTarget",
"adminCreateUser",
"adminCreateUserCredential",
"adminCreateUserHost",
"adminDeleteUserCredential",
"adminDeleteUserHost",
"adminExportUserData",
"adminGetHostPassword",
"adminGetUserCredentials",
"adminGetUserHosts",
"adminResetUserPassword",
"adminUpdateUserCredential",
"adminUpdateUserHost",
"applyAccentColor",
"applyFontSize",
"applyPresetSideEffects",
"applyUiFont",
"assignRoleToUser",
"beginSplitDrag",
"buildExportPayload",
"buildOriginWsUrl",
"bulkImportSSHHosts",
"bulkUpdateSSHHosts",
"canConfigureUpdates",
"canJoinSplit",
"changeAppLanguage",
"changePassword",
"clearLocalAdaptivePreferences",
"cn",
"copyToClipboard",
"createApiKey",
"createCredential",
"createFrontendLogger",
"createQuickConnectHost",
"createRole",
"createSSHHost",
"defaultConnectAction",
"defaultCredentialRowFields",
"defaultCredentialSidebarPreferences",
"defaultHostRowFields",
"defaultHostSidebarPreferences",
"defaultValuesEqual",
"deleteAccount",
"deleteAllHostsInFolder",
"deleteApiKey",
"deleteCredential",
"deleteOpenTab",
"deleteRole",
"deleteSSHHost",
"deleteUser",
"deployCredentialToHost",
"desktopUpdates",
"docsUrl",
"duplicateCredential",
"enabledHostProtocols",
"endSplitDrag",
"exportAllSSHHosts",
"filterPaletteItems",
"findCoreHostDefault",
"findMatchingKeybinding",
"generateKeyPair",
"generatePublicKeyFromPrivate",
"getActiveSessions",
"getAdaptiveResourceBudget",
"getApiKeys",
"getAuditForwarding",
"getAuditLogActions",
"getAuditLogs",
"getBasePath",
"getBranding",
"getChannelReleases",
"getConnectedRemoteApi",
"getCredentialAccess",
"getCredentialDetails",
"getCredentialSidebarPreferences",
"getCredentials",
"getDatabaseHealth",
"getDatabaseTransferUrl",
"getDefaultConnectionTab",
"getDeveloperMode",
"getDeviceId",
"getErrorMessage",
"getExternalAutoProvision",
"getFolderAccess",
"getFolderDefaultsId",
"getHostAccess",
"getHostAuthOverride",
"getHostDefaultsLevel",
"getHostEditorSection",
"getHostPassword",
"getHostSidebarPreferences",
"getHostTags",
"getLiveHostStatus",
"getLogLevel",
"getNotificationPrivateEndpoints",
"getPanel",
"getPasswordLoginAllowed",
"getPasswordResetAllowed",
"getPermissionsCatalog",
"getPluginAdminSettings",
"getPluginHostSettings",
"getPluginUserSettings",
"getPlugins",
"getPollingEnvironmentMultiplier",
"getPreferredHostAction",
"getRecentActivity",
"getRegisteredDashboardCard",
"getRegistrationAllowed",
"getRoles",
"getSSHFolders",
"getSSHHosts",
"getSecondFactorAfterExternalLogin",
"getSessionTimeout",
"getSessionTimeoutMinutes",
"getSessions",
"getStatusCheckSettings",
"getStatusClasses",
"getSyncConflicts",
"getSyncErrors",
"getTabType",
"getTlsStatus",
"getUpdateChannel",
"getUptime",
"getUserInfo",
"getUserList",
"getUserPreferences",
"getUserRoles",
"getUserSecondFactors",
"getVersionInfo",
"globalShortcutHandler",
"groupRailItems",
"hasVisibleFields",
"hostActionsFor",
"hostBadgesFor",
"hostKey",
"hostMenuItemsFor",
"hostProtocolFlags",
"hydrateLocalSharedHostAuth",
"importSSHConfigHosts",
"invalidateHostsAndStatusCaches",
"invalidateServerStatusCache",
"isCoreRailView",
"isElectron",
"isFieldShown",
"isMacPlatform",
"isPersistentTabType",
"isPrerelease",
"isSessionTabType",
"isSplitDragging",
"isSplitTab",
"isTabJumpHotkey",
"linkExternalToPasswordAccount",
"linkedServerUrl",
"listHostActions",
"listHostProtocols",
"loadPaletteGroup",
"logoutUser",
"makeHostSshSubTabs",
"makeHostTabs",
"makeUserAdmin",
"mapCredentials",
"markAdaptiveResourceUsed",
"markTabSurfaceUsed",
"maskSecrets",
"moveRailItem",
"moveSplitDrag",
"normalizeLanguageCode",
"notifySyncChanged",
"openInSplit",
"paletteEntriesFor",
"parseCustomKeybindings",
"parseServerTime",
"pluginKey",
"pluginName",
"pluginSecretKeys",
"pluginWsUrl",
"preloadTabSurface",
"previewHostDefaultsLevel",
"promotableIds",
"protocolEnabled",
"protocolPort",
"quickConnectTargets",
"railItemLabel",
"readFromClipboard",
"recordHostActionPreference",
"refreshSyncStatus",
"registerLayout",
"registerTabCloseGuard",
"releaseUrlFrom",
"rem",
"removeAdminStatus",
"removeRoleFromUser",
"renameCredentialFolder",
"renameFolder",
"reorderCredentials",
"reorderFolders",
"reorderSSHHosts",
"reportBetaFeedbackUrl",
"reportCoreIssueUrl",
"requestFromLegacyEvent",
"requestManage",
"resetHostToDefaults",
"resetRecentActivity",
"resetUserSecondFactors",
"resolveConnectionOrigin",
"resolveHostDefaults",
"resolveHostTabType",
"resolveRemoteHostId",
"retrySyncErrors",
"revokeAllUserSessions",
"revokeCredentialAccess",
"revokeFolderAccess",
"revokeHostAccess",
"revokeSession",
"rightDockableIds",
"roleLabel",
"runAdaptiveBackgroundTask",
"runAdaptivePolling",
"runHostAction",
"runVisibleInterval",
"sanitizeCredentialSidebarPreferences",
"sanitizeHostSidebarPreferences",
"saveCredentialSidebarPreferences",
"saveHostDefaultsLevel",
"saveHostSidebarPreferences",
"saveHostTags",
"saveUserPreferences",
"setAllPluginChannels",
"setDeveloperMode",
"setHostAuthOverride",
"setNotificationPrivateEndpoints",
"setPluginOptions",
"setSyncStatus",
"setUpdateChannel",
"settleSyncConflict",
"shareCredential",
"shareFolder",
"shareHost",
"shell",
"splitDefaultKey",
"sshHostToHost",
"syncNow",
"tabIcon",
"tabJumpHotkeyKeys",
"takePendingManageRequest",
"unlinkExternalFromPasswordAccount",
"unlinkServer",
"updateAuditForwarding",
"updateBranding",
"updateCredential",
"updateExternalAutoProvision",
"updateFolderMetadata",
"updateHostAccess",
"updateLogLevel",
"updatePasswordLoginAllowed",
"updatePasswordResetAllowed",
"updatePluginAdminSettings",
"updatePluginHostSettings",
"updatePluginUserSettings",
"updateRegistrationAllowed",
"updateRole",
"updateSSHHost",
"updateSecondFactorAfterExternalLogin",
"updateSessionTimeout",
"updateStatusCheckSettings",
"updateSyncSettings",
"uploadTlsCertificate",
"usageBarColor",
"usageColor",
"useActionSlot",
"useAdaptivePolling",
"useAppTheme",
"useAreaPreferences",
"useBranding",
"useChooseTheme",
"useConfirm",
"useConnectionGate",
"useConnectionLog",
"useDesktopUpdate",
"useEditorDefaultKeys",
"useHostActions",
"useHostBadges",
"useHostContextMenuItems",
"useHostEditorSections",
"useHostProtocols",
"useHostStatus",
"useInstalledPlugins",
"useIsDefaultsEditor",
"useIsMobile",
"useKeybindingActions",
"useKeybindingDefaults",
"useOptionalConnectionLog",
"usePageVisibleInterval",
"usePaletteEntries",
"usePaletteGroups",
"usePanels",
"usePermissions",
"usePluginDocsUrl",
"usePluginHostSections",
"usePluginStore",
"useRailItems",
"useRegisteredDashboardCards",
"useServerStatus",
"useServerStatusMeta",
"useShellController",
"useSplitTargets",
"useSshAuthEditors",
"useSshAuthProviders",
"useStatusColorScheme",
"useSurfaceKind",
"useSyncStatus",
"useTabHandlesVersion",
"useTabs",
"useTabsSafe",
"useTheme",
"useThemeOptions",
"useUiPreference",
"useUiPreferencesContext",
"waitForDefaultsJob",
"withProtocolSettings",
];
/** Every name a module exports, resolved the way the compiler sees it. */
-176
View File
@@ -1,176 +0,0 @@
#!/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 flatKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, value]) =>
typeof value === "object"
? flatKeys(value, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}
const EN_KEYS = new Set(
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
);
// t("x", { count }) reads x_one or x_other, so index the plural key.
function realKey(key) {
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
? key
: `${key}_other`;
}
function keysIn(text) {
return [
...new Set(
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(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}<AccordionSection\b/.test(line)) starts.push(i);
});
const out = {};
starts.forEach((start, k) => {
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": [
...new Set([
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
...keysIn(read("AdminPluginDeveloperMode.tsx")),
]),
],
"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")),
updates: [
...new Set(
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", file),
"utf8",
),
),
),
),
],
};
}
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<string, string[]> = {\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)}`);
}
+57
View File
@@ -2281,6 +2281,63 @@ router.post(
},
);
/**
* @openapi
* /plugins/{id}/restore-bundled:
* post:
* summary: Restore the bundled copy of a plugin
* description: >
* Drops any update downloaded over a plugin that ships with Termix and
* loads the copy from the image again, so a broken release can be rolled
* back from the layout repair view. The shipped version is pinned so
* auto update does not bring the release back. Dependents restart.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The restored version and the plugin's state.
* 400:
* description: Invalid plugin id.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: The plugin has no bundled copy, or a linked server manages it.
*/
router.post(
"/:id/restore-bundled",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
if (!ID_PATTERN.test(pluginId)) {
res.status(400).json({ error: "Invalid plugin id" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
const result = await restoreBundledPlugin(pluginId);
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to restore the bundled copy",
"plugin_restore_bundled",
);
}
},
);
/**
* @openapi
* /plugins/{id}:
+76 -23
View File
@@ -1644,41 +1644,94 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
if (prepare) await prepare(previous);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
* dev runner.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
"This plugin has no bundled copy",
409,
"NOT_BUNDLED",
"NO_BUNDLED_COPY",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
});
}
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
restoreBundledPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
@@ -1141,6 +1142,35 @@ describe("plugins route", () => {
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("restores the bundled copy", async () => {
manageMock.restoreBundledPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
version: "1.0.0",
state: "active",
});
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
});
it("answers 409 when a plugin has no bundled copy", async () => {
manageMock.restoreBundledPlugin.mockRejectedValue(
new PluginManageError(
"This plugin has no bundled copy",
409,
"NO_BUNDLED_COPY",
),
);
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
+48
View File
@@ -661,6 +661,54 @@ describe("reloading a bundled plugin for the dev runner", () => {
});
});
describe("restoring the bundled copy", () => {
it("drops a downloaded update, loads the shipped copy and pins it", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
// What a signed update leaves behind next to the shipped copy.
const artifact = path.join(data, "plugins", "docker.tmxplug");
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, "update");
fs.writeFileSync(`${artifact}.sig`, "sig");
const result = await manage.restoreBundledPlugin("docker");
expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" });
expect(loader.get("docker")?.manifest.version).toBe("1.0.0");
expect(fs.existsSync(artifact)).toBe(false);
expect(fs.existsSync(`${artifact}.sig`)).toBe(false);
expect(db.rows.get("docker")).toMatchObject({
pinnedVersion: "1.0.0",
autoUpdate: false,
});
});
it("keeps a plugin that is turned off stopped", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
await manage.setPluginState("docker", false);
const result = await manage.restoreBundledPlugin("docker");
expect(result.state).toBe("stopped");
expect(loader.get("docker")?.state).toBe("loaded");
});
it("refuses a plugin that has no bundled copy", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
await boot();
await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({
code: "NO_BUNDLED_COPY",
status: 409,
});
});
it("refuses a bad id", async () => {
await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({
code: "INVALID_ID",
status: 400,
});
});
});
describe("consent", () => {
it("refuses an install without the consented list, before any download", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
+9
View File
@@ -519,6 +519,15 @@ export async function uninstallPlugin(pluginId: string): Promise<void> {
}
}
/** Puts a bundled plugin back to the copy that shipped with this install. */
export async function restoreBundledPlugin(pluginId: string): Promise<void> {
try {
await rbacApi.post(`${pluginPath(pluginId)}/restore-bundled`);
} finally {
announcePluginsChanged();
}
}
export async function previewPluginState(
pluginId: string,
enabled: boolean,
File diff suppressed because it is too large. Load diff
-448
View File
@@ -1,448 +0,0 @@
import { useIsMobile } from "@/hooks/use-mobile";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
KeyRound,
Plus,
Server,
Settings,
User,
} from "lucide-react";
import { ComponentSlot } from "@/shell/ActionSlot";
import { VersionBadge } from "@/components/version-badge";
import { Facts } from "@/components/panel-layout";
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
import { activityTarget } from "@/lib/activity-types";
import {
defaultConnectAction,
hostActionsFor,
listHostActions,
} from "@/sidebar/host-contributions";
import {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
import type { RecentActivityItem } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import { quarterBorders } from "./quarter-borders";
import { parseServerTime } from "@/lib/server-time";
export type VersionStatus =
"up_to_date" | "requires_update" | "beta" | "unknown";
function Stat({
label,
aside,
children,
}: {
label: string;
/** Sits beside the label, like the release channel. */
aside?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
{children}
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
{aside}
</div>
</div>
);
}
export function StatsStrip({
hosts,
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
}: {
hosts: Host[];
uptimeFormatted: string;
versionText: string;
versionStatus: VersionStatus;
releaseUrl: string;
dbHealth: "healthy" | "error";
}) {
const { t } = useTranslation();
const online = hosts.filter((h) => h.status === "online").length;
const big = "text-2xl font-bold leading-none tracking-tight";
return (
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
<Stat
label={t("dashboard.version")}
aside={
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="shrink-0 px-1 py-px text-[9px]"
/>
}
>
<span className={`${big} truncate`} title={versionText || undefined}>
{versionText || "-"}
</span>
</Stat>
<Stat label={t("dashboard.uptime")}>
<span className={big}>{uptimeFormatted || "-"}</span>
</Stat>
<Stat label={t("dashboard.database")}>
<span
className={`${big} ${dbHealth === "healthy" ? "text-accent-brand" : "text-destructive"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</span>
</Stat>
<Stat label={t("dashboardTab.hostsAvailable")}>
<div className="flex items-baseline gap-1">
<span className={big}>{online}</span>
<span className="text-sm leading-none text-muted-foreground">
/{hosts.length}
</span>
</div>
</Stat>
</div>
);
}
export function CountersStrip({
hosts,
credentialCount,
onOpenSingletonTab,
}: {
hosts: Host[];
credentialCount: number;
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) {
const { t } = useTranslation();
const item =
"flex items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50";
return (
<div className="grid h-full auto-cols-fr grid-flow-col divide-x divide-border">
<button
type="button"
onClick={() => onOpenSingletonTab("host-manager")}
className={item}
>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xl font-bold leading-none tracking-tight">
{hosts.length}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t("dashboard.totalHosts")}
</span>
</button>
<button
type="button"
onClick={() =>
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
}
className={item}
>
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xl font-bold leading-none tracking-tight">
{credentialCount}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t("dashboard.totalCredentials")}
</span>
</button>
<ComponentSlot slotId="dashboard.counters" />
</div>
);
}
function ActionButton({
icon: Icon,
label,
hint,
onClick,
className,
}: {
icon: React.ElementType;
label: string;
hint: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`group/btn flex h-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 ${className ?? ""}`}
>
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
<Icon className="size-3 text-accent-brand" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium">{label}</span>
<span className="truncate text-[10px] text-muted-foreground">
{hint}
</span>
</span>
</button>
);
}
export function QuickActions({
onOpenSingletonTab,
hosts,
onOpenTab,
isAdmin,
}: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
isAdmin: boolean;
}) {
const { t } = useTranslation();
const pinnedHosts = hosts.filter((h) => h.pin).slice(0, 4);
const endpoint = (host: Host) => {
const protocol = host.enableSsh ? undefined : enabledHostProtocols(host)[0];
const port = host.enableSsh
? host.sshPort
: protocol
? protocolPort(host.pluginSettings, protocol)
: host.port;
return `${host.ip}:${port ?? host.port}`;
};
const actions = [
{
icon: Plus,
label: t("dashboard.addHost"),
hint: t("dashboardTab.registerNewServer"),
onClick: () =>
onOpenSingletonTab("host-manager", "host-manager:add-host"),
},
{
icon: KeyRound,
label: t("dashboard.addCredential"),
hint: t("dashboardTab.storeSshKeysOrPasswords"),
onClick: () =>
onOpenSingletonTab("host-manager", "host-manager:add-credential"),
},
...(isAdmin
? [
{
icon: Settings,
label: t("dashboard.adminSettings"),
hint: t("dashboardTab.manageUsersAndRoles"),
onClick: () => onOpenSingletonTab("admin-settings"),
},
]
: []),
{
icon: User,
label: t("dashboard.userProfile"),
hint: t("dashboardTab.manageYourAccount"),
onClick: () => onOpenSingletonTab("user-profile"),
},
];
return (
<div className="flex min-h-full flex-1 flex-col">
<div className="grid flex-1 auto-rows-fr grid-cols-1 sm:grid-cols-2">
{actions.map((action, i) => (
<ActionButton
key={action.label}
{...action}
className={quarterBorders(i, actions.length)}
/>
))}
</div>
{pinnedHosts.length > 0 && (
<div className="flex flex-col border-t border-border">
{pinnedHosts.map((host) => {
const Icon =
defaultConnectAction(listHostActions(), host)?.icon ?? Server;
return (
<button
key={host.id}
type="button"
onClick={() => {
const type = getDefaultConnectionTab(host);
if (type) onOpenTab(host, type);
}}
className="flex items-center gap-2 border-b border-border/60 px-3 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
>
<Icon className="size-3 shrink-0 text-accent-brand" />
<span className="truncate text-xs font-medium">
{host.name || host.ip}
</span>
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">
{endpoint(host)}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
export function HostStatusList({
hosts,
onOpenTab,
statusLoading,
}: {
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
statusLoading?: boolean;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
// A phone has no room for the metric bars next to the host name.
const isMobile = useIsMobile();
if (hosts.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
{t("dashboardTab.noHostsConfigured")}
</div>
);
}
return (
<div className="flex flex-col">
{hosts.map((host) => {
const availability = host.status ?? "offline";
return (
<div
key={host.id}
role="button"
tabIndex={0}
onClick={() => {
// An overview action (a metrics view) wins over connecting.
const actions = hostActionsFor(listHostActions(), host);
const target =
actions.find((action) => action.overview)?.tabType ??
defaultConnectAction(actions, host)?.tabType;
if (target) onOpenTab(host, target);
}}
className="group/row flex min-w-0 cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
>
<span
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(availability, scheme, "dot", statusLoading)}`}
/>
<span className="min-w-0 flex-1">
<Facts className="min-w-0">
<span
className="truncate text-xs font-medium"
title={host.name}
>
{host.name}
</span>
<span
className="truncate font-mono text-[10px] text-muted-foreground"
title={host.ip}
>
{host.ip}
</span>
</Facts>
</span>
{/* Plugins add live details to a host row here. */}
{!isMobile && (
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
host,
online: availability === "online",
}}
/>
)}
<span
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
>
{statusLoading || availability === "unknown"
? t("dashboardTab.checking")
: availability === "online"
? t("hosts.status.online")
: t("hosts.status.offline")}
</span>
<ExternalLink className="size-2.5 shrink-0 text-muted-foreground/0 transition-colors group-hover/row:text-muted-foreground/60" />
</div>
);
})}
</div>
);
}
export function RecentActivityList({
activity,
hosts,
onOpenTab,
statusLoading,
}: {
activity: RecentActivityItem[];
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
statusLoading?: boolean;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const ago = (ts: string) => {
const diff = Math.floor(
(Date.now() - parseServerTime(ts).getTime()) / 1000,
);
if (diff < 60) return t("dashboard.justNow");
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
};
if (activity.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
{t("dashboard.noRecentActivity")}
</div>
);
}
return (
<div className="flex flex-col">
{activity.map((item) => {
const host = hosts.find((h) => h.id === String(item.hostId));
const target = activityTarget(item.type);
const Icon = target?.icon ?? Server;
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
if (host && target) onOpenTab(host, target.tab);
}}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
>
<span
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(host?.status ?? false, scheme, "dot", statusLoading)}`}
/>
<Facts className="min-w-0">
<span
className="truncate text-xs font-medium"
title={item.hostName}
>
{item.hostName}
</span>
<span className="flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground">
<Icon className="size-2.5 shrink-0" />
<span className="truncate">
{target?.labelKey
? t(target.labelKey)
: item.type.replace("_", " ")}
</span>
</span>
</Facts>
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
{ago(item.timestamp)}
</span>
</div>
);
})}
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Lines only between cells, so the actions read as quarters: one column on
* small screens, two above. An odd last cell spans the full row.
*/
export function quarterBorders(index: number, count: number): string {
const classes = ["border-border"];
const last = index === count - 1;
if (!last) classes.push("border-b");
if (Math.floor(index / 2) === Math.floor((count - 1) / 2)) {
classes.push("sm:border-b-0");
}
if (index % 2 === 0 && !last) classes.push("sm:border-r");
if (last && count % 2 === 1) classes.push("sm:col-span-2");
return classes.join(" ");
}
+3
View File
@@ -0,0 +1,3 @@
/** The version of this build, empty in a dev build. */
export const APP_VERSION: string | undefined =
import.meta.env.VITE_APP_VERSION || undefined;
+7 -3
View File
@@ -1,4 +1,8 @@
import type { TFunction } from "i18next";
/** The part of a translate function these helpers use. */
export type Translator = (
key: string,
options?: Record<string, unknown>,
) => string;
/**
* Where a plugin's i18n keys resolve from.
@@ -28,7 +32,7 @@ export function pluginKey(
* listed in a registry, shows the manifest name.
*/
export function pluginName(
t: TFunction,
t: Translator,
plugin: { id: string; name: string },
): string {
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
@@ -36,7 +40,7 @@ export function pluginName(
/** Same as pluginName, for "plugin.description". */
export function pluginDescription(
t: TFunction,
t: Translator,
plugin: { id: string; description?: string | null },
): string {
return t(pluginKey(plugin.id, "plugin.description"), {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { TFunction } from "i18next";
import type { Translator } from "./plugin-i18n";
/** System roles store an i18n key as their display name. */
export function roleLabel<T extends string | null | undefined>(
t: TFunction,
t: Translator,
displayName: T,
): T | string {
return typeof displayName === "string" &&
+36 -855
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File renamed without changes.
+24 -47
View File
@@ -2,16 +2,15 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Check,
Copy,
Download,
LayoutTemplate,
Palette,
History,
Power,
RefreshCw,
RotateCcw,
Settings,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
@@ -23,11 +22,8 @@ import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
import { usePluginsManager } from "@/plugins/use-plugins-manager";
import { PluginIconBox } from "@/plugins/plugin-bits";
import { getPluginRecord } from "@/plugin-host/plugin-store";
import {
ShellConfirmCapture,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
import { TabStage } from "@/shell/stage/TabStage";
import { restoreBundledPlugin } from "@/api/plugins-api";
import { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
import {
chooseDefaultLook,
chooseLayout,
@@ -50,10 +46,12 @@ function AdminTools({
status,
installOpen,
setInstallOpen,
onRetry,
}: {
status: RepairStatus;
installOpen: boolean;
setInstallOpen: (open: boolean) => void;
onRetry: () => void;
}) {
const { t } = useTranslation();
const manager = usePluginsManager();
@@ -94,6 +92,21 @@ function AdminTools({
{t("looks.repair.disable", { name: entry.name })}
</Button>
)}
{entry?.installed && entry.bundled && !entry.managedByServer && (
<Button
size="sm"
variant="outline"
onClick={() => {
void restoreBundledPlugin(entry.id)
.then(onRetry)
.catch(() => toast.error(t("looks.repair.restoreFailed")));
}}
disabled={manager.busy.has(entry.id)}
>
<History />
{t("looks.repair.restore", { name: entry.name })}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
<Download />
{t("looks.repair.install")}
@@ -162,7 +175,6 @@ export function RepairView({
onRetry: () => void;
}) {
const { t } = useTranslation();
const shell = useShellController();
const { isAdmin } = usePermissions();
const { theme } = useTheme();
const chooseTheme = useChooseTheme();
@@ -170,12 +182,13 @@ export function RepairView({
const themes = useThemes();
const [copied, setCopied] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
const layoutName =
layouts.find((layout) => layout.id === status.layoutId) ?? null;
const name = layoutName ? layoutLabel(layoutName, t) : status.layoutId;
const name = layoutName
? layoutLabel(layoutName, t)
: status.layoutId || t("looks.repair.noLayout");
const details = [
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
status.pluginId
@@ -214,30 +227,6 @@ export function RepairView({
chooseDefaultLook({ layout: null, theme: null }),
);
if (settingsOpen) {
return (
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-2">
<Button
size="sm"
variant="ghost"
onClick={() => setSettingsOpen(false)}
>
<ArrowLeft />
{t("looks.repair.back")}
</Button>
</div>
<SurfaceScope kind="tab" className="overflow-hidden">
<ShellConfirmCapture />
<TabStage />
</SurfaceScope>
</div>
);
}
const safe = status.reason === "safe-url" || status.reason === "safe-env";
return (
@@ -298,24 +287,12 @@ export function RepairView({
{t("looks.repair.resetEveryone")}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
shell.tabs.openSingleton("settings", undefined, undefined, {
section: "appearance",
});
setSettingsOpen(true);
}}
>
<Settings />
{t("looks.repair.settings")}
</Button>
{isAdmin && (
<AdminTools
status={status}
installOpen={installOpen}
setInstallOpen={setInstallOpen}
onRetry={onRetry}
/>
)}
</div>
+7 -20
View File
@@ -2,13 +2,10 @@ import { useSyncExternalStore, type ComponentType } from "react";
import type { TFunction } from "i18next";
import { createRegistry } from "@/lib/registry";
/** The layout core ships with, used when nothing else is chosen or installed. */
export const BUILTIN_LAYOUT_ID = "default";
/** A layout someone can choose, known from a manifest before its code loads. */
export interface LayoutDescriptor {
id: string;
/** i18n key: core's for the built-in, the plugin namespace's for plugins. */
/** i18n key in the plugin namespace, or plain text. */
label: string;
description?: string;
defaultTheme?: string;
@@ -16,7 +13,7 @@ export interface LayoutDescriptor {
pluginId?: string;
}
/** A layout whose code is running, from app.registerLayout or core. */
/** A layout whose code is running, from app.registerLayout. */
export interface RegisteredLayout {
id: string;
pluginId?: string;
@@ -26,13 +23,7 @@ export interface RegisteredLayout {
defaultTheme?: string;
}
const BUILTIN_DESCRIPTOR: LayoutDescriptor = {
id: BUILTIN_LAYOUT_ID,
label: "looks.builtinLayout",
};
let pluginLayouts: LayoutDescriptor[] = [];
let descriptorSnapshot: LayoutDescriptor[] = [BUILTIN_DESCRIPTOR];
let descriptorSnapshot: LayoutDescriptor[] = [];
const descriptorListeners = new Set<() => void>();
const registered = createRegistry<RegisteredLayout>();
@@ -42,7 +33,7 @@ export const getRegisteredLayout = registered.get;
export const useRegisteredLayouts = registered.useList;
export const resetRegisteredLayouts = registered.reset;
/** Every layout that can be chosen: the built-in, then plugin layouts. */
/** Every layout that can be chosen, from the installed plugins. */
export function listLayouts(): LayoutDescriptor[] {
return descriptorSnapshot;
}
@@ -57,12 +48,8 @@ export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
JSON.stringify(
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
);
if (key(layouts) === key(pluginLayouts)) return;
pluginLayouts = layouts;
descriptorSnapshot = [
BUILTIN_DESCRIPTOR,
...layouts.filter((layout) => layout.id !== BUILTIN_LAYOUT_ID),
];
if (key(layouts) === key(descriptorSnapshot)) return;
descriptorSnapshot = layouts;
for (const listener of descriptorListeners) listener();
}
@@ -78,7 +65,7 @@ export function useLayouts(): LayoutDescriptor[] {
}
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
if (!layout.pluginId) return t(layout.label);
if (!layout.pluginId) return layout.label;
return t(`${layout.pluginId}:${layout.label}`, {
defaultValue: layout.label,
});
+5 -10
View File
@@ -1,25 +1,20 @@
import type { LookResponse } from "@/api/look-api";
import { BUILTIN_LAYOUT_ID } from "./layout-registry";
/** The theme a fresh install starts in when nothing else says otherwise. */
export const FALLBACK_THEME = "dark";
/**
* The layout to show: the user's choice, else the admin default, else the
* default look, else core's built-in. Without an answer from the server the
* last layout this browser showed stands in.
* default look. Empty when none of them names one, which the repair view
* answers. Without an answer from the server the last layout this browser
* showed stands in.
*/
export function resolveLayoutId(
look: LookResponse | null,
lastShown: string | null = null,
): string {
if (!look) return lastShown ?? BUILTIN_LAYOUT_ID;
return (
look.layoutId ??
look.defaults.layout ??
look.defaultLook.layout ??
BUILTIN_LAYOUT_ID
);
if (!look) return lastShown ?? "";
return look.layoutId ?? look.defaults.layout ?? look.defaultLook.layout ?? "";
}
/**
-411
View File
@@ -1,411 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
import { TabStrip } from "@/components/tab-strip";
import { EmptyState } from "@/components/empty-state";
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
import { cn } from "@/lib/utils";
/** True while the editor is searched, so every lazy section mounts. */
const SearchingContext = createContext(false);
export interface EditorNavItem {
id: string;
label: string;
icon?: ReactNode;
/** Marks a section holding something that needs fixing. */
error?: boolean;
}
export interface EditorNavBand {
id: string;
label: string;
items: EditorNavItem[];
}
/**
* The element that really scrolls: the editor's own area, or the page around
* it when the editor is embedded somewhere that scrolls itself.
*/
function scroller(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" || overflow === "scroll") &&
node.scrollHeight > node.clientHeight + 1
) {
return node;
}
node = node.parentElement;
}
return el;
}
/** Which section is in view, and a way to jump to one. */
export function useScrollSpy(
containerRef: RefObject<HTMLElement | null>,
ids: string[],
): { active: string; scrollTo: (id: string) => void } {
const [active, setActive] = useState(ids[0] ?? "");
const idsKey = ids.join("|");
// A jump scrolls past every section between; the spy waits it out.
const lockUntil = useRef(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onScroll = () => {
if (Date.now() < lockUntil.current) return;
const area = scroller(container);
const top = area.getBoundingClientRect().top;
let current = ids[0] ?? "";
for (const id of ids) {
const el = container.querySelector<HTMLElement>(
`[data-section="${id}"]`,
);
if (!el) continue;
if (el.getBoundingClientRect().top - top <= 24) current = id;
}
if (
area.scrollHeight > area.clientHeight &&
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
) {
current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
onScroll();
// Captured on the document so an outer scroller is heard too.
document.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
return () =>
document.removeEventListener("scroll", onScroll, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerRef, idsKey]);
return {
active: ids.includes(active) ? active : (ids[0] ?? ""),
scrollTo: (id: string) => {
const container = containerRef.current;
const el = container?.querySelector<HTMLElement>(
`[data-section="${id}"]`,
);
if (!container || !el) return;
lockUntil.current = Date.now() + 600;
setActive(id);
const area = scroller(container);
const target = () =>
el.getBoundingClientRect().top -
area.getBoundingClientRect().top +
area.scrollTop -
8;
let lastTop = target();
let lastHeight = area.scrollHeight;
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
// Sections above can still be mounting, which moves the target or
// cuts the scroll short. Follow it until the scroll lands and the
// layout stops changing, unless the user scrolls first.
const started = Date.now();
let changedAt = started;
let userScrolled = false;
const onUser = () => {
userScrolled = true;
};
area.addEventListener("wheel", onUser, { passive: true });
area.addEventListener("touchstart", onUser, { passive: true });
const done = () => {
area.removeEventListener("wheel", onUser);
area.removeEventListener("touchstart", onUser);
};
const follow = () => {
const now = Date.now();
if (userScrolled || !el.isConnected || now - started > 5000) {
return done();
}
const top = target();
// Keep the spy quiet for the whole trip, not just the first scroll.
lockUntil.current = now + 600;
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
lastTop = top;
lastHeight = area.scrollHeight;
changedAt = now;
area.scrollTo?.({ top, behavior: "smooth" });
}
const reachable = Math.min(
lastTop,
area.scrollHeight - area.clientHeight,
);
if (
Math.abs(area.scrollTop - reachable) <= 2 &&
now - changedAt > 400
) {
setActive(id);
return done();
}
requestAnimationFrame(follow);
};
requestAnimationFrame(follow);
},
};
}
/** Resolves once the element's height holds still for a few frames. */
function untilLayoutSettles(el: HTMLElement | null, max = 800): Promise<void> {
return new Promise((resolve) => {
if (!el || typeof requestAnimationFrame === "undefined") return resolve();
const started = Date.now();
let last = -1;
let still = 0;
const check = () => {
const height = el.scrollHeight;
still = height === last ? still + 1 : 0;
last = height;
if (still >= 3 || Date.now() - started > max) return resolve();
requestAnimationFrame(check);
};
requestAnimationFrame(check);
});
}
/** Wraps a section so the nav can find it. */
export function EditorSection({
id,
label,
showHeading = true,
children,
}: {
id: string;
label?: string;
showHeading?: boolean;
children: ReactNode;
}) {
return (
<section data-section={id} className={`flex flex-col ${PANEL.gap}`}>
{showHeading && label && <GroupHeading title={label} className="pt-1" />}
{children}
</section>
);
}
/**
* Mounts its children the first time they scroll near view, so a long editor
* does not start every section's requests at once.
*/
export function LazySection({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const searching = useContext(SearchingContext);
const [shown, setShown] = useState(
() => typeof IntersectionObserver === "undefined",
);
useEffect(() => {
if (searching) setShown(true);
}, [searching]);
useLayoutEffect(() => {
if (shown) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShown(true);
observer.disconnect();
}
},
{ rootMargin: "400px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [shown]);
return (
<div ref={ref} className={shown ? "contents" : "min-h-24"}>
{shown && children}
</div>
);
}
/**
* An editor laid out like the settings screen: a section nav beside one
* scrolling form, and the actions pinned under it.
*/
export function EditorPane({
bands,
banner,
footer,
errorCount = 0,
revealRef,
children,
className,
}: {
bands: EditorNavBand[];
/** Filled with a function that scrolls a section into view. */
revealRef?: React.MutableRefObject<((id: string) => void) | null>;
/** Sits above the form, inside the scroll area. */
banner?: ReactNode;
/** Usually Cancel and Save, pinned at the bottom. */
footer?: ReactNode;
/** Shown beside the footer so a problem in a hidden section is not missed. */
errorCount?: number;
children: ReactNode;
className?: string;
}) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const searching = query.trim().length > 0;
const onFiltered = useCallback((root: HTMLElement) => {
const next = new Set<string>();
for (const el of root.querySelectorAll<HTMLElement>(
`[data-section][${HIDDEN_ATTR}]`,
)) {
if (el.dataset.section) next.add(el.dataset.section);
}
setHiddenIds((prev) =>
prev.size === next.size && [...next].every((id) => prev.has(id))
? prev
: next,
);
}, []);
const anyShown = useSettingsPageFilter(
formRef,
query,
"editor",
"[data-section]",
onFiltered,
);
const visibleBands = bands
.map((band) => ({
...band,
items: band.items.filter((item) => !hiddenIds.has(item.id)),
}))
.filter((band) => band.items.length > 0);
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
const showNav = bands.flatMap((band) => band.items).length > 1;
// A jump mounts every lazy section first and scrolls once the form stops
// growing. Mounting them on the way down made the scroll stall and restart.
const [mountAll, setMountAll] = useState(false);
const jump = (id: string) => {
if (mountAll) return scrollTo(id);
setMountAll(true);
void untilLayoutSettles(scrollRef.current).then(() => scrollTo(id));
};
if (revealRef) revealRef.current = jump;
return (
<div className={cn("flex min-h-0 flex-1", className)}>
{showNav && (
<nav
aria-label={t("manage.sections")}
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
>
{visibleBands.map((band) => (
<div key={band.id} className="flex flex-col">
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
{band.items.map((item) => {
const on = item.id === active;
return (
<button
key={item.id}
type="button"
aria-current={on ? "true" : undefined}
onClick={() => jump(item.id)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item.icon && (
<span className="flex shrink-0 [&_svg]:size-3.5">
{item.icon}
</span>
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
{item.error && (
<span
title={t("manage.sectionNeedsAttention")}
className="ml-auto size-1.5 shrink-0 rounded-full bg-destructive"
/>
)}
</button>
);
})}
</div>
))}
</nav>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("manage.searchSettings")}
fill
/>
</div>
</div>
{showNav && (
<div className="shrink-0 border-b border-border px-1 lg:hidden">
<TabStrip
tabs={visibleBands.flatMap((band) =>
band.items.map((item) => ({ id: item.id, label: item.label })),
)}
activeTab={active}
onTabChange={jump}
/>
</div>
)}
<div
ref={scrollRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!anyShown && (
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
)}
<div
ref={formRef}
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
{banner}
<SearchingContext.Provider value={searching || mountAll}>
{children}
</SearchingContext.Provider>
</div>
</div>
{(footer || errorCount > 0) && (
<div className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2">
{errorCount > 0 && (
<span className="text-[10px] text-destructive">
{t("manage.needsAttention", { count: errorCount })}
</span>
)}
<div className="ml-auto flex items-center gap-2">{footer}</div>
</div>
)}
</div>
</div>
);
}
-467
View File
@@ -1,467 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
Globe,
KeyRound,
Plus,
Server,
SlidersHorizontal,
User,
} from "lucide-react";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { PanelSearch } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
import type { Credential, Host } from "@/types/ui-types";
import { defaultsRows, type DefaultsRow } from "./defaults-rows";
import {
allFolderPaths,
allParentIds,
buildCredentialManageTree,
buildHostManageTree,
flattenTree,
type ManageRow,
} from "./manage-tree";
import type { ManageMode } from "./manage-requests";
import {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
function indent(depth: number) {
return 6 + depth * 13;
}
/** Rules marking each open ancestor, so deep rows still read as nested. */
function Guides({ depth }: { depth: number }) {
if (depth === 0) return null;
return (
<>
{Array.from({ length: depth }, (_, i) => (
<span
key={i}
aria-hidden
className="absolute inset-y-0 w-px bg-border"
style={{ left: rem(indent(i) + 7) }}
/>
))}
</>
);
}
const rowFocus =
"focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring";
/** The list column of the Manage tab. */
export function ManageList({
editing,
mode,
onMode,
hosts,
credentials,
canEditInstanceDefaults,
isOnline,
selectedKey,
onPickHost,
onPickCredential,
onPickDefaults,
onAdd,
}: {
editing: boolean;
mode: ManageMode;
onMode: (mode: ManageMode) => void;
hosts: Host[];
credentials: Credential[];
canEditInstanceDefaults: boolean;
isOnline: (host: Host) => boolean;
/** "h:<id>", "c:<id>" or a defaults row key. */
selectedKey: string | null;
onPickHost: (host: Host) => void;
onPickCredential: (credential: Credential) => void;
onPickDefaults: (row: DefaultsRow) => void;
onAdd: () => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const tree = useMemo(
() =>
mode === "credentials"
? buildCredentialManageTree(credentials)
: buildHostManageTree(hosts, isOnline),
[mode, hosts, credentials, isOnline],
);
// Everything starts open; closing something is remembered while mounted.
const [open, setOpen] = useState<Set<string>>(new Set());
useEffect(() => {
setOpen(new Set([...allFolderPaths(tree), ...allParentIds(tree)]));
// Rebuilt only when the side changes, so toggles survive an edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
const toggle = (key: string) =>
setOpen((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const rows = useMemo(
() => (mode === "defaults" ? [] : flattenTree(tree, open, needle)),
[mode, tree, open, needle],
);
const defaults = useMemo(
() =>
defaultsRows(hosts, canEditInstanceDefaults, t).filter(
(row) => !needle || row.label.toLowerCase().includes(needle),
),
[hosts, canEditInstanceDefaults, t, needle],
);
const pick = (id: string) => {
if (mode === "hosts") {
const host = hosts.find((h) => h.id === id);
if (host) onPickHost(host);
} else {
const credential = credentials.find((c) => c.id === id);
if (credential) onPickCredential(credential);
}
};
const empty = mode === "defaults" ? defaults.length === 0 : rows.length === 0;
return (
<div
className={cn(
editing ? "hidden md:flex" : "flex",
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
)}
>
<nav
aria-label={t("manage.sections")}
className="flex shrink-0 flex-col border-b border-border py-1"
>
{(
[
{
value: "hosts",
label: t("nav.hosts"),
icon: Server,
count: hosts.length,
},
{
value: "credentials",
label: t("nav.credentials"),
icon: KeyRound,
count: credentials.length,
},
{
value: "defaults",
label: t("manage.defaults"),
icon: SlidersHorizontal,
},
] as {
value: ManageMode;
label: string;
icon: typeof Server;
count?: number;
}[]
).map((item) => {
const active = item.value === mode;
const Icon = item.icon;
return (
<button
key={item.value}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onMode(item.value)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
rowFocus,
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{item.label}
</span>
{item.count !== undefined && (
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
{item.count}
</span>
)}
</button>
);
})}
</nav>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<div className="flex items-center gap-2">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={
mode === "hosts"
? t("manage.searchHosts")
: mode === "credentials"
? t("manage.searchCredentials")
: t("manage.searchDefaults")
}
fill
/>
{mode !== "defaults" && (
<Button
variant="outline"
size="icon"
title={
mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")
}
aria-label={
mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")
}
onClick={onAdd}
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
<Plus className="size-3.5" />
</Button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1 thin-scrollbar">
{empty ? (
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={needle ? t("manage.nothingMatches") : t("manage.nothingYet")}
hint={
needle
? undefined
: mode === "hosts"
? t("manage.emptyHostsHint")
: t("manage.emptyCredentialsHint")
}
/>
) : mode === "defaults" ? (
defaults.map((row) => {
const Icon =
row.level === "admin"
? Globe
: row.level === "user"
? User
: Folder;
const selected = selectedKey === row.key;
return (
<button
key={row.key}
type="button"
onClick={() => onPickDefaults(row)}
className={cn(
"flex w-full items-center gap-2 border-l-2 px-2.5 py-1.5 text-left transition-colors",
rowFocus,
selected
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-foreground hover:bg-muted",
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
!selected && "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{row.label}
</span>
<SlidersHorizontal className="size-3 shrink-0 text-muted-foreground/60" />
</button>
);
})
) : (
rows.map((row) =>
row.kind === "folder" ? (
<FolderRow
key={`f:${row.folder.path}`}
row={row}
showOnline={mode === "hosts"}
open={!!needle || open.has(row.folder.path)}
locked={!!needle}
onToggle={() => toggle(row.folder.path)}
/>
) : (
<ItemRow
key={`i:${row.item.id}`}
row={row}
isHost={mode === "hosts"}
selected={
selectedKey ===
`${mode === "hosts" ? "h" : "c"}:${row.item.id}`
}
open={!!needle || open.has(row.item.id)}
locked={!!needle}
onToggle={() => toggle(row.item.id)}
onPick={() => pick(row.item.id)}
/>
),
)
)}
</div>
</div>
);
}
function FolderRow({
row,
open,
locked,
showOnline,
onToggle,
}: {
row: Extract<ManageRow, { kind: "folder" }>;
open: boolean;
/** Search forces folders open, so the chevron stops responding. */
locked: boolean;
showOnline: boolean;
onToggle: () => void;
}) {
const Icon = open ? FolderOpen : Folder;
return (
<div className="relative">
<Guides depth={row.depth} />
<button
type="button"
aria-expanded={open}
onClick={locked ? undefined : onToggle}
style={{ paddingLeft: rem(indent(row.depth)) }}
className={cn(
"group flex w-full items-center gap-1.5 py-1.5 pr-2.5 text-left transition-colors hover:bg-muted",
rowFocus,
)}
>
{open ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
)}
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{row.folder.name}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">
{showOnline ? `${row.online}/${row.count}` : row.count}
</span>
</button>
</div>
);
}
function ItemRow({
row,
isHost,
selected,
open,
locked,
onToggle,
onPick,
}: {
row: Extract<ManageRow, { kind: "item" }>;
isHost: boolean;
selected: boolean;
open: boolean;
locked: boolean;
onToggle: () => void;
onPick: () => void;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const nests = row.item.children.length > 0;
const Icon = isHost ? Server : KeyRound;
return (
<div className="relative">
<Guides depth={row.depth} />
<div
className={cn(
"flex items-center border-l-2 transition-colors",
selected
? "border-accent-brand bg-accent-brand/10"
: "border-transparent hover:bg-muted",
)}
>
<div
className="flex shrink-0 items-center justify-center"
style={{ paddingLeft: rem(indent(row.depth)) }}
>
{nests ? (
<button
type="button"
onClick={locked ? undefined : onToggle}
aria-expanded={open}
title={open ? t("manage.collapse") : t("manage.expand")}
aria-label={open ? t("manage.collapse") : t("manage.expand")}
className={cn(
"flex size-4 items-center justify-center text-muted-foreground hover:text-foreground",
rowFocus,
)}
>
{open ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
) : (
<span aria-hidden className="size-4" />
)}
</div>
<button
type="button"
onClick={onPick}
aria-current={selected ? "true" : undefined}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 py-1.5 pl-1 pr-2.5 text-left transition-colors",
rowFocus,
selected ? "text-accent-brand" : "text-foreground",
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
!selected && "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{row.item.name}
</span>
<span className="shrink-0 truncate font-mono text-[10px] text-muted-foreground">
{row.item.note}
</span>
{isHost && (
<span
aria-hidden
title={row.item.online ? t("common.online") : t("common.offline")}
className={cn(
"size-1.5 shrink-0 rounded-full",
getStatusClasses(!!row.item.online, scheme, "dot"),
)}
/>
)}
</button>
</div>
</div>
);
}
-588
View File
@@ -1,588 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, LibraryBig, Plus, Server, Trash2 } from "lucide-react";
import { toast } from "sonner";
import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { FakeSwitch } from "@/components/section-card";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useConfirm } from "@/components/surface/surface-scope";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { usePermissions } from "@/hooks/use-permissions";
import { useServerStatus } from "@/lib/ServerStatusContext";
import {
deleteCredential,
deleteSSHHost,
getCredentialDetails,
getCredentials,
getSSHHosts,
type SSHHostWithStatus,
} from "@/main-axios";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
import { registerTabCloseGuard } from "@/shell/tab-close-guards";
import { HostEditor } from "@/sidebar/HostEditor";
import { CredentialEditorView } from "@/sidebar/CredentialEditorView";
import {
HostDefaultsEditorView,
hostDefaultsTitle,
type HostDefaultsEditorTarget,
} from "@/sidebar/host-defaults/HostDefaultsEditorView";
import { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
import {
hostProtocolFlags,
type HostProtocols,
} from "@/sidebar/host-protocols";
import { canDeleteHost } from "@/sidebar/host-permissions";
import type { Credential, Host } from "@/types/ui-types";
import { ManageList } from "./ManageList";
import type { DefaultsRow } from "./defaults-rows";
import {
MANAGE_REQUEST_EVENT,
takePendingManageRequest,
type ManageMode,
type ManageRequest,
} from "./manage-requests";
import { docsUrl } from "@/lib/docs";
type Editing =
| {
kind: "host";
host: Host | null;
draft?: HostDraft;
key: number;
protocols: HostProtocols;
}
| { kind: "credential"; credential: Credential | null; key: number }
| { kind: "defaults"; target: HostDefaultsEditorTarget; rowKey: string };
const TAB_ID = "host-manager";
/** Fetches a credential with its secrets marked as stored, for the editor. */
async function loadCredentialForEdit(cred: Credential): Promise<Credential> {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
return {
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
};
} catch {
return cred;
}
}
/**
* Hosts, credentials and host defaults, edited at full width: a list on the
* left, the thing you picked on the right.
*/
export function ManageTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const { has } = usePermissions();
const { statuses } = useServerStatus();
const presetMode = useUiPreference("hostEditor", "mode");
const [mode, setMode] = useState<ManageMode>("hosts");
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [editing, setEditing] = useState<Editing | null>(null);
const [dirty, setDirty] = useState(false);
// Starts from the preset and stays where the user puts it this session.
const [advanced, setAdvanced] = useState(presetMode !== "simple");
// The host a credential was opened from, so Back returns to it.
const [returnHost, setReturnHost] = useState<Host | "new" | null>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const credentialsRef = useRef(credentials);
credentialsRef.current = credentials;
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
const reloadHosts = useCallback(() => {
return getSSHHosts()
.then((raw) => {
const list = raw.map(sshHostToHost);
hostsRef.current = list;
setHosts(list);
})
.catch(() => {});
}, []);
const reloadCredentials = useCallback(() => {
return getCredentials()
.then((res) => {
const list = mapCredentials(res);
credentialsRef.current = list;
setCredentials(list);
})
.catch(() => {});
}, []);
const loaded = useRef<Promise<unknown>>(Promise.resolve());
useEffect(() => {
loaded.current = Promise.all([reloadHosts(), reloadCredentials()]);
}, [reloadHosts, reloadCredentials]);
useEffect(() => {
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("hosts:refresh", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener("hosts:refresh", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, [reloadHosts, reloadCredentials]);
/** Asks before throwing away unsaved changes. True means go ahead. */
const confirmDiscard = useCallback(async () => {
if (!dirtyRef.current) return true;
return confirm({
title: t("common.unsavedChanges"),
description: t("hosts.unsavedChangesDescription"),
confirmLabel: t("hosts.discardChanges"),
cancelLabel: t("hosts.keepEditing"),
});
}, [confirm, t]);
useEffect(
() => registerTabCloseGuard(TAB_ID, confirmDiscard),
[confirmDiscard],
);
const openHost = useCallback((host: Host | null, draft?: HostDraft) => {
setMode("hosts");
setReturnHost(null);
setDirty(false);
setEditing({
kind: "host",
host,
draft,
key: Date.now(),
protocols: hostProtocolFlags(host),
});
}, []);
const openCredential = useCallback(async (credential: Credential | null) => {
setMode("credentials");
setDirty(false);
const full = credential ? await loadCredentialForEdit(credential) : null;
setEditing({ kind: "credential", credential: full, key: Date.now() });
}, []);
const openDefaults = useCallback(
async (row: Pick<DefaultsRow, "level" | "folderName">) => {
setMode("defaults");
setReturnHost(null);
setDirty(false);
const rowKey =
row.level === "folder" ? `folder:${row.folderName}` : row.level;
if (row.level === "folder" && row.folderName) {
try {
const folderId = await getFolderDefaultsId(row.folderName);
setEditing({
kind: "defaults",
rowKey,
target: { level: "folder", folderId, folderName: row.folderName },
});
} catch {
toast.error(t("hostDefaults.loadFailed"));
}
return;
}
setEditing({ kind: "defaults", rowKey, target: { level: row.level } });
},
[t],
);
const apply = useCallback(
async (request: ManageRequest | null) => {
if (!request) return;
if (!(await confirmDiscard())) return;
await loaded.current;
if (request.kind === "host") {
const find = () =>
hostsRef.current.find((h) => h.id === request.hostId) ?? null;
let host = request.hostId ? find() : null;
// Our list can lag the sidebar's, so refetch once before giving up.
if (request.hostId && !host) {
invalidateHostsAndStatusCaches();
await reloadHosts();
host = find();
if (!host) {
toast.error(t("manage.hostNotFound"));
return;
}
}
openHost(host, request.draft);
} else if (request.kind === "credential") {
const find = () =>
credentialsRef.current.find(
(c) => String(c.id) === String(request.credentialId),
) ?? null;
let credential = request.credentialId ? find() : null;
if (request.credentialId && !credential) {
await reloadCredentials();
credential = find();
}
void openCredential(credential);
} else if (request.kind === "defaults") {
void openDefaults(request);
} else {
setDirty(false);
setEditing(null);
setMode(request.mode);
}
},
[
confirmDiscard,
openHost,
openCredential,
openDefaults,
reloadHosts,
reloadCredentials,
t,
],
);
// The request that opened this tab was parked before it mounted.
useEffect(() => {
void apply(takePendingManageRequest());
const onRequest = () => void apply(takePendingManageRequest());
window.addEventListener(MANAGE_REQUEST_EVENT, onRequest);
return () => window.removeEventListener(MANAGE_REQUEST_EVENT, onRequest);
}, [apply]);
const guarded =
<A extends unknown[]>(fn: (...args: A) => void) =>
async (...args: A) => {
if (await confirmDiscard()) fn(...args);
};
const close = () => {
setDirty(false);
setEditing(null);
};
const backFromCredential = () => {
if (returnHost) {
const host = returnHost;
setReturnHost(null);
openHost(host === "new" ? null : host);
return;
}
close();
};
const editCredentialFromHost = async (credentialId: string) => {
const cred = credentials.find((c) => String(c.id) === String(credentialId));
if (!cred || editing?.kind !== "host") return;
if (!(await confirmDiscard())) return;
setReturnHost(editing.host ?? "new");
await openCredential(cred);
};
const deleteCurrent = async () => {
if (editing?.kind === "host" && editing.host) {
const host = editing.host;
const ok = await confirm({
title: t("hosts.deleteHostConfirm", { name: host.name || host.ip }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteSSHHost(Number(host.id));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.deletedCount", { count: 1 }));
close();
} catch {
toast.error(t("hosts.failedToDeleteCount", { count: 1 }));
}
} else if (editing?.kind === "credential" && editing.credential) {
const cred = editing.credential;
const ok = await confirm({
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.deletedCredential", { name: cred.name }));
close();
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
}
};
const isOnline = useCallback(
(host: Host) => statuses.get(Number(host.id))?.status === "online",
[statuses],
);
const existingFolders = useMemo(
() =>
[
...new Set(
credentials.map((c) => c.folder).filter((f): f is string => !!f),
),
].sort(),
[credentials],
);
const selectedKey = !editing
? null
: editing.kind === "host"
? editing.host
? `h:${editing.host.id}`
: null
: editing.kind === "credential"
? editing.credential
? `c:${editing.credential.id}`
: null
: editing.rowKey;
const status = !editing
? mode === "hosts"
? t("manage.hostCount", { count: hosts.length })
: mode === "credentials"
? t("manage.credentialCount", { count: credentials.length })
: t("manage.defaults")
: editing.kind === "host"
? editing.host
? editing.host.name || editing.host.ip
: t("manage.newHost")
: editing.kind === "credential"
? editing.credential
? editing.credential.name
: t("manage.newCredential")
: hostDefaultsTitle(editing.target, t);
const canDelete =
(editing?.kind === "host" &&
!!editing.host &&
canDeleteHost(editing.host)) ||
(editing?.kind === "credential" && !!editing.credential);
const renderEditor = () => {
if (!editing) return null;
if (editing.kind === "defaults") {
return (
<HostDefaultsEditorView
key={editing.rowKey}
target={editing.target}
onClose={guarded(close)}
onDirtyChange={setDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
if (editing.kind === "credential") {
return (
<CredentialEditorView
key={`c:${editing.credential?.id ?? "new"}:${editing.key}`}
credential={editing.credential}
existingFolders={existingFolders}
saveAsNewHost={returnHost ?? undefined}
onBack={backFromCredential}
onSave={(saved, options) => {
const id = String((saved as { id: number | string }).id);
void reloadCredentials();
if (options?.assignToHost && returnHost) {
const host = returnHost;
setReturnHost(null);
setDirty(false);
setMode("hosts");
const next =
host === "new" ? null : { ...host, credentialId: id };
setEditing({
kind: "host",
host: next,
key: Date.now(),
protocols: hostProtocolFlags(next),
});
return;
}
if (returnHost) {
backFromCredential();
return;
}
close();
}}
/>
);
}
return (
<HostEditor
key={`h:${editing.host?.id ?? "new"}:${editing.key}`}
host={editing.host}
draft={editing.host ? undefined : editing.draft}
advanced={advanced}
onBack={guarded(close)}
onSave={(saved) => {
const updated = sshHostToHost(saved as unknown as SSHHostWithStatus);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx < 0) return [...prev, updated];
const next = [...prev];
next[idx] = updated;
return next;
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
close();
}}
protocols={editing.protocols}
onProtocolChange={(p) =>
setEditing((current) =>
current?.kind === "host"
? { ...current, protocols: { ...current.protocols, ...p } }
: current,
)
}
onDirtyChange={setDirty}
hosts={hosts}
credentials={credentials}
onEditCredential={(id) => void editCredentialFromHost(id)}
/>
);
};
return (
<PanelShell
icon={<LibraryBig className="size-4" />}
title={t("nav.manage")}
status={status}
scroll={false}
docs={docsUrl(
mode === "credentials"
? "credentials"
: mode === "hosts"
? "hosts"
: "hostDefaults",
)}
leading={
editing ? (
<span className="flex h-full md:hidden">
<BackButton
onClick={() =>
void guarded(
editing.kind === "credential" ? backFromCredential : close,
)()
}
/>
</span>
) : undefined
}
actions={
<>
{editing?.kind === "host" && (
<label className="flex items-center gap-2 px-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("manage.advanced")}
</span>
<FakeSwitch checked={advanced} onChange={setAdvanced} />
</label>
)}
{canDelete && (
<Button
variant="ghost"
size="icon-sm"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={() => void deleteCurrent()}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
)}
</>
}
>
<div className="flex min-h-0 flex-1">
<ManageList
editing={!!editing}
mode={mode}
onMode={(next) =>
void guarded(() => {
setMode(next);
close();
})()
}
hosts={hosts}
credentials={credentials}
canEditInstanceDefaults={has("admin.settings.manage")}
isOnline={isOnline}
selectedKey={selectedKey}
onPickHost={(host) => void guarded(() => openHost(host))()}
onPickCredential={(cred) =>
void guarded(() => void openCredential(cred))()
}
onPickDefaults={(row) => void guarded(() => void openDefaults(row))()}
onAdd={() =>
void guarded(() =>
mode === "credentials"
? void openCredential(null)
: openHost(null),
)()
}
/>
{editing ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{renderEditor()}
</div>
) : (
<div className="hidden min-h-0 flex-1 items-center justify-center md:flex">
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={
mode === "hosts"
? t("manage.pickHost")
: mode === "credentials"
? t("manage.pickCredential")
: t("manage.pickDefaults")
}
hint={t("manage.pickHint")}
action={
mode !== "defaults" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
mode === "hosts"
? openHost(null)
: void openCredential(null)
}
>
<Plus className="mr-1 size-3" />
{mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")}
</Button>
) : undefined
}
/>
</div>
)}
</div>
</PanelShell>
);
}
-43
View File
@@ -1,43 +0,0 @@
import type { Host } from "@/types/ui-types";
import type { HostDefaultsLevel } from "@/types/host-defaults";
export interface DefaultsRow {
key: string;
level: HostDefaultsLevel;
folderName?: string;
label: string;
}
/** The levels of host defaults a user can open: instance, their own, folders. */
export function defaultsRows(
hosts: Host[],
canEditInstance: boolean,
t: (key: string, options?: Record<string, unknown>) => string,
): DefaultsRow[] {
const rows: DefaultsRow[] = [];
if (canEditInstance)
rows.push({
key: "admin",
level: "admin",
label: t("hostDefaults.titleAdmin"),
});
rows.push({ key: "user", level: "user", label: t("hostDefaults.titleUser") });
const folders = new Set<string>();
for (const host of hosts) {
if (!host.folder) continue;
let path = "";
for (const part of host.folder.split(" / ")) {
path = path ? `${path} / ${part}` : part;
folders.add(path);
}
}
for (const folder of [...folders].sort((a, b) => a.localeCompare(b))) {
rows.push({
key: `folder:${folder}`,
level: "folder",
folderName: folder,
label: folder,
});
}
return rows;
}
-21
View File
@@ -1,21 +0,0 @@
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
/** Field keys mapped to an i18n key saying what is wrong. */
export type HostFormErrors = Partial<Record<"ip" | "sshPort", string>>;
function validPort(port: unknown): boolean {
const n = Number(port);
return Number.isInteger(n) && n >= 1 && n <= 65535;
}
/** What has to be fixed before a host can be saved. */
export function validateHostForm(
form: Pick<HostEditorForm, "ip" | "sshPort">,
protocols: Pick<HostProtocols, "enableSsh">,
): HostFormErrors {
const errors: HostFormErrors = {};
if (!form.ip.trim()) errors.ip = "manage.errorAddressRequired";
if (protocols.enableSsh && !validPort(form.sshPort))
errors.sshPort = "manage.errorPortRange";
return errors;
}
-256
View File
@@ -1,256 +0,0 @@
import type { Credential, Host } from "@/types/ui-types";
/**
* The Manage list as a tree: folders from the " / " path, sub-hosts under
* their parent host.
*/
export type ManageItem = {
id: string;
name: string;
/** IP for a host, username for a credential. */
note: string;
/** Extra search text, e.g. tags. */
search: string;
online?: boolean;
children: ManageItem[];
};
export type ManageFolder = {
/** Full " / " path, unique across the tree. */
path: string;
/** Last segment, what the row shows. */
name: string;
folders: ManageFolder[];
items: ManageItem[];
};
export type ManageRow =
| {
kind: "folder";
depth: number;
folder: ManageFolder;
count: number;
online: number;
}
| { kind: "item"; depth: number; item: ManageItem; folderPath: string };
function emptyFolder(path: string, name: string): ManageFolder {
return { path, name, folders: [], items: [] };
}
/** Walks to the folder at `path`, creating each missing segment on the way. */
function folderAt(
root: ManageFolder,
index: Map<string, ManageFolder>,
path: string,
) {
if (!path) return root;
const existing = index.get(path);
if (existing) return existing;
let current = root;
let accumulated = "";
for (const part of path.split(" / ")) {
const trimmed = part.trim();
if (!trimmed) continue;
accumulated = accumulated ? `${accumulated} / ${trimmed}` : trimmed;
let next = index.get(accumulated);
if (!next) {
next = emptyFolder(accumulated, trimmed);
index.set(accumulated, next);
current.folders.push(next);
}
current = next;
}
return current;
}
export function buildHostManageTree(
hosts: Host[],
isOnline: (host: Host) => boolean = (host) => host.online,
): ManageFolder {
const root = emptyFolder("", "");
const index = new Map<string, ManageFolder>();
const toItem = (host: Host): ManageItem => ({
id: host.id,
name: host.name || host.ip,
note: host.ip,
search: `${host.username} ${(host.tags ?? []).join(" ")}`,
online: isOnline(host),
children: [],
});
const items = new Map(hosts.map((h) => [h.id, toItem(h)]));
// A parent that is gone or would close a loop drops the host back to its
// folder, the same as buildHostTree.
const byId = new Map(hosts.map((h) => [h.id, h]));
const isDescendant = (candidate: string, ancestor: string) => {
const seen = new Set<string>();
let current: string | null | undefined = candidate;
while (current) {
if (current === ancestor) return true;
if (seen.has(current)) return false;
seen.add(current);
current = byId.get(current)?.parentHostId;
}
return false;
};
const nested = new Set<string>();
for (const host of hosts) {
if (!host.parentHostId) continue;
const parent = items.get(host.parentHostId);
if (!parent) continue;
if (isDescendant(host.parentHostId, host.id)) continue;
parent.children.push(items.get(host.id)!);
nested.add(host.id);
}
for (const host of hosts) {
if (nested.has(host.id)) continue;
folderAt(root, index, host.folder ?? "").items.push(items.get(host.id)!);
}
return sortFolder(root);
}
export function buildCredentialManageTree(
credentials: Credential[],
): ManageFolder {
const root = emptyFolder("", "");
const index = new Map<string, ManageFolder>();
for (const cred of credentials) {
folderAt(root, index, cred.folder ?? "").items.push({
id: cred.id,
name: cred.name,
note: cred.username,
search: `${cred.type} ${(cred.tags ?? []).join(" ")}`,
children: [],
});
}
return sortFolder(root);
}
function sortFolder(folder: ManageFolder): ManageFolder {
folder.folders.sort((a, b) => a.name.localeCompare(b.name));
folder.items.sort((a, b) => a.name.localeCompare(b.name));
folder.folders.forEach(sortFolder);
return folder;
}
function itemMatches(item: ManageItem, needle: string): boolean {
if (`${item.name} ${item.note} ${item.search}`.toLowerCase().includes(needle))
return true;
return item.children.some((child) => itemMatches(child, needle));
}
/** Total items in a folder and its subfolders, plus how many are online. */
export function folderCounts(folder: ManageFolder): {
count: number;
online: number;
} {
let count = 0;
let online = 0;
const walk = (item: ManageItem) => {
count++;
if (item.online) online++;
item.children.forEach(walk);
};
folder.items.forEach(walk);
for (const child of folder.folders) {
const sub = folderCounts(child);
count += sub.count;
online += sub.online;
}
return { count, online };
}
/** The rows actually drawn. While searching, folders holding a match open on their own. */
export function flattenTree(
folder: ManageFolder,
open: Set<string>,
needle: string,
depth = 0,
rows: ManageRow[] = [],
): ManageRow[] {
for (const child of folder.folders) {
const counts = folderCounts(child);
const matching = filterFolder(child, needle);
if (!matching) continue;
rows.push({
kind: "folder",
depth,
folder: child,
count: counts.count,
online: counts.online,
});
if (needle || open.has(child.path))
flattenTree(matching, open, needle, depth + 1, rows);
}
for (const item of folder.items) {
if (needle && !itemMatches(item, needle)) continue;
pushItem(item, folder.path, depth, open, needle, rows);
}
return rows;
}
function pushItem(
item: ManageItem,
folderPath: string,
depth: number,
open: Set<string>,
needle: string,
rows: ManageRow[],
) {
rows.push({ kind: "item", depth, item, folderPath });
if (item.children.length === 0) return;
if (!needle && !open.has(item.id)) return;
for (const child of item.children) {
if (needle && !itemMatches(child, needle)) continue;
pushItem(child, folderPath, depth + 1, open, needle, rows);
}
}
/** Drops folders with nothing matching, so search never leaves empty headers. */
function filterFolder(
folder: ManageFolder,
needle: string,
): ManageFolder | null {
if (!needle) return folder;
const folders = folder.folders
.map((child) => filterFolder(child, needle))
.filter((child): child is ManageFolder => child !== null);
const items = folder.items.filter((item) => itemMatches(item, needle));
if (folders.length === 0 && items.length === 0) return null;
return { ...folder, folders, items };
}
/** Every folder path in the tree, for expand-all and the default open set. */
export function allFolderPaths(
folder: ManageFolder,
out: string[] = [],
): string[] {
for (const child of folder.folders) {
out.push(child.path);
allFolderPaths(child, out);
}
return out;
}
/** Every parent host id, so sub-hosts can start expanded too. */
export function allParentIds(
folder: ManageFolder,
out: string[] = [],
): string[] {
const walk = (item: ManageItem) => {
if (item.children.length > 0) out.push(item.id);
item.children.forEach(walk);
};
folder.items.forEach(walk);
folder.folders.forEach((child) => allParentIds(child, out));
return out;
}
+494
View File
@@ -353,3 +353,497 @@ export {
type ConnectionGateProps,
} from "@/components/connection/ConnectionGate";
export { type ConnectionUnavailable } from "@/components/connection/ConnectionScreen";
// For shell-level plugins such as a layout: the API client, shared types and
// the shell's own state. These reach into core, so they may change with a core
// release. An ordinary plugin should not need them.
export { APP_VERSION } from "@/lib/app-version";
// The palette's own input has to share the Command it sits in, so cmdk is
// the shell's copy.
export { Command as CommandPrimitive } from "cmdk";
export {
buildExportPayload,
hostKey,
maskSecrets,
pluginSecretKeys,
type ExportPayload,
type FieldGroup,
} from "@/sidebar/host-export-payload";
export {
type AuthOverrideProtocol,
SSH_AUTH_PROTOCOL,
} from "@/types/auth-protocols";
export {
CREDENTIAL_ROW_FIELD_KEYS,
type CredentialDensity,
type CredentialRowFields,
type CredentialSidebarFilterState,
type CredentialSidebarPreferences,
type CredentialSortKey,
type CredentialTrayTrigger,
defaultCredentialRowFields,
defaultCredentialSidebarPreferences,
sanitizeCredentialSidebarPreferences,
} from "@/types/credential-sidebar-preferences";
export {
CORE_HOST_DEFAULTS,
CORE_NAMESPACE,
type DefaultOverrides,
defaultValuesEqual,
findCoreHostDefault,
type HostDefaultsLevel,
type ResolvedHostDefault,
splitDefaultKey,
} from "@/types/host-defaults";
export {
defaultHostRowFields,
defaultHostSidebarPreferences,
type HostClickBehavior,
type HostDensity,
type HostGroupKey,
type HostRowFields,
type HostSidebarFilterState,
type HostSidebarPreferences,
type HostTrayTrigger,
sanitizeHostSidebarPreferences,
type SortKey,
} from "@/types/host-sidebar-preferences";
export type {
HostData,
HostProtocolAuthInput,
ProxyNode,
SSHHost,
SSHHostData,
} from "@/types";
export {
UI_AREA_KEYS,
type UiAreaKey,
type UiPreset,
} from "@/types/ui-preferences";
export type {
AdminSection,
Credential,
DashboardCardId,
FontSizeId,
Host,
HostFolder,
SharePermissionLevel,
Tab,
TabType,
ThemeId,
ToolsTab,
UiFontId,
} from "@/types/ui-types";
export {
type AuditLog,
type AuditLogFilters,
getAuditForwarding,
getAuditLogActions,
getAuditLogs,
updateAuditForwarding,
} from "@/api/audit-log-api";
export {
getUserSecondFactors,
resetUserSecondFactors,
type UserSecondFactorSummary,
} from "@/api/auth-methods-api";
export { getCredentials } from "@/api/credentials-api";
export {
getFolderDefaultsId,
getHostDefaultsLevel,
type HostDefaultsChange,
type HostDefaultsTarget,
previewHostDefaultsLevel,
resetHostToDefaults,
resolveHostDefaults,
saveHostDefaultsLevel,
waitForDefaultsJob,
} from "@/api/host-defaults-api";
export { getHostTags, saveHostTags } from "@/api/host-tags-api";
export {
getSessionTimeoutMinutes,
getUserPreferences,
parseCustomKeybindings,
saveUserPreferences,
} from "@/api/open-tabs-api";
export {
getDeveloperMode,
getPluginAdminSettings,
getPluginHostSettings,
getPlugins,
getPluginUserSettings,
type PluginSettingsErrors,
type PluginSettingsField,
PluginSettingsValidationError,
type PluginSummary,
setAllPluginChannels,
setDeveloperMode,
setPluginOptions,
updatePluginAdminSettings,
updatePluginHostSettings,
updatePluginUserSettings,
} from "@/api/plugins-api";
export {
getNotificationPrivateEndpoints,
setNotificationPrivateEndpoints,
} from "@/api/private-endpoints-api";
export {
type CredentialPermissionLevel,
type FolderAccessRule,
getCredentialAccess,
getFolderAccess,
getRoles,
revokeCredentialAccess,
revokeFolderAccess,
shareCredential,
type ShareTarget,
} from "@/api/rbac-api";
export {
type BrandingSettings,
getBranding,
updateBranding,
} from "@/api/settings-api";
export {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
syncNow,
type SyncProxyHeader,
type SyncStatus,
unlinkServer,
updateSyncSettings,
} from "@/api/sync-api";
export {
getChannelReleases,
getUpdateChannel,
type ReleaseInfo,
setUpdateChannel,
type UpdateChannelState,
} from "@/api/system-status-api";
export {
getTlsStatus,
type TlsStatus,
uploadTlsCertificate,
} from "@/api/tls-api";
export {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/command";
export { usePluginDocsUrl } from "@/components/docs-link";
export {
DEFAULT_FOLDER_COLOR,
DEFAULT_FOLDER_ICON,
FolderIconEl,
HexColorPicker,
IconPicker,
} from "@/components/folder-style";
export { useTheme } from "@/components/theme-provider";
export { VersionBadge } from "@/components/version-badge";
export { useBranding } from "@/contexts/BrandingContext";
export {
useAreaPreferences,
useUiPreference,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
export {
getRegisteredDashboardCard,
useRegisteredDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
export { useActionSlot } from "@/hooks/use-action-slot";
export { useInstalledPlugins } from "@/hooks/use-installed-plugins";
export { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
export { usePermissions } from "@/hooks/use-permissions";
export {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
export { useSyncStatus } from "@/hooks/use-sync-status";
export { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
export { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
export { LANGUAGES } from "@/i18n/languages";
export {
getLiveHostStatus,
useHostStatus,
useServerStatus,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
export { activityTarget } from "@/lib/activity-types";
export { applyPresetSideEffects } from "@/lib/apply-ui-preset";
export { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
export {
canConfigureUpdates,
type DesktopUpdateMode,
desktopUpdates,
type DesktopUpdateState,
useDesktopUpdate,
} from "@/lib/desktop-updates";
export { type DocsPage, docsUrl } from "@/lib/docs";
export { getErrorMessage } from "@/lib/error-message";
export {
getDefaultConnectionTab,
resolveHostTabType,
} from "@/lib/host-connection-tabs";
export {
DefaultsOnly,
HostDefaultsContext,
type HostDefaultsContextValue,
HostOnly,
} from "@/lib/host-defaults-context";
export {
invalidateHostsAndStatusCaches,
invalidateServerStatusCache,
} from "@/lib/hosts-request-cache";
export { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
export { notifySyncChanged } from "@/lib/linked-server";
export {
clearLocalAdaptivePreferences,
getPreferredHostAction,
recordHostActionPreference,
} from "@/lib/local-adaptive-preferences";
export { pluginKey, pluginName } from "@/lib/plugin-i18n";
export { PluginIcon } from "@/lib/plugin-icon";
export { rem } from "@/lib/rem";
export { getConnectedRemoteApi } from "@/lib/remote-server-api";
export { roleLabel } from "@/lib/role-label";
export type { StatusValue } from "@/lib/server-status-store";
export { parseServerTime } from "@/lib/server-time";
export { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
export { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
export {
ACCENT_PRESET_COLORS,
applyAccentColor,
applyFontSize,
applyUiFont,
DASHBOARD_CARDS,
FONT_SIZES,
UI_FONTS,
} from "@/lib/theme";
export { registerLayout } from "@/look/layout-registry";
export { useChooseTheme } from "@/look/use-look-theme";
export {
type AccessRecord,
type ActiveSessionInfo,
adminCreateUser,
adminCreateUserCredential,
adminCreateUserHost,
adminDeleteUserCredential,
adminDeleteUserHost,
adminExportUserData,
adminGetHostPassword,
adminGetUserCredentials,
adminGetUserHosts,
adminResetUserPassword,
adminUpdateUserCredential,
adminUpdateUserHost,
type ApiKey,
assignRoleToUser,
bulkImportSSHHosts,
bulkUpdateSSHHosts,
changePassword,
createApiKey,
createCredential,
type CreatedApiKey,
createRole,
createSSHHost,
deleteAccount,
deleteAllHostsInFolder,
deleteApiKey,
deleteCredential,
deleteOpenTab,
deleteRole,
deleteSSHHost,
deleteUser,
deployCredentialToHost,
duplicateCredential,
exportAllSSHHosts,
generateKeyPair,
generatePublicKeyFromPrivate,
getActiveSessions,
getApiKeys,
getCredentialDetails,
getCredentialSidebarPreferences,
getExternalAutoProvision,
getHostAccess,
getHostAuthOverride,
getHostPassword,
getHostSidebarPreferences,
getLogLevel,
getPasswordLoginAllowed,
getPasswordResetAllowed,
getPermissionsCatalog,
getRegistrationAllowed,
getSecondFactorAfterExternalLogin,
getSessions,
getSessionTimeout,
getSSHFolders,
getSSHHosts,
getStatusCheckSettings,
getUserInfo,
getUserList,
getUserRoles,
importSSHConfigHosts,
type LinkedAccountInfo,
linkExternalToPasswordAccount,
logoutUser,
makeUserAdmin,
type OpenTabRecord,
type PermissionCatalogEntry,
releaseUrlFrom,
removeAdminStatus,
removeRoleFromUser,
renameCredentialFolder,
renameFolder,
reorderCredentials,
reorderFolders,
reorderSSHHosts,
resetRecentActivity,
revokeAllUserSessions,
revokeHostAccess,
revokeSession,
type Role,
saveCredentialSidebarPreferences,
saveHostSidebarPreferences,
setHostAuthOverride,
shareFolder,
shareHost,
type SSHHostWithStatus,
unlinkExternalFromPasswordAccount,
updateCredential,
updateExternalAutoProvision,
updateFolderMetadata,
updateHostAccess,
updateLogLevel,
updatePasswordLoginAllowed,
updatePasswordResetAllowed,
updateRegistrationAllowed,
updateRole,
updateSecondFactorAfterExternalLogin,
updateSessionTimeout,
updateSSHHost,
updateStatusCheckSettings,
type UserPreferences,
type UserRole,
} from "@/main-axios";
export {
MANAGE_REQUEST_EVENT,
type ManageMode,
type ManageRequest,
requestFromLegacyEvent,
requestManage,
takePendingManageRequest,
} from "@/manage/manage-requests";
export { useSshAuthEditors } from "@/plugin-host/auth-registry";
export { usePluginStore } from "@/plugin-host/plugin-store";
export { shell } from "@/plugin-host/shell-bridge";
export { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
export { isPrerelease } from "@/plugins/plugin-model";
export { usePluginHostSections } from "@/settings/HostPluginSections";
export { SettingsFieldRow } from "@/settings/SettingsFields";
export { LookSettings } from "@/look/LookSettings";
export { useThemeOptions } from "@/settings/appearance/appearance-controls";
export {
hasVisibleFields,
isFieldShown,
} from "@/settings/settings-fields-util";
export { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
export { ProxySettingsFields } from "@/settings/sync/ProxySettingsFields";
export { useShellController } from "@/shell/controller/ShellControllerProvider";
export {
type KeybindingActionDef,
useKeybindingActions,
useKeybindingDefaults,
} from "@/shell/keybinding-registry";
export {
filterPaletteItems,
loadPaletteGroup,
paletteEntriesFor,
type PaletteItemDef,
usePaletteEntries,
usePaletteGroups,
} from "@/shell/palette-registry";
export { getPanel, usePanels } from "@/shell/panel-registry";
export { SplitLayoutMenu } from "@/shell/split/SplitLayoutMenu";
export {
beginSplitDrag,
endSplitDrag,
isSplitDragging,
moveSplitDrag,
} from "@/shell/split/split-drag";
export {
canJoinSplit,
isSplitTab,
type SplitSummary,
type TabSplitAction,
} from "@/shell/split/split-tabs";
export {
openInSplit,
type SplitOpenTarget,
useSplitTargets,
} from "@/shell/split/split-targets";
export { MAX_PANES } from "@/shell/split/split-tree";
export { registerTabCloseGuard } from "@/shell/tab-close-guards";
export { useTabHandlesVersion } from "@/shell/tab-handles";
export {
getTabType,
isPersistentTabType,
isSessionTabType,
type TabShellCallbacks,
} from "@/shell/tab-registry";
export {
markTabSurfaceUsed,
preloadTabSurface,
tabIcon,
} from "@/shell/tabUtils";
export { AuthEnrollmentSections } from "@/sidebar/AuthEnrollmentSections";
export { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
export {
getHostEditorSection,
makeHostSshSubTabs,
makeHostTabs,
useHostEditorSections,
} from "@/sidebar/HostManagerTabs";
export {
actionOrder,
defaultConnectAction,
type HostActionDef,
hostActionsFor,
hostBadgesFor,
hostMenuItemsFor,
listHostActions,
runHostAction,
useHostActions,
useHostBadges,
useHostContextMenuItems,
} from "@/sidebar/host-contributions";
export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog";
export {
enabledHostProtocols,
type HostProtocolDef,
hostProtocolFlags,
type HostProtocols,
listHostProtocols,
protocolEnabled,
protocolPort,
useHostProtocols,
withProtocolSettings,
} from "@/sidebar/host-protocols";
export { quickConnectTargets } from "@/sidebar/quick-connect-host";
export {
groupRailItems,
isCoreRailView,
moveRailItem,
promotableIds,
type RailGroup,
type RailItemDef,
railItemLabel,
rightDockableIds,
useRailItems,
} from "@/sidebar/rail-items";
+41
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
/**
* The Termix side of @termix-ssh/plugin-sdk/testing's renderWithApp.
*
@@ -462,3 +463,43 @@ export async function renderPlugin(
},
};
}
// Registries a shell-level plugin's own tests fill directly, since they
// render parts of the shell without activating a plugin.
export {
registerHostAction,
resetHostContributions,
} from "@/sidebar/host-contributions";
export { registerHostProtocol } from "@/sidebar/host-protocols";
export {
RAIL_ITEMS,
registerRailItem,
resetRegisteredRailItems,
} from "@/sidebar/rail-items";
export {
declareActionSlot,
registerAction,
registerSlotContribution,
resetActionRegistry,
} from "@/shell/action-registry";
export { trackTabHandle } from "@/shell/tab-handles";
export {
registerKeybindingAction,
registerKeybindingDefault,
resetKeybindingRegistry,
} from "@/shell/keybinding-registry";
export { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
export { HostPluginSections } from "@/settings/HostPluginSections";
export {
registerDashboardCard,
resetDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
export {
resetPluginStore,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
export { setPermissionsForTesting } from "@/hooks/use-permissions";
export { getRegisteredLayout } from "@/look/layout-registry";
export { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
export { ServerStatusProvider } from "@/lib/ServerStatusContext";
export { TabPortals } from "@/shell/stage/TabPortals";
-162
View File
@@ -1,162 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink, MonitorDown } from "lucide-react";
import { Button } from "@/components/button";
import { SectionCard, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
canConfigureUpdates,
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
type DesktopUpdateState,
} from "@/lib/desktop-updates";
const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"];
function StatusText({ state }: { state: DesktopUpdateState }) {
const { t } = useTranslation();
const version = state.version;
switch (state.status) {
case "checking":
return <>{t("desktopUpdates.statusChecking")}</>;
case "available":
return <>{t("desktopUpdates.statusAvailable", { version })}</>;
case "downloading":
return (
<>
{t("desktopUpdates.statusDownloading", {
version,
progress: state.progress ?? 0,
})}
</>
);
case "downloaded":
return <>{t("desktopUpdates.statusReady", { version })}</>;
case "up-to-date":
return <>{t("desktopUpdates.statusUpToDate")}</>;
case "error":
return <>{t("desktopUpdates.statusError")}</>;
default:
return <>{t("desktopUpdates.statusIdle")}</>;
}
}
/** Desktop only: how this install updates itself. */
export function DesktopUpdatesCard() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [busy, setBusy] = useState(false);
if (!canConfigureUpdates(state)) return null;
const bridge = desktopUpdates();
const run = async (action: () => Promise<unknown> | undefined) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const modeLabel = (mode: DesktopUpdateMode) =>
mode === "automatic"
? t("desktopUpdates.modeAutomatic")
: mode === "prompt"
? t("desktopUpdates.modePrompt")
: t("desktopUpdates.modeNever");
return (
<SectionCard
title={t("desktopUpdates.title")}
icon={<MonitorDown className="size-3.5" />}
rowId="updates-desktop"
>
<SettingRow
label={t("desktopUpdates.mode")}
description={
state.kind === "manual"
? t("desktopUpdates.manualModeHint")
: t("desktopUpdates.modeHint")
}
>
<Select
disabled={busy}
value={state.mode}
onValueChange={(next) =>
void run(() => bridge?.setMode(next as DesktopUpdateMode))
}
>
<SelectTrigger className="h-8 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{modeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("desktopUpdates.status", { version: state.currentVersion })}
description={<StatusText state={state} />}
>
{state.status === "downloaded" ? (
<Button
size="sm"
className="h-8"
onClick={() => void bridge?.install()}
>
{t("desktopUpdates.restart")}
</Button>
) : state.status === "available" && state.kind === "auto" ? (
<Button
size="sm"
className="h-8"
disabled={busy}
onClick={() => void run(() => bridge?.download())}
>
{t("desktopUpdates.download")}
</Button>
) : state.status === "available" && state.releaseUrl ? (
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={state.releaseUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("desktopUpdates.download")}
<ExternalLink className="size-3" />
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={
busy ||
state.status === "checking" ||
state.status === "downloading"
}
onClick={() => void run(() => bridge?.check())}
>
{t("desktopUpdates.checkNow")}
</Button>
)}
</SettingRow>
</SectionCard>
);
}
-232
View File
@@ -1,232 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
/**
* Manifest-declared settings shown as ordinary sections in the profile and
* admin panels, one per feature, named after it.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginUserSettings,
PluginSettingsValidationError,
type PluginSettingsErrors,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
export type FeatureSettingsScope = "admin" | "user";
export type FeatureSectionId = `feature:${string}`;
export function featureSectionId(pluginId: string): FeatureSectionId {
return `feature:${pluginId}`;
}
/** Enabled features with fields in this scope, sorted by name. */
export function useFeatureSettings(
scope: FeatureSettingsScope,
): PluginSummary[] {
const plugins = useInstalledPlugins();
return useMemo(
() =>
plugins
.filter(
(plugin) =>
plugin.enabled &&
hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []),
)
.sort((a, b) => a.name.localeCompare(b.name)),
[plugins, scope],
);
}
export function FeatureSettingsSection({
plugin,
scope,
open,
onToggle,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<FeatureSettingsForm
plugin={plugin}
scope={scope}
fields={plugin.contributes?.settings?.[scope] ?? []}
/>
</AccordionSection>
);
}
export function FeatureSettingsForm({
plugin,
scope,
fields,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
fields: PluginSettingsField[];
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<PluginSettingsErrors>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const running = plugin.enabled && plugin.state !== "failed";
const load = useCallback(async () => {
setLoading(true);
try {
const loaded =
scope === "admin"
? await getPluginAdminSettings(plugin.id)
: await getPluginUserSettings(plugin.id);
setValues(loaded);
setErrors({});
setDirty(false);
} catch {
toast.error(t("settings.pluginSettingsLoadFailed"));
} finally {
setLoading(false);
}
}, [plugin.id, scope, t]);
useEffect(() => {
void load();
}, [load]);
const setValue = (key: string, value: unknown) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const saved =
scope === "admin"
? await updatePluginAdminSettings(plugin.id, values)
: await updatePluginUserSettings(plugin.id, values);
setValues(saved);
setErrors({});
setDirty(false);
toast.success(t("settings.pluginSettingsSaved"));
} catch (error) {
if (error instanceof PluginSettingsValidationError) {
setErrors(error.errors);
toast.error(t("settings.pluginSettingsInvalid"));
} else {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
} finally {
setSaving(false);
}
};
// Groups appear in the order of their first field, so the manifest sets the layout.
const groups = useMemo(() => {
const ordered: { key?: string; fields: PluginSettingsField[] }[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
else ordered.push({ key: field.group, fields: [field] });
}
return ordered;
}, [fields]);
if (loading) {
return (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const rows = (group: PluginSettingsField[]) =>
group
.filter((field) => isFieldShown(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
));
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: pluginName(t, plugin),
})}
</div>
)}
{groups.map((group, index) =>
group.key ? (
<SectionCard
key={group.key}
title={t(pluginKey(plugin.id, group.key))}
icon={icon}
>
{rows(group.fields)}
</SectionCard>
) : (
<div key={`group-${index}`}>{rows(group.fields)}</div>
),
)}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!dirty || saving}
onClick={() => void save()}
>
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
{t("common.save")}
</Button>
</div>
</div>
);
}
-478
View File
@@ -1,478 +0,0 @@
import { Select2 } from "@/components/select2";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
} from "@/types/keybindings";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { GroupHeading } from "@/components/panel-layout";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Keyboard, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "@/sidebar/FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
if (combo.alt) parts.push("Alt");
if (combo.shift) parts.push("Shift");
if (combo.meta) parts.push("Cmd");
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
return parts.join(" + ");
}
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Settings > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsSettings() {
const { t } = useTranslation();
const confirm = useConfirm();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
string | undefined
>(undefined);
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, []);
const conflictWarning = useMemo(() => {
if (!recordedCombo) return null;
const conflict = bindings.find(
(kb) =>
kb.id !== editingId &&
kb.enabled &&
kb.combo.ctrl === recordedCombo.ctrl &&
kb.combo.alt === recordedCombo.alt &&
kb.combo.shift === recordedCombo.shift &&
kb.combo.meta === recordedCombo.meta &&
kb.combo.key === recordedCombo.key,
);
if (conflict) {
return t("newUi.sidebar.keybindings.conflictWarning", {
combo: formatCombo(conflict.combo),
});
}
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
resetForm();
setFormOpen(true);
}
function openEditForm(kb: CustomKeybinding) {
setEditingId(kb.id);
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setDraft({ ...kb.action });
setFormOpen(true);
}
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
resetForm();
setOverridesDefaultId(defaultId);
setRecordedCombo(combo);
setFormOpen(true);
}
async function persist(updated: CustomKeybinding[]) {
try {
await saveUserPreferences({
customKeybindings: JSON.stringify(updated),
});
setBindings(updated);
window.dispatchEvent(new Event("customKeybindingsChanged"));
} catch {
toast.error(t("newUi.sidebar.keybindings.saveError"));
}
}
async function handleDelete(id: string) {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.deleteConfirm"),
});
if (ok) persist(bindings.filter((kb) => kb.id !== id));
}
async function handleResetAll() {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.resetAllConfirm"),
confirmLabel: t("newUi.sidebar.keybindings.resetAllToDefaults"),
});
if (ok) persist([]);
}
function startRecording(inputEl: HTMLInputElement) {
setRecording(true);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
const combo: KeyCombo = {
key: isAlnum ? e.key.toLowerCase() : e.code,
isCode: !isAlnum,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
};
setRecordedCombo(combo);
setRecording(false);
inputEl.removeEventListener("keydown", handler, true);
inputEl.blur();
};
inputEl.addEventListener("keydown", handler, true);
}
function handleSave() {
if (!recordedCombo) {
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
const now = new Date().toISOString();
const existing = editingId
? bindings.find((kb) => kb.id === editingId)
: undefined;
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const updated = editingId
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
: [...bindings, newBinding];
persist(updated);
setFormOpen(false);
resetForm();
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
<div className="flex flex-col gap-4 border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</p>
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.defaultsHeading")}
/>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(override.id)}
title={t("newUi.sidebar.keybindings.resetToDefault")}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.customHeading")}
action={
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
}
/>
{loading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.loading")}
</span>
) : customOnlyBindings.length === 0 ? (
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.keybindings.noCustomBindings")}
</span>
) : (
customOnlyBindings.map((kb) => {
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(kb)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
<Button
variant="ghost"
size="sm"
className="self-start text-muted-foreground"
onClick={() => void handleResetAll()}
disabled={bindings.length === 0}
>
<RotateCcw className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
</div>
<InlineView
open={formOpen}
onOpenChange={setFormOpen}
icon={<Keyboard className="size-4" />}
title={
editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")
}
footer={
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFormOpen(false)}
>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.keybindings.saveBinding")}
</Button>
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
? t("newUi.sidebar.keybindings.recording")
: recordedCombo
? formatCombo(recordedCombo)
: ""
}
placeholder={t("newUi.sidebar.keybindings.pressKeysPlaceholder")}
onFocus={(e) => startRecording(e.currentTarget)}
className="font-mono cursor-pointer"
/>
{conflictWarning && (
<span className="text-xs text-warning">{conflictWarning}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<Select2
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</Select2>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
</InlineView>
</>
);
}
-429
View File
@@ -1,429 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Bug,
ChevronDown,
ExternalLink,
Search,
Settings as SettingsIcon,
} from "lucide-react";
import {
GroupHeading,
PANEL,
PanelSearch,
PanelShell,
} from "@/components/panel-layout";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import {
FeatureSettingsForm,
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { UpdatesSettings } from "./UpdatesSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
searchSettingsPages,
visibleSettingsPages,
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
import { usePluginDocsUrl } from "@/components/docs-link";
import { docsUrl } from "@/lib/docs";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: string;
/** Changes on every open request, so asking for the same page again still goes there. */
sectionAt?: string | number;
/** An element id on that page to scroll to, as "id@nonce". */
reveal?: string;
username: string;
isAdmin: boolean;
onLogout?: () => void;
userPrefs?: UserPreferences;
onPrefsChange?: (prefs: Partial<UserPreferences>) => void;
onOpenHostTab?: (host: Host) => void;
}
const BAND_LABEL: Record<SettingsBand, string> = {
you: "settings.bandYou",
app: "settings.bandApp",
plugins: "settings.bandPlugins",
instance: "settings.bandInstance",
};
/** Scrolls to the first text on the page containing the query and marks it. */
function revealText(container: HTMLElement, needle: string): boolean {
const lower = needle.toLowerCase();
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.textContent?.toLowerCase().includes(lower)) {
const el = node.parentElement;
if (el && el.offsetParent !== null) {
el.scrollIntoView?.({ block: "center", behavior: "smooth" });
el.classList.add("settings-search-hit");
window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return true;
}
}
node = walker.nextNode();
}
return false;
}
/** Everything that configures the app, one page at a time. */
export function SettingsTab({
section,
sectionAt,
reveal,
username,
isAdmin,
onLogout,
userPrefs,
onPrefsChange,
onOpenHostTab,
}: SettingsTabProps) {
const { t } = useTranslation();
const userFeatures = useFeatureSettings("user");
const adminFeatures = useFeatureSettings("admin");
const [query, setQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
const [pageQuery, setPageQuery] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
for (const plugin of [...userFeatures, ...(isAdmin ? adminFeatures : [])]) {
if (plugins.has(plugin.id)) continue;
const fields = [
...(plugin.contributes?.settings?.user ?? []),
...(isAdmin ? (plugin.contributes?.settings?.admin ?? []) : []),
];
plugins.set(plugin.id, {
id: `plugin:${plugin.id}`,
band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"),
label: pluginName(t, plugin),
icon: SettingsIcon,
pluginId: plugin.id,
searchKeys: fields.flatMap((field) =>
[field.labelKey, field.descriptionKey]
.filter((key): key is string => !!key)
.map((key) => pluginKey(plugin.id, key)),
),
});
}
return visibleSettingsPages(
[
...CORE_SETTINGS_PAGES,
...[...plugins.values()].sort((a, b) =>
(a.label ?? "").localeCompare(b.label ?? ""),
),
],
{ isAdmin, isElectron: isElectron() },
);
}, [userFeatures, adminFeatures, isAdmin, t]);
const [current, setCurrent] = useState(section ?? "account");
useEffect(() => {
if (section) setCurrent(section);
}, [section, sectionAt]);
// The page renders lazily, so the target may take a moment to appear.
useEffect(() => {
const target = reveal?.split("@")[0];
if (!target) return;
let tries = 0;
let timer = 0;
const attempt = () => {
const el = contentRef.current?.querySelector<HTMLElement>(
`#${CSS.escape(target)}`,
);
if (el) {
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
el.classList.add("settings-search-hit");
timer = window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return;
}
if (++tries < 15) timer = window.setTimeout(attempt, 120);
};
timer = window.setTimeout(attempt, 60);
return () => window.clearTimeout(timer);
}, [reveal]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
const hits = searchSettingsPages(pages, query, (key) => t(key));
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
const go = (id: string) => {
setCurrent(id);
setNavOpen(false);
setPageQuery("");
contentRef.current?.scrollTo?.({ top: 0 });
const needle = query.trim();
if (!needle) return;
let tries = 0;
const attempt = () => {
if (!contentRef.current) return;
if (revealText(contentRef.current, needle) || ++tries > 12) return;
window.setTimeout(attempt, 150);
};
window.setTimeout(attempt, 60);
};
const pluginDocs = usePluginDocsUrl(page.pluginId);
const pageDocs = page.pluginId
? pluginDocs
: page.docs
? docsUrl(page.docs)
: null;
const pluginFeature = page.pluginId
? {
user: userFeatures.find((p) => p.id === page.pluginId),
admin: isAdmin
? adminFeatures.find((p) => p.id === page.pluginId)
: undefined,
}
: null;
const nav = (
<div className="flex flex-col gap-3 py-2.5">
<div className="px-2.5">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("settings.searchPlaceholder")}
fill
/>
</div>
{query.trim() && hits.length === 0 && (
<p className="px-3 text-xs text-muted-foreground">
{t("settings.noResults")}
</p>
)}
{SETTINGS_BAND_ORDER.map((band) => {
const items = hits.filter((hit) => hit.page.band === band);
const reportBug =
band === "you" &&
(!query.trim() ||
t("dashboard.reportBug")
.toLowerCase()
.includes(query.trim().toLowerCase()));
if (items.length === 0 && !reportBug) return null;
return (
<div key={band} className="flex flex-col">
<GroupHeading
title={t(BAND_LABEL[band])}
className="px-2.5 pb-1.5"
/>
{items.map(({ page: item, match }) => {
const on = item.id === page.id;
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => go(item.id)}
aria-current={on ? "page" : undefined}
className={cn(
"flex w-full flex-col border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="flex items-center gap-2">
{item.pluginId ? (
<PluginIcon
name={
[...userFeatures, ...adminFeatures].find(
(p) => p.id === item.pluginId,
)?.icon
}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{pageLabel(item)}
</span>
</span>
{match && (
<span className="truncate pl-5.5 text-[10px] text-muted-foreground">
{match}
</span>
)}
</button>
);
})}
{reportBug && (
<a
href={reportCoreIssueUrl(
import.meta.env.VITE_APP_VERSION || undefined,
)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 border-l-2 border-transparent py-1.5 pl-2 pr-2 text-left text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring"
>
<Bug className="size-3.5 shrink-0" />
<span className="truncate text-xs font-medium">
{t("dashboard.reportBug")}
</span>
<ExternalLink className="ml-auto size-3 shrink-0" />
</a>
)}
</div>
);
})}
</div>
);
const showProfile = !!page.profileSection && page.id !== "keybindings";
const showAdmin = !!page.adminSection;
return (
<PanelShell
icon={<SettingsIcon className="size-4" />}
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
docs={pageDocs}
actions={
<button
type="button"
onClick={() => setNavOpen((v) => !v)}
aria-expanded={navOpen}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground md:hidden"
>
{t("settings.pages")}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
navOpen && "rotate-180",
)}
/>
</button>
}
>
<div className="relative flex min-h-0 flex-1">
<nav
aria-label={t("settings.pages")}
className={cn(
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
{nav}
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={pageQuery}
onChange={setPageQuery}
placeholder={t("settings.searchPage")}
fill
/>
</div>
</div>
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!pageHasMatches && (
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
)}
<div
ref={pageRef}
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
<div className={showProfile ? "contents" : "hidden"}>
<UserProfilePanel
section={page.profileSection ?? "__none__"}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
onOpenSettingsPage={go}
/>
</div>
{isAdmin && (
<div className={showAdmin ? "contents" : "hidden"}>
<AdminSettingsPanel
section={page.adminSection ?? ("__none__" as never)}
onOpenHostTab={onOpenHostTab}
/>
</div>
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
<FeatureSettingsForm
plugin={pluginFeature.user}
scope="user"
fields={
pluginFeature.user.contributes?.settings?.user ?? []
}
/>
)}
{pluginFeature.admin && (
<>
<GroupHeading
title={t("settings.instanceSettings")}
className="pt-2"
/>
<FeatureSettingsForm
plugin={pluginFeature.admin}
scope="admin"
fields={
pluginFeature.admin.contributes?.settings?.admin ?? []
}
/>
</>
)}
{!pluginFeature.user && !pluginFeature.admin && (
<EmptyState
icon={SettingsIcon}
title={t("settings.nothingHere")}
/>
)}
</>
)}
</div>
</div>
</SurfaceScope>
</div>
</PanelShell>
);
}
-330
View File
@@ -1,330 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Copy, ExternalLink, FlaskConical, Puzzle, Tag } from "lucide-react";
import { Button } from "@/components/button";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import {
getChannelReleases,
getUpdateChannel,
setUpdateChannel,
type ReleaseInfo,
type UpdateChannelState,
} from "@/api/system-status-api";
import {
getPlugins,
setAllPluginChannels,
setPluginOptions,
type PluginSummary,
} from "@/api/plugins-api";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
}
function ReleaseRow({
label,
release,
}: {
label: string;
release: ReleaseInfo | null;
}) {
const { t } = useTranslation();
return (
<SettingRow
label={label}
description={
release
? new Date(release.publishedAt).toLocaleDateString()
: t("settings.updates.noRelease")
}
>
{release && (
<a
href={release.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent-brand hover:underline"
>
v{release.version}
<ExternalLink className="size-3" />
</a>
)}
</SettingRow>
);
}
function InstanceUpdates() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
} | null>(null);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState(false);
const version = import.meta.env.VITE_APP_VERSION || undefined;
const loadPlugins = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
setPlugins([]);
}
}, []);
useEffect(() => {
getUpdateChannel()
.then(setChannel)
.catch(() => setChannel(null));
getChannelReleases()
.then(setReleases)
.catch(() => setReleases({ stable: null, beta: null }));
void loadPlugins();
}, [loadPlugins]);
const betaPlugins = plugins.filter(onBeta);
const runningBetas = plugins
.filter((plugin) => isPrerelease(plugin.version))
.map((plugin) => ({ id: plugin.id, version: plugin.version }));
const joined = channel?.channel === "beta";
const changeChannel = async (next: boolean) => {
setBusy(true);
try {
setChannel(await setUpdateChannel(next ? "beta" : "stable"));
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const changePlugins = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
await loadPlugins();
setBusy(false);
}
};
const copyImage = async () => {
try {
await navigator.clipboard.writeText(DOCKER_BETA_IMAGE);
toast.success(t("settings.updates.copied"));
} catch {
toast.error(t("settings.updates.copyFailed"));
}
};
return (
<div className="flex flex-col gap-3">
<SectionCard
title={t("settings.updates.betaProgram")}
icon={<FlaskConical className="size-3.5" />}
rowId="updates-beta"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.betaIntro")}
</p>
<SettingRow
label={t("settings.updates.termixBetas")}
description={
channel?.runningBeta
? t("settings.updates.runningBetaBuild")
: t("settings.updates.termixBetasHint")
}
>
<FakeSwitch
checked={joined}
disabled={busy || !channel || channel.runningBeta}
onChange={(next) => void changeChannel(next)}
/>
</SettingRow>
{joined && !channel?.runningBeta && (
<div className="flex flex-col gap-2 py-3 text-xs">
<span className="font-medium">
{t("settings.updates.howToInstall")}
</span>
{isElectron() ? (
<span className="text-muted-foreground">
{t("settings.updates.desktopInstall")}{" "}
{releases?.beta && (
<a
href={releases.beta.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
>
{t("settings.updates.downloadBeta", {
version: releases.beta.version,
})}
</a>
)}
</span>
) : (
<>
<span className="text-muted-foreground">
{t("settings.updates.dockerInstall")}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 truncate border border-border bg-muted/40 px-2 py-1.5 font-mono text-[11px]">
{DOCKER_BETA_IMAGE}
</code>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={() => void copyImage()}
>
<Copy className="size-3.5" />
{t("settings.updates.copy")}
</Button>
</div>
</>
)}
<a
href={docsUrl("betas")}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
>
{t("settings.updates.learnMore")}
<ExternalLink className="size-3" />
</a>
</div>
)}
<SettingRow
label={t("settings.updates.feedback")}
description={t("settings.updates.feedbackHint")}
>
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={reportBetaFeedbackUrl(version, runningBetas)}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.updates.sendFeedback")}
</a>
</Button>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settings.updates.releases")}
icon={<Tag className="size-3.5" />}
rowId="updates-releases"
>
<SettingRow label={t("settings.updates.thisVersion")}>
<span className="text-xs font-medium">
{version ? `v${version}` : "-"}
</span>
</SettingRow>
<ReleaseRow
label={t("settings.updates.newestStable")}
release={releases?.stable ?? null}
/>
<ReleaseRow
label={t("settings.updates.newestBeta")}
release={releases?.beta ?? null}
/>
</SectionCard>
<SectionCard
title={t("settings.updates.pluginBetas")}
icon={<Puzzle className="size-3.5" />}
rowId="updates-plugins"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.pluginBetasIntro")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("settings.updates.openPlugins")}
</button>
</p>
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={pluginName(t, plugin)}
description={
plugin.channel === "beta"
? `v${plugin.version}`
: t("settings.updates.waitingForStable", {
version: plugin.version,
})
}
>
{plugin.channel === "beta" && (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() =>
setPluginOptions(plugin.id, { channel: "stable" }),
)
}
>
{t("settings.updates.backToStable")}
</Button>
)}
</SettingRow>
))}
{plugins.length > 0 && (
<div className="flex flex-wrap gap-2 py-3">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() => setAllPluginChannels("beta"))
}
>
{t("settings.updates.allPluginsBeta")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy || betaPlugins.length === 0}
onClick={() =>
void changePlugins(() => setAllPluginChannels("stable"))
}
>
{t("settings.updates.allPluginsStable")}
</Button>
</div>
)}
</SectionCard>
</div>
);
}
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="flex flex-col gap-3">
<DesktopUpdatesCard />
{isAdmin && <InstanceUpdates />}
</div>
);
}
-139
View File
@@ -1,139 +0,0 @@
import { useEffect, useState, type RefObject } from "react";
export const HIDDEN_ATTR = "data-search-hidden";
const HIGHLIGHT = "settings-search";
const BLOCK = ".bg-card, [data-setting-row]";
function matches(el: Element, needle: string) {
return (el.textContent ?? "").toLowerCase().includes(needle);
}
/** Visible blocks with no other block above them inside root. */
function topBlocks(root: HTMLElement, blocks: Element[], selector: string) {
return blocks.filter((el) => {
let parent = el.parentElement;
while (parent && parent !== root) {
if (parent.matches(selector) || parent.classList.contains("hidden"))
return false;
parent = parent.parentElement;
}
return true;
});
}
/**
* Hides the cards and rows on a settings page that do not contain the query.
* A card whose own text matches keeps every row; otherwise only its matching
* rows stay. Returns how many cards are still showing.
*/
export function filterSettingsPage(
root: HTMLElement,
query: string,
blockSelector: string = BLOCK,
): number {
for (const el of root.querySelectorAll(`[${HIDDEN_ATTR}]`)) {
el.removeAttribute(HIDDEN_ATTR);
}
const needle = query.trim().toLowerCase();
const blocks = [...root.querySelectorAll(blockSelector)];
const cards = topBlocks(root, blocks, blockSelector);
if (!needle) return cards.length;
let shown = 0;
for (const card of cards) {
if (!matches(card, needle)) {
card.setAttribute(HIDDEN_ATTR, "");
continue;
}
shown++;
const rows = [...card.querySelectorAll("[data-setting-row]")].filter(
(row) => row !== card,
);
if (!rows.some((row) => matches(row, needle))) continue;
for (const row of rows) {
if (!matches(row, needle)) row.setAttribute(HIDDEN_ATTR, "");
}
for (const inner of card.querySelectorAll(".bg-card")) {
if (inner !== card && !matches(inner, needle)) {
inner.setAttribute(HIDDEN_ATTR, "");
}
}
}
return shown;
}
type HighlightRegistry = Map<string, unknown>;
function highlights(): HighlightRegistry | undefined {
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
return css?.highlights;
}
/** Marks every match with the CSS highlight API where the browser has it. */
function highlightMatches(root: HTMLElement, query: string) {
const registry = highlights();
const Highlight = (
globalThis as { Highlight?: new (...r: Range[]) => unknown }
).Highlight;
if (!registry || !Highlight) return;
registry.delete(HIGHLIGHT);
const needle = query.trim().toLowerCase();
if (!needle) return;
const ranges: Range[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent?.toLowerCase() ?? "";
let at = text.indexOf(needle);
while (at !== -1) {
const range = document.createRange();
range.setStart(node, at);
range.setEnd(node, at + needle.length);
ranges.push(range);
at = text.indexOf(needle, at + needle.length);
}
}
registry.set(HIGHLIGHT, new Highlight(...ranges));
}
/**
* Keeps a settings page filtered while its content loads and changes.
* Returns false when the query hides everything.
*/
export function useSettingsPageFilter(
rootRef: RefObject<HTMLElement | null>,
query: string,
pageKey: string,
blockSelector: string = BLOCK,
onFiltered?: (root: HTMLElement) => void,
): boolean {
const [anyShown, setAnyShown] = useState(true);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let frame = 0;
const run = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
setAnyShown(
filterSettingsPage(root, query, blockSelector) > 0 || !query.trim(),
);
highlightMatches(root, query);
onFiltered?.(root);
});
};
run();
if (!query.trim()) return () => cancelAnimationFrame(frame);
const observer = new MutationObserver(run);
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
highlights()?.delete(HIGHLIGHT);
};
}, [rootRef, query, pageKey, blockSelector, onFiltered]);
return anyShown;
}
-11
View File
@@ -1,11 +0,0 @@
import { createContext, useContext } from "react";
/**
* True inside a Settings tab page. Panels built from accordion sections then
* show only the open one, flat, since the page already names it.
*/
export const SettingsEmbedContext = createContext(false);
export function useSettingsEmbedded(): boolean {
return useContext(SettingsEmbedContext);
}
-264
View File
@@ -1,264 +0,0 @@
import {
Database,
FileText,
FlaskConical,
Globe,
KeyRound,
Keyboard,
Lock,
Monitor,
Palette,
Server,
Settings,
Shield,
SlidersHorizontal,
User,
Users,
type LucideIcon,
} from "lucide-react";
import type { AdminSection } from "@/types/ui-types";
import type { DocsPage } from "@/lib/docs";
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
export type SettingsBand = "you" | "app" | "plugins" | "instance";
export const SETTINGS_BAND_ORDER: SettingsBand[] = [
"you",
"app",
"plugins",
"instance",
];
export interface SettingsPage {
id: string;
band: SettingsBand;
labelKey: string;
/** Already translated, for plugin pages named after the plugin. */
label?: string;
icon: LucideIcon;
/** Only for admins. */
admin?: boolean;
/** Also shown to non-admins in the desktop app. */
desktopToo?: boolean;
/** Left out of the desktop app. */
browserOnly?: boolean;
/** The profile section this page shows. */
profileSection?: string;
/** The admin section this page shows. */
adminSection?: AdminSection;
/** A plugin's settings page. */
pluginId?: string;
/** The docs page for this settings page. Plugin pages use the plugin's docs. */
docs?: DocsPage;
/** i18n keys whose text search matches. */
searchKeys: string[];
}
function core(
page: Omit<SettingsPage, "searchKeys"> & { indexId?: string },
): SettingsPage {
return {
...page,
searchKeys: SETTINGS_SEARCH_INDEX[page.indexId ?? page.id] ?? [],
};
}
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
core({
id: "account",
docs: "account",
band: "you",
labelKey: "settings.account",
icon: User,
profileSection: "account",
}),
core({
id: "security",
docs: "accountSecurity",
band: "you",
labelKey: "settings.security",
icon: Shield,
profileSection: "security",
browserOnly: true,
}),
core({
id: "api-keys",
docs: "apiKeys",
band: "you",
labelKey: "settings.apiKeys",
icon: KeyRound,
profileSection: "api-keys",
}),
core({
id: "data",
docs: "accountData",
band: "you",
labelKey: "settings.data",
icon: Database,
profileSection: "data",
}),
core({
id: "interface",
docs: "interfaceSettings",
band: "app",
labelKey: "settings.interface",
icon: SlidersHorizontal,
profileSection: "interface",
}),
core({
id: "appearance",
docs: "appearance",
band: "app",
labelKey: "settings.appearance",
icon: Palette,
profileSection: "appearance",
}),
core({
id: "keybindings",
docs: "keybindings",
band: "app",
labelKey: "settings.keybindings",
icon: Keyboard,
profileSection: "keybindings",
}),
core({
id: "admin-general",
docs: "adminGeneral",
band: "instance",
labelKey: "settings.adminGeneral",
icon: Settings,
admin: true,
adminSection: "general",
}),
core({
id: "updates",
docs: "updating",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
desktopToo: true,
}),
core({
id: "admin-users",
docs: "users",
band: "instance",
labelKey: "settings.adminUsers",
icon: Users,
admin: true,
adminSection: "users",
}),
core({
id: "admin-sessions",
docs: "sessions",
band: "instance",
labelKey: "settings.adminSessions",
icon: Monitor,
admin: true,
adminSection: "sessions",
}),
core({
id: "admin-roles",
docs: "roles",
band: "instance",
labelKey: "settings.adminRoles",
icon: Lock,
admin: true,
adminSection: "roles",
}),
core({
id: "admin-host-defaults",
docs: "hostDefaults",
band: "instance",
labelKey: "settings.adminHostDefaults",
icon: Server,
admin: true,
adminSection: "host-defaults",
}),
core({
id: "admin-branding",
docs: "branding",
band: "instance",
labelKey: "settings.adminBranding",
icon: Palette,
admin: true,
adminSection: "branding",
}),
core({
id: "admin-database",
docs: "adminDatabase",
band: "instance",
labelKey: "settings.adminDatabase",
icon: Database,
admin: true,
adminSection: "database",
}),
core({
id: "admin-ssl",
docs: "adminSsl",
band: "instance",
labelKey: "settings.adminSsl",
icon: Globe,
admin: true,
adminSection: "ssl",
}),
core({
id: "admin-api-keys",
docs: "apiKeys",
band: "instance",
labelKey: "settings.adminApiKeys",
icon: KeyRound,
admin: true,
adminSection: "api-keys",
}),
core({
id: "admin-audit-log",
docs: "auditLog",
band: "instance",
labelKey: "settings.adminAuditLog",
icon: FileText,
admin: true,
adminSection: "audit-log",
}),
];
/** The pages this user can reach, in nav order. */
export function visibleSettingsPages(
pages: SettingsPage[],
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
): SettingsPage[] {
return pages.filter(
(page) =>
(!page.admin || isAdmin || (page.desktopToo && isElectron)) &&
(!page.browserOnly || !isElectron),
);
}
export interface SettingsSearchHit {
page: SettingsPage;
/** The first matching phrase, to show under the page name. */
match: string;
}
/** Pages whose name or any label on them contains the query. */
export function searchSettingsPages(
pages: SettingsPage[],
query: string,
translate: (key: string) => string,
): SettingsSearchHit[] {
const needle = query.trim().toLowerCase();
if (!needle) return pages.map((page) => ({ page, match: "" }));
const hits: SettingsSearchHit[] = [];
for (const page of pages) {
const label = page.label ?? translate(page.labelKey);
if (label.toLowerCase().includes(needle)) {
hits.push({ page, match: "" });
continue;
}
const phrase = page.searchKeys
.map((key) => translate(key))
.find((text) => text.toLowerCase().includes(needle));
if (phrase) hits.push({ page, match: phrase });
}
return hits;
}
-488
View File
@@ -1,488 +0,0 @@
// Generated by scripts/generate-settings-search-index.cjs. Do not edit.
/** The i18n keys each core settings section shows, for search. */
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountDescription",
"newUi.sidebar.userProfile.deleteButton",
"newUi.sidebar.userProfile.desktopProfileDescription",
"newUi.sidebar.userProfile.desktopProfileTitle",
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.onBetaDescription",
"newUi.sidebar.userProfile.onBetaTitle",
"newUi.sidebar.userProfile.openUpdates",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.sendBetaFeedback",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.tryBetaDescription",
"newUi.sidebar.userProfile.tryBetaTitle",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
"newUi.sidebar.userProfile.usernameLabel",
"newUi.sidebar.userProfile.versionLabel",
],
"admin-api-keys": [
"admin.apiKeyCreatedWarning",
"admin.apiKeyExpiresAt",
"admin.apiKeyName",
"admin.apiKeyNoExpiry",
"admin.apiKeySelectUser",
"admin.apiKeyUser",
"admin.apiKeysCliHint",
"admin.apiKeysCliLink",
"admin.apiKeysCount_other",
"admin.apiKeysDocsLink",
"admin.copiedToClipboard",
"admin.createKey",
"admin.createRole",
"admin.creating",
"admin.done",
"admin.newApiKey",
"admin.revokeKey",
"admin.revokeKeyConfirm",
"admin.revokeKeyFailed",
"admin.revokeKeySuccess",
"admin.revokedBadge",
"admin.sectionApiKeys",
"common.cancel",
"placeholders.apiKeyName",
],
"admin-audit-log": [
"admin.auditForwardingDesc",
"admin.auditForwardingEnvNotice",
"admin.auditForwardingSaveError",
"admin.auditForwardingSaved",
"admin.auditForwardingTitle",
"admin.auditForwardingToken",
"admin.auditForwardingTokenStored",
"admin.auditLogClearFilters",
"admin.auditLogEmpty",
"admin.auditLogFailed",
"admin.auditLogFilterAction",
"admin.auditLogFilterAll",
"admin.auditLogFilterFrom",
"admin.auditLogFilterResourceType",
"admin.auditLogFilterStatus",
"admin.auditLogFilterTo",
"admin.auditLogFilterUser",
"admin.auditLogIp",
"admin.auditLogPage",
"admin.auditLogResourceId",
"admin.auditLogSuccess",
"admin.auditLogTotal",
"admin.sectionAuditLog",
"common.save",
],
"admin-branding": [
"admin.brandingAppName",
"admin.brandingAppNameDesc",
"admin.brandingLogo",
"admin.brandingLogoDesc",
"admin.brandingLogoInvalid",
"admin.brandingLogoReset",
"admin.brandingLogoUpload",
"admin.brandingTagline",
"admin.brandingTaglineDesc",
"admin.sectionBranding",
"common.loading",
"common.save",
"common.saving",
],
"admin-database": [
"admin.changeFile",
"admin.export",
"admin.exportDatabase",
"admin.exportDatabaseDesc",
"admin.exporting",
"admin.import",
"admin.importDatabase",
"admin.importDatabaseDesc",
"admin.importDatabaseSelected",
"admin.importing",
"admin.sectionDatabase",
"admin.selectFile",
],
"admin-general": [
"admin.allowPasswordLogin",
"admin.allowPasswordLoginDesc",
"admin.allowPasswordReset",
"admin.allowPasswordResetDesc",
"admin.allowRegistration",
"admin.allowRegistrationDesc",
"admin.externalAutoProvision",
"admin.externalAutoProvisionDesc",
"admin.hours",
"admin.logLevel",
"admin.monitoringDefaults",
"admin.notificationPrivateEndpoints",
"admin.notificationPrivateEndpointsDesc",
"admin.passwordLoginForced",
"admin.sec",
"admin.secondFactorAfterExternalLogin",
"admin.secondFactorAfterExternalLoginDesc",
"admin.sectionGeneral",
"admin.sessionTimeout",
"admin.sessionTimeoutRange",
"admin.statusCheck",
"common.save",
"plugins.manager.developer.enable",
"plugins.manager.developer.enableBody",
"plugins.manager.developer.enableTitle",
"plugins.manager.developer.hint",
"plugins.manager.developer.signedOnly",
"plugins.manager.developer.title",
"plugins.manager.errors.developerMode",
],
"admin-host-defaults": [
"admin.hostTags",
"admin.hostTagsDesc",
"admin.hostTagsError",
"admin.sectionHostDefaults",
"common.remove",
"hostDefaults.adminSectionDescription",
"hostDefaults.editServerDefaults",
"hosts.addTag",
],
"admin-roles": [
"admin.createRole",
"admin.creating",
"admin.customBadge",
"admin.deleteRoleConfirm",
"admin.deleteRoleSuccess",
"admin.newRole",
"admin.roleDescription",
"admin.roleDisplayName",
"admin.roleName",
"admin.rolePermissions.count_other",
"admin.rolePermissions.editAction",
"admin.rolePermissions.loadError",
"admin.rolePermissions.pluginDisabled",
"admin.rolePermissions.save",
"admin.rolePermissions.saveError",
"admin.rolePermissions.saved",
"admin.rolePermissions.saving",
"admin.rolesCount_other",
"admin.sectionRoles",
"admin.systemBadge",
"common.cancel",
"common.optional",
],
"admin-sessions": [
"admin.revokeAll",
"admin.revokeAllSessionsConfirm",
"admin.revokeAllSessionsFailed",
"admin.revokeAllSessionsSuccess",
"admin.revokeSession",
"admin.revokeSessionConfirm",
"admin.revokeSessionFailed",
"admin.sectionSessions",
"admin.sessionActive",
"admin.sessionExpires",
"admin.sessionsActive_other",
"admin.youBadge",
],
"admin-ssl": [
"admin.sectionSsl",
"admin.sslCertExpiresAt",
"admin.sslCertStatus",
"admin.sslCertStatusExpired",
"admin.sslCertStatusExpiring",
"admin.sslCertStatusNone",
"admin.sslCertStatusValid",
"admin.sslDescription",
"admin.sslDocsLink",
"admin.sslInfoNote",
"admin.sslIssuer",
"admin.sslManualCert",
"admin.sslManualCertPlaceholder",
"admin.sslManualDesc",
"admin.sslManualKey",
"admin.sslManualKeyPlaceholder",
"admin.sslManualTitle",
"admin.sslManualUpload",
"admin.sslManualUploadLoading",
"admin.sslNames",
"admin.sslNotRenewed",
"admin.sslRenewedBy",
"admin.sslSelfSigned",
],
"admin-users": [
"admin.addRole",
"admin.adminBadge",
"admin.adminStatusAdministrator",
"admin.adminStatusRegularUser",
"admin.assignRoleFailed",
"admin.authTypeDual",
"admin.authTypeExternal",
"admin.authTypeLocal",
"admin.createUser",
"admin.createUserDesc",
"admin.createUserEnterUsername",
"admin.createUserPassword",
"admin.createUserPasswordHint",
"admin.createUserSubmit",
"admin.createUserTitle",
"admin.createUserUsername",
"admin.creating",
"admin.deleteUser",
"admin.deleteUserConfirm",
"admin.deleteUserFailed",
"admin.deleteUserSuccess",
"admin.deleteUserWarning",
"admin.deleting",
"admin.editUserAdminStatus",
"admin.editUserAuthType",
"admin.editUserDesc",
"admin.editUserTitle",
"admin.editUserUserId",
"admin.editUserUsername",
"admin.linkAccountDesc",
"admin.linkAccountDescLocal",
"admin.linkAccountEffect1",
"admin.linkAccountEffect2",
"admin.linkAccountEffect3",
"admin.linkAccountExternalNotFound",
"admin.linkAccountExternalPlaceholder",
"admin.linkAccountExternalUsername",
"admin.linkAccountFailed",
"admin.linkAccountInProgress",
"admin.linkAccountSuccess",
"admin.linkAccountTargetPlaceholder",
"admin.linkAccountTargetUsername",
"admin.linkAccountTitle",
"admin.linkAccountWarningTitle",
"admin.linkAccounts",
"admin.manageUserData",
"admin.noCustomRoles",
"admin.noUsers",
"admin.noUsersMatch",
"admin.pageOf",
"admin.removeRoleFailed",
"admin.revoke",
"admin.revokeAllUserSessions",
"admin.revokeAllUserSessionsDesc",
"admin.searchUsers",
"admin.sectionUsers",
"admin.unlinkAccount",
"admin.unlinkAccountDesc",
"admin.unlinkAccountFailed",
"admin.unlinkAccountInProgress",
"admin.unlinkAccountSuccess",
"admin.unlinkAccountTitle",
"admin.unlinkAccountWarning",
"admin.userAdminAccess",
"admin.userAdminAccessDesc",
"admin.userRoles",
"admin.usersCount_other",
"common.cancel",
"common.loading",
"common.next",
"common.previous",
"manage.cannotBeUndone",
"placeholders.enterPassword",
],
"api-keys": [
"admin.revokeKey",
"admin.revokeKeyConfirm",
"newUi.sidebar.userProfile.apiKeyActive",
"newUi.sidebar.userProfile.apiKeyCount_other",
"newUi.sidebar.userProfile.apiKeyCreateFailed",
"newUi.sidebar.userProfile.apiKeyCreated",
"newUi.sidebar.userProfile.apiKeyCreatedWarning",
"newUi.sidebar.userProfile.apiKeyExpires",
"newUi.sidebar.userProfile.apiKeyNameLabel",
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
"newUi.sidebar.userProfile.apiKeyNameRequired",
"newUi.sidebar.userProfile.apiKeyPermissionsHint",
"newUi.sidebar.userProfile.apiKeyRevokeFailed",
"newUi.sidebar.userProfile.apiKeyRevoked",
"newUi.sidebar.userProfile.apiKeyUsageHint",
"newUi.sidebar.userProfile.apiKeyUsageHintHeader",
"newUi.sidebar.userProfile.apiKeyUser",
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.copiedToClipboard",
"newUi.sidebar.userProfile.copyApiKey",
"newUi.sidebar.userProfile.createApiKeyDescription",
"newUi.sidebar.userProfile.createApiKeyTitle",
"newUi.sidebar.userProfile.createKey",
"newUi.sidebar.userProfile.done",
"newUi.sidebar.userProfile.expiryDateLabel",
"newUi.sidebar.userProfile.newKey",
"newUi.sidebar.userProfile.noApiKeys",
"newUi.sidebar.userProfile.optional",
"newUi.sidebar.userProfile.sectionApiKeys",
],
"appearance": [
"looks.layoutLabel",
"newUi.sidebar.userProfile.accentColorLabel",
"newUi.sidebar.userProfile.appRailHoverPushesContent",
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
"newUi.sidebar.userProfile.colorPickerTooltip",
"newUi.sidebar.userProfile.commandPalette",
"newUi.sidebar.userProfile.commandPaletteDesc",
"newUi.sidebar.userProfile.confirmTabClose",
"newUi.sidebar.userProfile.confirmTabCloseDesc",
"newUi.sidebar.userProfile.disableUpdateChecks",
"newUi.sidebar.userProfile.disableUpdateChecksDesc",
"newUi.sidebar.userProfile.expandAppRailOnHover",
"newUi.sidebar.userProfile.expandAppRailOnHoverDesc",
"newUi.sidebar.userProfile.fontSizeLabel",
"newUi.sidebar.userProfile.interfaceFontDescription",
"newUi.sidebar.userProfile.interfaceFontLabel",
"newUi.sidebar.userProfile.keyboardShortcuts",
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
"newUi.sidebar.userProfile.languageLabel",
"newUi.sidebar.userProfile.manageShortcuts",
"newUi.sidebar.userProfile.navigationTabsDesc",
"newUi.sidebar.userProfile.pinAppRail",
"newUi.sidebar.userProfile.pinAppRailDesc",
"newUi.sidebar.userProfile.reopenTabsOnLogin",
"newUi.sidebar.userProfile.reopenTabsOnLoginDesc",
"newUi.sidebar.userProfile.sectionAppearance",
"newUi.sidebar.userProfile.settingsAppRail",
"newUi.sidebar.userProfile.settingsNavigation",
"newUi.sidebar.userProfile.settingsTabsAndShortcuts",
"newUi.sidebar.userProfile.settingsUpdates",
"newUi.sidebar.userProfile.showPinAppRailButton",
"newUi.sidebar.userProfile.showPinAppRailButtonDesc",
"newUi.sidebar.userProfile.showTabNumbers",
"newUi.sidebar.userProfile.showTabNumbersDesc",
"newUi.sidebar.userProfile.sidebarSettingsMoved",
"newUi.sidebar.userProfile.themeLabel",
],
"data": [
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.changeFile",
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountPermanent",
"newUi.sidebar.userProfile.deleteAccountWarning",
"newUi.sidebar.userProfile.deleting",
"newUi.sidebar.userProfile.export",
"newUi.sidebar.userProfile.exportData",
"newUi.sidebar.userProfile.exportDataDesc",
"newUi.sidebar.userProfile.exporting",
"newUi.sidebar.userProfile.import",
"newUi.sidebar.userProfile.importData",
"newUi.sidebar.userProfile.importDataDesc",
"newUi.sidebar.userProfile.importDataSelected",
"newUi.sidebar.userProfile.importing",
"newUi.sidebar.userProfile.sectionData",
"newUi.sidebar.userProfile.selectFile",
],
"interface": [
"newUi.sidebar.userProfile.interfacePresetDesc",
"newUi.sidebar.userProfile.presetClearOverrides",
"newUi.sidebar.userProfile.presetConfirmApply",
"newUi.sidebar.userProfile.presetConfirmDesc",
"newUi.sidebar.userProfile.presetConfirmTitle",
"newUi.sidebar.userProfile.presetOverridesTitle",
"newUi.sidebar.userProfile.presetRevertArea",
"newUi.sidebar.userProfile.runSetupAgainButton",
"newUi.sidebar.userProfile.runSetupAgainDesc",
"newUi.sidebar.userProfile.sectionInterface",
],
"keybindings": [
"newUi.sidebar.keybindings.actionLabel",
"newUi.sidebar.keybindings.actionRequiredError",
"newUi.sidebar.keybindings.addBinding",
"newUi.sidebar.keybindings.addBindingTitle",
"newUi.sidebar.keybindings.cancel",
"newUi.sidebar.keybindings.comboRequiredError",
"newUi.sidebar.keybindings.conflictWarning",
"newUi.sidebar.keybindings.customHeading",
"newUi.sidebar.keybindings.customize",
"newUi.sidebar.keybindings.customizedBadge",
"newUi.sidebar.keybindings.defaultBadge",
"newUi.sidebar.keybindings.defaultsHeading",
"newUi.sidebar.keybindings.deleteConfirm",
"newUi.sidebar.keybindings.description",
"newUi.sidebar.keybindings.editBindingTitle",
"newUi.sidebar.keybindings.loading",
"newUi.sidebar.keybindings.noCustomBindings",
"newUi.sidebar.keybindings.pressKeysPlaceholder",
"newUi.sidebar.keybindings.pressKeysToRecord",
"newUi.sidebar.keybindings.recording",
"newUi.sidebar.keybindings.resetAllConfirm",
"newUi.sidebar.keybindings.resetAllToDefaults",
"newUi.sidebar.keybindings.resetToDefault",
"newUi.sidebar.keybindings.saveBinding",
"newUi.sidebar.keybindings.saveError",
"newUi.sidebar.keybindings.unavailableAction",
],
"security": [
"newUi.sidebar.userProfile.changePassword",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.confirmPasswordPlaceholder",
"newUi.sidebar.userProfile.currentPasswordLabel",
"newUi.sidebar.userProfile.currentPasswordPlaceholder",
"newUi.sidebar.userProfile.newPasswordLabel",
"newUi.sidebar.userProfile.newPasswordPlaceholder",
"newUi.sidebar.userProfile.passwordFieldsRequired",
"newUi.sidebar.userProfile.passwordMismatch",
"newUi.sidebar.userProfile.passwordTooShort",
"newUi.sidebar.userProfile.passwordUpdateFailed",
"newUi.sidebar.userProfile.passwordUpdated",
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"desktopUpdates.checkNow",
"desktopUpdates.download",
"desktopUpdates.manualModeHint",
"desktopUpdates.mode",
"desktopUpdates.modeAutomatic",
"desktopUpdates.modeHint",
"desktopUpdates.modeNever",
"desktopUpdates.modePrompt",
"desktopUpdates.restart",
"desktopUpdates.status",
"desktopUpdates.statusAvailable",
"desktopUpdates.statusChecking",
"desktopUpdates.statusDownloading",
"desktopUpdates.statusError",
"desktopUpdates.statusIdle",
"desktopUpdates.statusReady",
"desktopUpdates.statusUpToDate",
"desktopUpdates.title",
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
"settings.updates.betaIntro",
"settings.updates.betaProgram",
"settings.updates.copied",
"settings.updates.copy",
"settings.updates.copyFailed",
"settings.updates.desktopInstall",
"settings.updates.dockerInstall",
"settings.updates.downloadBeta",
"settings.updates.feedback",
"settings.updates.feedbackHint",
"settings.updates.howToInstall",
"settings.updates.learnMore",
"settings.updates.newestBeta",
"settings.updates.newestStable",
"settings.updates.noRelease",
"settings.updates.openPlugins",
"settings.updates.pluginBetas",
"settings.updates.pluginBetasIntro",
"settings.updates.releases",
"settings.updates.runningBetaBuild",
"settings.updates.saveFailed",
"settings.updates.sendFeedback",
"settings.updates.termixBetas",
"settings.updates.termixBetasHint",
"settings.updates.thisVersion",
"settings.updates.waitingForStable",
],
};
-635
View File
@@ -1,635 +0,0 @@
import { InlineView } from "@/components/surface/surface-scope";
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertTriangle,
Cloud,
CloudOff,
Loader2,
RefreshCw,
Unlink,
} from "lucide-react";
import { Button } from "@/components/button";
import { Switch } from "@/components/switch";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache";
import {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
syncNow,
unlinkServer,
updateSyncSettings,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
type SyncProxyHeader,
type SyncStatus,
} from "@/api/sync-api";
import { LinkServerDialog } from "./LinkServerDialog";
import { ProxySettingsFields } from "./ProxySettingsFields";
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{description && (
<span className="text-[10px] text-muted-foreground leading-relaxed">
{description}
</span>
)}
</div>
{children}
</div>
);
}
function useRelativeTime() {
const { t } = useTranslation();
return (value: string | null | undefined) => {
if (!value) return t("sync.never");
const seconds = Math.max(
0,
Math.round((Date.now() - new Date(value).getTime()) / 1000),
);
if (seconds < 60) return t("sync.justNow");
if (seconds < 3600)
return t("sync.minutesAgo", { count: Math.round(seconds / 60) });
if (seconds < 86400)
return t("sync.hoursAgo", { count: Math.round(seconds / 3600) });
return new Date(value).toLocaleString();
};
}
function entityLabel(
t: (key: string, options?: Record<string, unknown>) => string,
entity: SyncEntityInfo,
): string {
return t(`sync.entities.${entity.type}`, { defaultValue: entity.type });
}
export function SyncPanel() {
const { t } = useTranslation();
const status = useSyncStatus();
const relativeTime = useRelativeTime();
const [linkOpen, setLinkOpen] = useState(false);
const [reloginOpen, setReloginOpen] = useState(false);
const [unlinkOpen, setUnlinkOpen] = useState(false);
const [keepData, setKeepData] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<SyncConflictItem[]>([]);
const [errors, setErrors] = useState<SyncErrorItem[]>([]);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [proxyDraft, setProxyDraft] = useState<{
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
} | null>(null);
const apply = useCallback((next: SyncStatus) => {
setSyncStatus(next);
notifySyncChanged();
}, []);
useEffect(() => {
window.electronAPI
?.invoke?.("get-desktop-settings")
.then((settings) => {
if (settings) setDesktopSettings(settings as DesktopSettings);
})
.catch(() => {});
}, []);
const conflictCount = status?.conflicts ?? 0;
const errorCount = status?.errors ?? 0;
useEffect(() => {
if (!status?.linked) return;
getSyncConflicts()
.then(setConflicts)
.catch(() => {});
}, [status?.linked, conflictCount, status?.lastSyncAt]);
useEffect(() => {
if (!status?.linked) return;
getSyncErrors()
.then(setErrors)
.catch(() => {});
}, [status?.linked, errorCount, status?.lastSyncAt]);
const run = async (key: string, action: () => Promise<void>) => {
setBusy(key);
try {
await action();
} catch {
toast.error(t("sync.actionFailed"));
} finally {
setBusy(null);
}
};
const groups = useMemo(() => {
const byOwner = new Map<string, SyncEntityInfo[]>();
for (const entity of status?.entities ?? []) {
const key = entity.owner === "core" ? "core" : entity.owner;
const list = byOwner.get(key) ?? [];
list.push(entity);
byOwner.set(key, list);
}
return [...byOwner.entries()].sort(([a], [b]) =>
a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b),
);
}, [status?.entities]);
if (!status) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
const linkDialog = (
<LinkServerDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onLinked={apply}
/>
);
if (!status.linked) {
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CloudOff className="size-4 text-muted-foreground" />
<span className="text-sm font-bold">{t("sync.unlinkedTitle")}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("sync.unlinkedBody")}
</p>
<ul className="text-xs text-muted-foreground list-disc pl-4 flex flex-col gap-1">
<li>{t("sync.unlinkedPointHosts")}</li>
<li>{t("sync.unlinkedPointOffline")}</li>
<li>{t("sync.unlinkedPointAccount")}</li>
</ul>
<Button
variant="outline"
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setLinkOpen(true)}
>
<Cloud className="size-4" />
{t("sync.linkButton")}
</Button>
</div>
{linkDialog}
</div>
);
}
const state = status.status ?? "idle";
const stateText =
state === "syncing"
? t("sync.state.syncing")
: state === "offline"
? t("sync.state.offline")
: state === "error"
? t("sync.state.error")
: state === "signed_out"
? t("sync.state.signedOut")
: t("sync.state.synced", { time: relativeTime(status.lastSyncAt) });
const toggleType = (type: string, enabled: boolean) =>
run(`type:${type}`, async () => {
const disabled = new Set<string>(
(status.entities ?? [])
.filter((entity) => !entity.enabled)
.map((entity) => entity.type),
);
if (enabled) disabled.delete(type);
else disabled.add(type);
apply(await updateSyncSettings({ disabledTypes: [...disabled] }));
});
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
invalidateServerStatusCache();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
};
const draft = proxyDraft ?? {
customHeaders: status.customHeaders ?? [],
basicAuth: status.basicAuth ?? null,
allowInvalidCertificate: !!status.allowInvalidCertificate,
};
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex items-start gap-2">
<Cloud className="size-4 text-accent-brand mt-0.5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">
{status.serverName || t("sync.server")}
</span>
<span className="text-[10px] text-muted-foreground break-all">
{status.serverUrl}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.account")}
</span>
<span className="font-semibold truncate">
{status.account?.username || "-"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.status")}
</span>
<span
className={`font-semibold flex items-center gap-1 ${
state === "idle" || state === "syncing"
? ""
: "text-destructive"
}`}
>
{state === "syncing" && (
<Loader2 className="size-3 animate-spin" />
)}
{stateText}
</span>
</div>
</div>
{status.lastError && state !== "idle" && state !== "syncing" && (
<p className="text-[10px] text-muted-foreground break-words">
{status.lastError}
</p>
)}
{!!status.pending && (
<p className="text-[10px] text-muted-foreground">
{t("sync.pending", { count: status.pending })}
</p>
)}
<div className="flex flex-wrap gap-2">
{state === "signed_out" ? (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setReloginOpen(true)}
>
{t("sync.signInAgain")}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
disabled={busy === "sync" || state === "syncing"}
onClick={() =>
run("sync", async () => {
apply(await syncNow());
})
}
>
{busy === "sync" || state === "syncing" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("sync.syncNow")}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
onClick={() => {
setKeepData(true);
setUnlinkOpen(true);
}}
>
<Unlink className="size-3" />
{t("sync.unlink")}
</Button>
</div>
</div>
{conflicts.length > 0 && (
<Section
title={t("sync.conflictsTitle")}
description={t("sync.conflictsDescription")}
>
{conflicts.map((conflict) => (
<div
key={conflict.id}
className="flex items-center justify-between gap-2 border border-border p-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">
{conflict.name}
</span>
<span className="text-[10px] text-muted-foreground">
{t(`sync.entities.${conflict.entityType}`, {
defaultValue: conflict.entityType,
})}
</span>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "mine");
refreshSyncStatus();
})
}
>
{t("sync.keepMine")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "server");
refreshSyncStatus();
})
}
>
{t("sync.keepServer")}
</Button>
</div>
</div>
))}
</Section>
)}
{errors.length > 0 && (
<Section
title={t("sync.errorsTitle")}
description={t("sync.errorsDescription")}
>
{errors.map((error) => (
<div
key={`${error.entityType}:${error.syncId}`}
className="flex items-start gap-2 border border-border p-2"
>
<AlertTriangle className="size-3.5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold">
{t(`sync.entities.${error.entityType}`, {
defaultValue: error.entityType,
})}
</span>
<span className="text-[10px] text-muted-foreground break-words">
{error.reason === "permission"
? t("sync.errorPermission")
: error.reason}
</span>
</div>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start h-7 text-[10px] rounded-none"
disabled={busy === "retry"}
onClick={() =>
run("retry", async () => {
apply(await retrySyncErrors());
})
}
>
{t("sync.retry")}
</Button>
</Section>
)}
<Section
title={t("sync.whatSyncs")}
description={t("sync.whatSyncsDescription")}
>
{groups.map(([owner, entities]) => (
<div key={owner} className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
{owner === "core"
? t("sync.coreGroup")
: (entities[0]?.ownerName ?? owner)}
</span>
{entities.map((entity) => (
<div
key={entity.type}
className="flex items-center justify-between gap-3"
>
<div className="flex flex-col min-w-0">
<span className="text-xs">{entityLabel(t, entity)}</span>
{!entity.onServer && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnServer")}
</span>
)}
{entity.onServer && !entity.onDevice && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnDevice")}
</span>
)}
</div>
<Switch
checked={entity.enabled}
disabled={busy === `type:${entity.type}`}
onCheckedChange={(checked) =>
void toggleType(entity.type, checked)
}
/>
</div>
))}
</div>
))}
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("sync.localOnlyHint")}
</p>
</Section>
<Section
title={t("sync.originTitle")}
description={t("sync.originDescription")}
>
<div className="flex border border-border overflow-hidden w-fit">
{(["local", "remote"] as const).map((origin) => (
<button
key={origin}
type="button"
onClick={() => void handleOriginChange(origin)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === origin
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{origin === "local"
? t("sync.originLocal")
: t("sync.originRemote")}
</button>
))}
</div>
</Section>
<Section
title={t("sync.proxy.title")}
description={t("sync.proxy.description")}
>
<ProxySettingsFields
customHeaders={draft.customHeaders}
basicAuth={draft.basicAuth}
onChange={(next) => setProxyDraft({ ...draft, ...next })}
/>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={draft.allowInvalidCertificate}
onCheckedChange={(checked) =>
setProxyDraft({ ...draft, allowInvalidCertificate: checked })
}
/>
</div>
{proxyDraft && (
<Button
variant="outline"
size="sm"
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy === "proxy"}
onClick={() =>
run("proxy", async () => {
apply(await updateSyncSettings(proxyDraft));
setProxyDraft(null);
toast.success(t("sync.proxy.saved"));
})
}
>
{t("common.save")}
</Button>
)}
</Section>
<LinkServerDialog
open={reloginOpen}
onOpenChange={setReloginOpen}
onLinked={apply}
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
/>
<InlineView
open={unlinkOpen}
onOpenChange={setUnlinkOpen}
title={t("sync.unlinkTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
className="rounded-none"
onClick={() => setUnlinkOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="rounded-none"
disabled={busy === "unlink"}
onClick={() =>
run("unlink", async () => {
apply(await unlinkServer(keepData));
setUnlinkOpen(false);
toast.success(t("sync.unlinked"));
})
}
>
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
{t("sync.unlink")}
</Button>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("sync.unlinkDescription")}
</p>
<div className="flex flex-col gap-2">
{(
[
{
value: true,
title: t("sync.unlinkKeepTitle"),
body: t("sync.unlinkKeepBody"),
},
{
value: false,
title: t("sync.unlinkRemoveTitle"),
body: t("sync.unlinkRemoveBody"),
},
] as const
).map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => setKeepData(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
keepData === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
</div>
</InlineView>
{linkDialog}
</div>
);
}
-604
View File
@@ -1,604 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Command as CommandPrimitive } from "cmdk";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Kbd } from "@/components/kbd";
import {
Command,
CommandItem,
CommandList,
CommandGroup,
CommandEmpty,
} from "@/components/command";
import {
BookOpen,
Bug,
Clock,
FlaskConical,
Folder,
Globe,
KeyRound,
MessagesSquare,
Pencil,
Plus,
Search,
Server,
Settings,
ShieldCheck,
} from "lucide-react";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import type { Host, TabType, Tab } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
import { useRailItems } from "@/sidebar/rail-items";
import {
defaultConnectAction,
hostActionsFor,
runHostAction,
useHostActions,
} from "@/sidebar/host-contributions";
import {
filterPaletteItems,
loadPaletteGroup,
paletteEntriesFor,
usePaletteEntries,
usePaletteGroups,
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
import { docsUrl } from "@/lib/docs";
import { parseServerTime } from "@/lib/server-time";
interface CommandPaletteProps {
/** Opens settings, optionally at one section. */
onOpenSettings?: (section?: string) => void;
/** Opens a host in the editor. */
onEditHost?: (host: Host) => void;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
hosts: Host[];
terminalTabs?: Tab[];
activeTabId?: string;
onOpenTab: (type: TabType, label?: string, pendingEvent?: string) => void;
/** Opens a sidebar panel. Kept separate from onOpenTab, which is TabType-shaped. */
onOpenPanel?: (view: string) => void;
}
export function CommandPalette({
isOpen,
setIsOpen,
hosts,
terminalTabs = [],
activeTabId = "",
onOpenTab,
onOpenPanel,
onOpenSettings,
onEditHost,
}: CommandPaletteProps) {
const { t } = useTranslation();
const railItems = useRailItems();
const hostActions = useHostActions();
const paletteEntries = usePaletteEntries();
const inputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
[],
);
const paletteGroups = usePaletteGroups();
const [groupItems, setGroupItems] = useState<
Record<string, PaletteItemDef[]>
>({});
const [selectedValue, setSelectedValue] = useState("");
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50);
setSearch("");
getRecentActivity(5)
.then(setRecentActivity)
.catch(() => {});
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
for (const group of paletteGroups) {
void loadPaletteGroup(group).then((items) => {
if (!cancelled) {
setGroupItems((prev) => ({ ...prev, [group.id]: items }));
}
});
}
return () => {
cancelled = true;
};
}, [isOpen, paletteGroups]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setIsOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, setIsOpen]);
const query = search.trim().toLowerCase();
const searching = query.length > 0;
// Without a query the list is a short starting point, not every host.
const filteredHosts = searching
? hosts.filter(
(h) =>
h.name.toLowerCase().includes(query) ||
h.ip.toLowerCase().includes(query) ||
h.username.toLowerCase().includes(query) ||
h.folder?.toLowerCase().includes(query) ||
h.tags?.some((tag) => tag.toLowerCase().includes(query)),
)
: hosts.slice(0, 6);
// Group hosts by folder; ungrouped hosts appear first.
const groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
const folderMap = new Map<string, Host[]>();
const ungrouped: Host[] = [];
for (const h of filteredHosts) {
if (h.folder && searching) {
if (!folderMap.has(h.folder)) folderMap.set(h.folder, []);
folderMap.get(h.folder)!.push(h);
} else {
ungrouped.push(h);
}
}
if (ungrouped.length > 0)
groupedHosts.push({ folder: null, hosts: ungrouped });
for (const [folder, fhosts] of folderMap) {
groupedHosts.push({ folder, hosts: fhosts });
}
useEffect(() => {
if (!isOpen) return;
const firstHost = filteredHosts[0];
setSelectedValue(searching && firstHost ? `host-${firstHost.id}` : "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, search]);
const activeTargetTab =
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
const handleAction = (action: () => void) => {
action();
setIsOpen(false);
};
const matches = (...texts: (string | undefined)[]) =>
!searching || texts.some((text) => text?.toLowerCase().includes(query));
const destinations = railItems.filter((item) => matches(t(item.labelKey)));
const quickActions = [
{
id: "quick-action-add-host",
label: t("commandPalette.addNewHost"),
icon: Plus,
run: () => onOpenTab("host-manager", undefined, "host-manager:add-host"),
},
{
id: "quick-action-add-credential",
label: t("commandPalette.addCredential"),
icon: KeyRound,
run: () =>
onOpenTab("host-manager", undefined, "host-manager:add-credential"),
},
{
id: "quick-action-settings",
label: t("nav.settings"),
icon: Settings,
run: () =>
onOpenSettings ? onOpenSettings() : onOpenTab("user-profile"),
},
{
id: "quick-action-admin-settings",
label: t("commandPalette.adminSettings"),
icon: ShieldCheck,
run: () =>
onOpenSettings
? onOpenSettings("admin-general")
: onOpenTab("admin-settings"),
},
].filter((action) => matches(action.label));
const globalEntries = paletteEntriesFor(paletteEntries, "global").filter(
(entry) => matches(t(entry.titleKey), ...(entry.keywords ?? [])),
);
const links = [
{
id: "link-docs",
label: t("commandPalette.documentation"),
icon: BookOpen,
url: docsUrl(),
},
{
id: "link-github",
label: "GitHub",
icon: Globe,
url: "https://github.com/Termix-SSH/Termix",
},
{
id: "link-discord",
label: "Discord",
icon: MessagesSquare,
url: "https://discord.com/invite/jVQGdvHDrf",
},
{
id: "link-report-bug",
label: t("dashboard.reportBug"),
icon: Bug,
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
},
...(isPrerelease(import.meta.env.VITE_APP_VERSION)
? [
{
id: "link-beta-feedback",
label: t("newUi.sidebar.userProfile.sendBetaFeedback"),
icon: FlaskConical,
url: reportBetaFeedbackUrl(import.meta.env.VITE_APP_VERSION),
},
]
: []),
].filter((link) => matches(link.label));
const rowClass = "group gap-2 rounded-none";
const iconClass = "size-3.5 shrink-0 text-muted-foreground";
const hostButton =
"flex size-6 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-accent-brand hover:text-accent-brand";
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/70 pt-[15vh]"
onClick={() => setIsOpen(false)}
>
<div
className="motion-context-enter mx-4 w-full max-w-2xl overflow-hidden border border-border bg-card shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Command
className="rounded-none"
shouldFilter={false}
loop
value={selectedValue}
onValueChange={setSelectedValue}
>
<div className="flex items-center border-b border-border px-3">
<Search className="mr-2 size-4 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
ref={inputRef}
value={search}
onValueChange={setSearch}
placeholder={t("commandPalette.searchPlaceholder")}
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<Kbd className="ml-2">ESC</Kbd>
</div>
<CommandList className="thin-scrollbar max-h-[60vh] [contain:content]">
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
{t("commandPalette.noHostsFound", { search })}
</CommandEmpty>
{!searching && recentActivity.length > 0 && (
<CommandGroup heading={t("commandPalette.recentActivity")}>
{recentActivity.slice(0, 4).map((item) => {
const Icon = activityTarget(item.type)?.icon ?? Server;
return (
<CommandItem
key={item.id}
value={`recent-activity-${item.id}`}
onSelect={() =>
handleAction(() => {
const target = activityTarget(item.type);
if (target) onOpenTab(target.tab, item.hostName);
})
}
className={rowClass}
>
<Icon className={iconClass} />
<span className="truncate">{item.hostName}</span>
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="size-3" />
{parseServerTime(item.timestamp).toLocaleDateString()}
</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredHosts.length > 0 && (
<CommandGroup heading={t("commandPalette.serversAndHosts")}>
{groupedHosts.map(({ folder, hosts: groupHosts }) => (
<div key={folder ?? "__root__"}>
{folder && (
<div className="flex items-center gap-1.5 px-2 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
<Folder className="size-3" />
{folder}
</div>
)}
{groupHosts.map((host) => {
const online =
getLiveHostStatus(Number(host.id)) === "online";
return (
<CommandItem
key={host.id}
value={`host-${host.id}`}
onSelect={() =>
handleAction(() => {
const action = defaultConnectAction(
hostActions,
host,
);
if (action) runHostAction(action, host, shell);
})
}
className={rowClass}
>
<Server
className={cn(
"size-3.5 shrink-0",
online
? "text-accent-brand"
: "text-muted-foreground",
)}
/>
<span className="truncate">{host.name}</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{host.username}@{host.ip}
</span>
{host.isShared && (
<span className="shrink-0 border border-accent-brand/30 bg-accent-brand/10 px-1 text-[9px] uppercase leading-none tracking-wider text-accent-brand">
{t("hosts.sharing.sharedBadge")}
</span>
)}
<span
className={cn(
"ml-auto flex shrink-0 items-center gap-1",
!searching &&
"opacity-0 transition-opacity group-hover:opacity-100 group-data-[selected=true]:opacity-100",
)}
>
{hostActionsFor(hostActions, host).map((action) => {
const Icon = action.icon;
const label =
action.label?.(host) ?? t(action.titleKey);
return (
<button
key={action.id}
type="button"
title={label}
aria-label={label}
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
runHostAction(action, host, shell),
);
}}
className={hostButton}
>
<Icon className="size-3" />
</button>
);
})}
{paletteEntriesFor(
paletteEntries,
"host",
host,
).map((entry) => {
const Icon = entry.icon;
return (
<button
key={entry.id}
type="button"
title={t(entry.titleKey)}
aria-label={t(entry.titleKey)}
onClick={(e) => {
e.stopPropagation();
handleAction(() => entry.run(shell, host));
}}
className={hostButton}
>
{Icon && <Icon className="size-3" />}
</button>
);
})}
{canEditHost(host) && (
<button
type="button"
title={t("hosts.editHost")}
aria-label={t("hosts.editHost")}
onClick={(e) => {
e.stopPropagation();
handleAction(() => onEditHost?.(host));
}}
className={hostButton}
>
<Pencil className="size-3" />
</button>
)}
</span>
</CommandItem>
);
})}
</div>
))}
</CommandGroup>
)}
{paletteGroups.map((group) => {
const items = filterPaletteItems(
groupItems[group.id] ?? [],
search,
group.showWhenEmpty,
);
if (items.length === 0) return null;
return (
<CommandGroup
key={`group-${group.id}`}
heading={t(group.titleKey)}
>
{items.map((item) => {
const Icon = item.icon;
const blocked = !!item.needsTarget && !activeTargetTab;
return (
<CommandItem
key={`${group.id}-${item.id}`}
value={`group-${group.id}-${item.id}`}
onSelect={() => {
if (blocked) return;
handleAction(() =>
item.run({ targetTab: activeTargetTab, shell }),
);
}}
className={cn(
rowClass,
blocked && "pointer-events-none opacity-50",
)}
>
{Icon && <Icon className={iconClass} />}
<span className="truncate">{item.title}</span>
{item.description && (
<span className="truncate font-mono text-[11px] text-muted-foreground">
{item.description}
</span>
)}
{(blocked || item.hint) && (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
{blocked
? t("commandPalette.noTargetTab")
: item.hint}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
);
})}
{onOpenPanel && destinations.length > 0 && (
<CommandGroup heading={t("commandPalette.navigation")}>
{destinations.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={`nav-${item.id}`}
value={`nav-${item.id}`}
onSelect={() =>
handleAction(() =>
item.kind === "tab"
? onOpenTab(item.id as TabType)
: onOpenPanel(item.id),
)
}
className={rowClass}
>
<Icon className={iconClass} />
<span className="truncate">{t(item.labelKey)}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{quickActions.length > 0 && (
<CommandGroup heading={t("commandPalette.quickActions")}>
{quickActions.map((action) => {
const Icon = action.icon;
return (
<CommandItem
key={action.id}
value={action.id}
onSelect={() => handleAction(action.run)}
className={rowClass}
>
<Icon className={iconClass} />
{action.label}
</CommandItem>
);
})}
</CommandGroup>
)}
{globalEntries.length > 0 && (
<CommandGroup heading={t("commandPalette.pluginActions")}>
{globalEntries.map((entry) => {
const Icon = entry.icon;
return (
<CommandItem
key={`plugin-${entry.id}`}
value={`plugin-${entry.id}`}
onSelect={() => handleAction(() => entry.run(shell))}
className={rowClass}
>
{Icon && <Icon className={iconClass} />}
{t(entry.titleKey)}
</CommandItem>
);
})}
</CommandGroup>
)}
{links.length > 0 && (
<CommandGroup heading={t("commandPalette.links")}>
{links.map((link) => {
const Icon = link.icon;
return (
<CommandItem
key={link.id}
value={link.id}
onSelect={() => window.open(link.url, "_blank")}
className={rowClass}
>
<Icon className={iconClass} />
{link.label}
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Kbd className="h-4">↑↓</Kbd>
{t("commandPalette.navigate")}
</span>
<span className="flex items-center gap-1">
<Kbd className="h-4">↵</Kbd>
{t("commandPalette.select")}
</span>
<span className="ml-auto flex items-center gap-1">
<Kbd className="h-4">Ctrl</Kbd>
<span className="opacity-50">+</span>
<Kbd className="h-4">K</Kbd>
<span className="px-1 opacity-50">
{t("commandPalette.orShortcut")}
</span>
<Kbd className="h-4">⇧⇧</Kbd>
</span>
</div>
</Command>
</div>
</div>
);
}
-175
View File
@@ -1,175 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import { useTranslation } from "react-i18next";
import {
ChevronLeft,
ChevronRight,
PanelRight,
RotateCcw,
SquareArrowOutUpRight,
} from "lucide-react";
import { PANEL } from "@/components/panel-layout";
import type { EditingWidth } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
export const DOCK_DEFAULT_WIDTH = 291;
export const DOCK_MIN_WIDTH = 160;
export const DOCK_MAX_WIDTH = 480;
const EDITING_WIDTH = 560;
const WIDE_EDITING_WIDTH = 760;
/** The width a dock renders at, in Normal-size pixels. */
export function dockWidth(width: number, editing: EditingWidth): number {
if (editing === "wide") return WIDE_EDITING_WIDTH;
if (editing) return Math.max(width, EDITING_WIDTH);
return width;
}
function HeaderButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="flex h-full w-12.5 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
>
{children}
</button>
);
}
/** The shared header and frame of the left sidebar and the right dock. */
export function DockPanel({
side,
title,
open,
width,
editing = false,
dragging = false,
fill = false,
onResizeStart,
onClose,
onOpenAsTab,
onMoveToRightDock,
onResetWidth,
children,
}: {
side: "left" | "right";
title: string;
open: boolean;
width: number;
editing?: EditingWidth;
dragging?: boolean;
/** Fill the parent instead of sizing itself, as in the mobile sheet. */
fill?: boolean;
onResizeStart?: (e: React.MouseEvent) => void;
onClose: () => void;
onOpenAsTab?: () => void;
onMoveToRightDock?: () => void;
onResetWidth?: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const CollapseIcon = side === "left" ? ChevronLeft : ChevronRight;
return (
<div
className={cn(
"relative flex min-h-0 flex-col overflow-hidden bg-sidebar",
fill ? "flex-1" : "shrink-0",
open &&
!fill && [
side === "left" ? "border-r" : "border-l",
"transition-colors",
dragging ? "border-accent-brand/60" : "border-border",
],
)}
style={
fill
? undefined
: {
width: open ? rem(dockWidth(width, editing)) : 0,
transition: dragging ? "none" : "width 0.2s",
}
}
>
<div
className={`flex ${PANEL.header} shrink-0 flex-row items-center border-b border-border`}
>
<span className="min-w-0 flex-1 truncate whitespace-nowrap px-3 text-base font-bold tracking-tight text-foreground">
{title}
</span>
{onOpenAsTab && (
<HeaderButton title={t("nav.openAsTab")} onClick={onOpenAsTab}>
<SquareArrowOutUpRight className="size-3.5" />
</HeaderButton>
)}
{onMoveToRightDock && (
<HeaderButton
title={t("nav.openInRightDock")}
onClick={onMoveToRightDock}
>
<PanelRight className="size-3.5" />
</HeaderButton>
)}
{onResetWidth && (
<HeaderButton
title={t("nav.resetSidebarWidth")}
onClick={onResetWidth}
>
<RotateCcw className="size-3.5" />
</HeaderButton>
)}
<HeaderButton
title={
side === "left" ? t("nav.collapseSidebar") : t("nav.closeRightDock")
}
onClick={onClose}
>
<CollapseIcon className="size-4" />
</HeaderButton>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{children}
</div>
{open && onResizeStart && !editing && (
<div
onMouseDown={onResizeStart}
className={cn(
"absolute bottom-0 top-0 z-30 w-1 cursor-col-resize transition-colors",
side === "left" ? "right-0" : "left-0",
dragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40",
)}
/>
)}
</div>
);
}
/** The thin strip that reopens a collapsed left dock. */
export function DockReopenStrip({ onClick }: { onClick: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClick}
title={t("nav.openSidebar")}
aria-label={t("nav.openSidebar")}
className="absolute bottom-0 left-0 top-0 z-20 flex w-6 items-center justify-center border-r border-border bg-sidebar text-muted-foreground transition-colors hover:bg-accent-brand/5 hover:text-accent-brand"
>
<ChevronRight className="size-3.5" />
</button>
);
}
-301
View File
@@ -1,301 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LogOut,
MoreHorizontal,
Search,
Settings,
SquareArrowOutUpRight,
X,
} from "lucide-react";
import type { TabType } from "@/types/ui-types";
import type { RailView } from "@/sidebar/AppRail";
import {
groupRailItems,
useRailItems,
type RailItemDef,
} from "@/sidebar/rail-items";
import { useHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { RailBadge } from "@/sidebar/RailBadge";
import { cn } from "@/lib/utils";
import { isElectron } from "@/lib/electron";
const PRIMARY_COUNT = 4;
/** The four bar slots: flagged items first, then the rest in rail order. */
export function pickPrimaryItems(items: RailItemDef[]): RailItemDef[] {
const usable = items.filter((item) => item.kind !== "tab");
return [
...usable.filter((item) => item.mobilePrimary),
...usable.filter((item) => !item.mobilePrimary),
].slice(0, PRIMARY_COUNT);
}
/**
* The rail on a phone: the most used destinations on the bar, everything else
* in a sheet behind More with search, settings and logout.
*/
export function MobileBar({
railView,
sidebarOpen,
username,
onRailClick,
onOpenTab,
onOpenSettings,
onOpenPalette,
onLogout,
}: {
railView: RailView;
sidebarOpen: boolean;
username: string;
onRailClick: (view: RailView) => void;
onOpenTab: (type: TabType) => void;
onOpenSettings: () => void;
onOpenPalette: () => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const railItems = useRailItems();
const hidden = useHiddenRailTabs();
const visible = useMemo(
() => railItems.filter((item) => !hidden.has(item.id)),
[railItems, hidden],
);
const main = visible.filter((item) => item.placement !== "footer");
const footer = visible.filter((item) => item.placement === "footer");
const primary = useMemo(() => pickPrimaryItems(main), [main]);
const bands = useMemo(() => groupRailItems(main), [main]);
const badged = footer.find((item) => item.useBadge);
const close = () => setMoreOpen(false);
const open = (item: RailItemDef) => {
close();
if (item.kind === "tab") onOpenTab(item.id as TabType);
else onRailClick(item.id as RailView);
};
return (
<>
<nav
aria-label={t("nav.mobileNavigation")}
className="flex shrink-0 items-stretch border-t border-border bg-sidebar safe-bottom md:hidden"
>
{primary.map((item) => (
<BarButton
key={item.id}
icon={item.icon}
label={t(item.labelKey)}
active={sidebarOpen && railView === item.id}
useBadge={item.useBadge}
onClick={() => open(item)}
/>
))}
<BarButton
icon={MoreHorizontal}
label={t("common.more")}
active={moreOpen}
useBadge={badged?.useBadge}
onClick={() => setMoreOpen(true)}
/>
</nav>
{moreOpen && (
<div className="fixed inset-0 z-[200] flex flex-col justify-end md:hidden">
<button
type="button"
aria-label={t("common.close")}
className="absolute inset-0 bg-overlay"
onClick={close}
/>
<div
role="dialog"
aria-label={t("common.more")}
className="motion-panel-enter relative flex max-h-[80dvh] flex-col border-t border-border bg-sidebar safe-bottom"
>
<div className="flex h-12.5 shrink-0 items-center border-b border-border px-3">
<span className="flex-1 text-base font-bold tracking-tight text-foreground">
{t("common.more")}
</span>
<button
type="button"
onClick={close}
aria-label={t("common.close")}
className="flex size-9 items-center justify-center text-muted-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-2">
<SheetRow
icon={Search}
label={t("nav.search")}
onClick={() => {
close();
onOpenPalette();
}}
/>
{bands.map((band) => (
<div key={band.group} className="mt-1 flex flex-col">
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{t(`nav.group.${band.group}`)}
</div>
{band.items.map((item) => (
<SheetRow
key={item.id}
icon={item.icon}
label={t(item.labelKey)}
useBadge={item.useBadge}
active={sidebarOpen && railView === item.id}
onOpenAsTab={
item.promotable
? () => {
close();
onOpenTab(item.id as TabType);
}
: undefined
}
onClick={() => open(item)}
/>
))}
</div>
))}
<div className="mt-2 border-t border-border pt-2">
{footer.map((item) => (
<SheetRow
key={item.id}
icon={item.icon}
label={t(item.labelKey)}
useBadge={item.useBadge}
active={sidebarOpen && railView === item.id}
onClick={() => open(item)}
/>
))}
<SheetRow
icon={Settings}
label={t("nav.settings")}
hint={username}
onClick={() => {
close();
onOpenSettings();
}}
/>
{!isElectron() && (
<SheetRow
icon={LogOut}
label={t("common.logout")}
onClick={() => {
close();
onLogout();
}}
/>
)}
</div>
</div>
</div>
</div>
)}
</>
);
}
function BarButton({
icon: Icon,
label,
active,
useBadge,
onClick,
}: {
icon: React.ElementType;
label: string;
active?: boolean;
useBadge?: () => number | null | undefined;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={!!active}
className={cn(
"relative flex h-14 flex-1 flex-col items-center justify-center gap-1 transition-colors",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground active:bg-muted/60",
)}
>
<span className="relative flex items-center justify-center">
<Icon className="size-5" />
{useBadge && (
<RailBadge useBadge={useBadge} className="-right-2.5 -top-1.5" />
)}
</span>
<span className="max-w-full truncate px-1 text-[10px] font-medium leading-none">
{label}
</span>
</button>
);
}
function SheetRow({
icon: Icon,
label,
hint,
useBadge,
active,
onClick,
onOpenAsTab,
}: {
icon: React.ElementType;
label: string;
hint?: string;
useBadge?: () => number | null | undefined;
active?: boolean;
onClick: () => void;
onOpenAsTab?: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-stretch">
<button
type="button"
onClick={onClick}
className={cn(
"flex min-h-11 flex-1 items-center gap-3 border-l-2 px-3 text-left transition-colors",
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-foreground active:bg-muted",
)}
>
<span className="relative flex shrink-0">
<Icon className="size-4 text-muted-foreground" />
{useBadge && (
<RailBadge useBadge={useBadge} className="-right-2 -top-1.5" />
)}
</span>
<span className="flex-1 truncate text-sm font-medium">{label}</span>
{hint && (
<span className="shrink-0 truncate text-[11px] text-muted-foreground">
{hint}
</span>
)}
</button>
{onOpenAsTab && (
<button
type="button"
onClick={onOpenAsTab}
aria-label={t("nav.openAsTab")}
title={t("nav.openAsTab")}
className="flex w-11 shrink-0 items-center justify-center text-muted-foreground active:bg-muted"
>
<SquareArrowOutUpRight className="size-3.5" />
</button>
)}
</div>
);
}
+2 -68
View File
@@ -1,80 +1,15 @@
import { lazy, Suspense } from "react";
import { isElectron } from "@/lib/electron";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { ComponentSlot } from "@/shell/ActionSlot";
import { OnboardingHost } from "@/onboarding/OnboardingHost";
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
import { getTabType } from "@/shell/tab-registry";
import { TabPortals } from "@/shell/stage/TabPortals";
import { LayoutHost } from "@/shell/layout/LayoutHost";
import {
ShellControllerProvider,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
// Not needed for first paint.
const CommandPalette = lazy(() =>
import("@/shell/CommandPalette").then((m) => ({
default: m.CommandPalette,
})),
);
/** Core's palette, opened by any layout through the shell's palette.open(). */
function ShellPalette() {
const shell = useShellController();
const { tabs, hosts, palette } = shell;
if (!palette.isOpen) return null;
return (
<Suspense fallback={null}>
<CommandPalette
isOpen={palette.isOpen}
setIsOpen={palette.setOpen}
hosts={hosts.list}
terminalTabs={tabs.terminalTabs}
activeTabId={tabs.activeId}
onOpenPanel={(view) => shell.regions.railClick(view)}
onEditHost={hosts.edit}
onOpenSettings={(section) =>
tabs.openSingleton(
"settings",
undefined,
undefined,
section ? { section } : undefined,
)
}
onOpenTab={(type, label, pendingEvent) => {
if (
[
"dashboard",
"host-manager",
"user-profile",
"admin-settings",
"plugins",
].includes(type)
) {
tabs.openSingleton(type, pendingEvent);
} else if (getTabType(type)?.multiInstance) {
tabs.openMultiInstance(type);
} else if (getTabType(type)?.singleton) {
// A singleton plugin tab, optionally preselecting a host.
tabs.openSingleton(
type,
undefined,
label ? hosts.list.find((h) => h.name === label) : undefined,
);
} else if (label) {
const host = hosts.list.find((h) => h.name === label);
if (host) tabs.open(host, type);
}
}}
/>
</Suspense>
);
}
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
/**
* The signed-in app: the shell's state, the chosen layout, every tab's
* content and the pieces core always owns (palette, overlays, onboarding).
* content and the pieces core always owns (overlays, onboarding).
*/
export function ShellRoot({
username,
@@ -89,7 +24,6 @@ export function ShellRoot({
<LayoutHost />
<TabPortals />
<ComponentSlot slotId="shell.overlay" />
<ShellPalette />
<OnboardingHost />
{isElectron() && <DesktopUpdateToasts />}
</ShellControllerProvider>
-905
View File
@@ -1,905 +0,0 @@
import {
useRef,
useEffect,
useLayoutEffect,
useState,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
ChevronDown,
ChevronUp,
RefreshCw,
X,
Plus,
Pencil,
Columns2,
Rows2,
Ungroup,
Maximize2,
Minimize2,
PanelRight,
} from "lucide-react";
import { tabIcon } from "@/shell/tabUtils";
import { isSessionTabType } from "@/shell/tab-registry";
import { isElectron } from "@/lib/electron";
import type { Tab, TabType } from "@/types/ui-types";
import { ActionSlot } from "@/shell/ActionSlot";
import { useTabHandlesVersion } from "@/shell/tab-handles";
import {
canJoinSplit,
isSplitTab,
type SplitSummary,
type TabSplitAction,
} from "@/shell/split/split-tabs";
import { MAX_PANES } from "@/shell/split/split-tree";
import { SplitLayoutMenu } from "@/shell/split/SplitLayoutMenu";
import {
beginSplitDrag,
endSplitDrag,
isSplitDragging,
moveSplitDrag,
} from "@/shell/split/split-drag";
/** How far below the bar a dragged tab has to go before it drags into the view. */
const DRAG_OUT_DISTANCE = 16;
/**
* Tabs holding a live connection that can be refreshed: the registered
* session tabs.
*/
function isConnectionTab(type: TabType): boolean {
return isSessionTabType(type);
}
export function TabBar({
tabs,
activeTabId,
splits,
activeSplitFull,
onSetActiveTab,
onCloseTab,
onRefreshTab,
onReconnectDisconnected,
onReorderTabs,
onSplitAction,
onRenameTab,
isAppFullscreen,
onToggleAppFullscreen,
rightDockOpen,
onToggleRightDock,
showTabNumbers,
}: {
tabs: Tab[];
activeTabId: string;
/** Every split tab and its panes, for the "Add to split" entries. */
splits: SplitSummary[];
/** The active tab is a split with no room for another pane. */
activeSplitFull: boolean;
onSetActiveTab: (id: string) => void;
onCloseTab: (id: string) => void;
onRefreshTab: (id: string) => void;
onReconnectDisconnected?: () => void;
onReorderTabs: (tabs: Tab[]) => void;
onSplitAction: (action: TabSplitAction) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
isAppFullscreen: boolean;
onToggleAppFullscreen: () => void;
rightDockOpen?: boolean;
onToggleRightDock?: () => void;
showTabNumbers?: boolean;
}) {
const { t } = useTranslation();
// Re-renders when a tab's handle attaches, for buttons whose when reads it.
useTabHandlesVersion();
const reduceMotion = useReducedMotion();
const [open, setOpen] = useState(true);
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
const [draggingOut, setDraggingOut] = useState(false);
const [contextTabId, setContextTabId] = useState<string | null>(null);
const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>(
null,
);
const [renamingTabId, setRenamingTabId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
const tabBarRef = useRef<HTMLDivElement>(null);
const tabEls = useRef<Map<string, HTMLDivElement>>(new Map());
const dragData = useRef<{
id: string;
index: number;
startX: number;
startY: number;
offsetX: number;
width: number;
barTop: number;
barHeight: number;
x: number;
y: number;
} | null>(null);
const dragTargetRef = useRef<number | null>(null);
const didDrag = useRef(false);
// Hand-rolled instead of a Framer layoutId: a shared layoutId matched
// multiple tabs sharing the same indicator element across re-renders, and
// reordering tabs (which doesn't change which tab is active) could make it
// measure the wrong tab's rect or animate a spurious slide. Measuring the
// active tab's own DOM node directly is always correct.
const [indicatorRect, setIndicatorRect] = useState<{
left: number;
width: number;
} | null>(null);
const skipIndicatorAnimRef = useRef(false);
const activeIsSplit = isSplitTab(tabs.find((tab) => tab.id === activeTabId));
const measureIndicator = useCallback(() => {
const el = activeTabId ? tabEls.current.get(activeTabId) : null;
if (!el) {
setIndicatorRect(null);
return;
}
setIndicatorRect({ left: el.offsetLeft, width: el.offsetWidth });
}, [activeTabId]);
// useLayoutEffect so the indicator is measured for the new tab order before
// paint -- with useEffect there was a frame right after a drag-drop reorder
// where the indicator (which stays hidden during the drag) reappeared at
// its pre-reorder position before this caught up, flashing at the old spot.
useLayoutEffect(() => {
measureIndicator();
}, [measureIndicator, tabs, dragTargetIndex]);
useEffect(() => {
const el = tabBarRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measureIndicator);
observer.observe(el);
return () => observer.disconnect();
}, [measureIndicator]);
useEffect(() => {
if (!skipIndicatorAnimRef.current) return;
const id = requestAnimationFrame(() => {
skipIndicatorAnimRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [indicatorRect]);
useEffect(() => {
const el = tabBarRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
useEffect(() => {
if (!dragTabId) return;
function onPointerMove(e: PointerEvent) {
if (!dragData.current || !tabBarRef.current) return;
const d = dragData.current;
if (Math.abs(e.clientX - d.startX) > 5) didDrag.current = true;
const barRect = tabBarRef.current.getBoundingClientRect();
// Dragged down out of the bar: the tab heads for the view to be split.
const dragged = tabs.find((t) => t.id === d.id);
if (
canJoinSplit(dragged) &&
e.clientY > barRect.bottom + DRAG_OUT_DISTANCE
) {
didDrag.current = true;
if (isSplitDragging()) moveSplitDrag(e.clientX, e.clientY);
else
beginSplitDrag(
{
kind: "tab",
tabId: d.id,
label: dragged!.customLabel || dragged!.label,
},
e.clientX,
e.clientY,
);
setDraggingOut(true);
dragTargetRef.current = d.index;
setDragTargetIndex(d.index);
return;
}
if (isSplitDragging()) {
endSplitDrag(false);
setDraggingOut(false);
}
const x = Math.max(
barRect.left + 2,
Math.min(barRect.right - d.width - 6, e.clientX - d.offsetX),
);
const y = d.barTop;
setDragPos({ x, y });
const centerX = e.clientX - d.offsetX + d.width / 2;
let newTarget = d.index;
tabEls.current.forEach((el, id) => {
if (id === d.id) return;
const rect = el.getBoundingClientRect();
const mid = rect.left + rect.width / 2;
const idx = tabs.findIndex((t) => t.id === id);
if (idx < d.index && centerX < mid)
newTarget = Math.min(newTarget, idx);
if (idx > d.index && centerX > mid)
newTarget = Math.max(newTarget, idx);
});
if (tabs[0].type === "dashboard") newTarget = Math.max(1, newTarget);
dragTargetRef.current = newTarget;
setDragTargetIndex(newTarget);
}
function endDrag(commit: boolean) {
if (!dragData.current) return;
const { id, index } = dragData.current;
const to = dragTargetRef.current ?? index;
const droppedOut = isSplitDragging();
if (droppedOut) endSplitDrag(commit);
setDraggingOut(false);
if (commit && !droppedOut && to !== index) {
const next = [...tabs];
if (next[0].id !== id) next.splice(to, 0, next.splice(index, 1)[0]);
skipIndicatorAnimRef.current = true;
onReorderTabs(next);
}
dragData.current = null;
dragTargetRef.current = null;
setDragTabId(null);
setDragTargetIndex(null);
setDragPos(null);
setTimeout(() => {
didDrag.current = false;
}, 0);
}
function onPointerUp() {
endDrag(true);
}
// The browser can abort a pointer gesture without ever firing pointerup
// (OS/browser cancels it, focus leaves the window, capture is lost some
// other way). Without this, dragTabId stays stuck non-null forever and
// every tab keeps rendering with a stale translateX from the aborted
// drag, which can visually butt two tabs together with no seam between
// them until something else forces a re-render.
function onPointerCancel() {
endDrag(false);
}
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel);
window.addEventListener("lostpointercapture", onPointerCancel);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerCancel);
window.removeEventListener("lostpointercapture", onPointerCancel);
};
}, [dragTabId, tabs, onReorderTabs]);
useEffect(() => {
if (!contextTabId) return;
function onDown(e: MouseEvent) {
if (!(e.target as HTMLElement).closest("[data-context-menu]")) {
setContextTabId(null);
setContextPos(null);
}
}
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [contextTabId]);
useEffect(() => {
if (renamingTabId) {
setTimeout(() => renameInputRef.current?.focus(), 0);
}
}, [renamingTabId]);
function commitRename() {
if (!renamingTabId) return;
const trimmed = renameValue.trim();
if (trimmed) onRenameTab?.(renamingTabId, trimmed);
setRenamingTabId(null);
}
const dragIdx = tabs.findIndex((t) => t.id === dragTabId);
const target = dragTargetIndex ?? dragIdx;
return (
<div className="flex flex-col shrink-0 min-w-0">
<div
className={`flex items-end bg-sidebar min-w-0 transition-[height,border-color] duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
>
<div
ref={tabBarRef}
className="relative flex h-full flex-1 min-w-0 overflow-x-auto scrollbar-none pl-px"
>
{indicatorRect && !dragTabId && (
<span
data-tab-indicator={activeTabId}
className="pointer-events-none absolute bottom-0 h-0.5 bg-accent-brand z-10"
style={{
left: indicatorRect.left,
width: indicatorRect.width,
transition:
reduceMotion || skipIndicatorAnimRef.current
? "none"
: "left 200ms ease, width 200ms ease",
}}
/>
)}
{tabs.map((tab, index) => {
const active = tab.id === activeTabId;
const isDragging = dragTabId === tab.id;
let translateX = 0;
if (
dragTabId &&
!isDragging &&
dragIdx !== -1 &&
target !== null &&
target !== dragIdx
) {
const draggedWidth =
tabEls.current.get(dragTabId)?.offsetWidth ?? 0;
if (dragIdx < target && index > dragIdx && index <= target)
translateX = -draggedWidth;
else if (dragIdx > target && index < dragIdx && index >= target)
translateX = draggedWidth;
}
return (
<div
key={tab.id}
ref={(el) => {
if (el) tabEls.current.set(tab.id, el);
else tabEls.current.delete(tab.id);
}}
onClick={() =>
!dragTabId && !didDrag.current && onSetActiveTab(tab.id)
}
onMouseDown={(e) => {
if (e.button === 1 && tab.type !== "dashboard") {
e.preventDefault();
onCloseTab(tab.id);
}
}}
onContextMenu={(e) => {
if (tab.type === "dashboard") return;
e.preventDefault();
setContextTabId(tab.id);
setContextPos({ x: e.clientX, y: e.clientY });
}}
onPointerDown={(e) => {
if (e.button !== 0 || tab.type === "dashboard") return;
e.preventDefault();
const el = tabEls.current.get(tab.id);
if (!el || !tabBarRef.current) return;
const rect = el.getBoundingClientRect();
const barRect = tabBarRef.current.getBoundingClientRect();
dragData.current = {
id: tab.id,
index,
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
width: rect.width,
barTop: barRect.top,
barHeight: barRect.height,
x: rect.left,
y: barRect.top,
};
setDragTabId(tab.id);
setDragTargetIndex(index);
setDragPos({ x: rect.left, y: barRect.top });
(e.currentTarget as HTMLElement).setPointerCapture(
e.pointerId,
);
}}
style={{
// Only set a transform while a drag is actually shifting
// tabs around. Leaving a permanent translateX(0) on every
// tab gives each one its own transform node, which opts it
// out of the pixel snapping a plain box gets when painted.
// The 1px seam then lands on a fractional device pixel at
// non-100% zoom and anti-aliases down to nothing, so two
// tabs look merged into one.
transform:
dragTabId && !isDragging
? `translateX(${translateX}px)`
: undefined,
transition:
dragTabId && !isDragging ? "transform 200ms ease" : "none",
opacity: isDragging ? 0 : 1,
cursor:
tab.type === "dashboard"
? "pointer"
: isDragging
? "grabbing"
: "grab",
userSelect: "none",
}}
className={`group/tab relative flex items-center gap-2 shrink-0 transition-colors border-r border-border text-sm
${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""}
${
tab.type === "dashboard"
? `px-2.5 md:px-3.5 ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
: `px-2.5 md:px-4 font-medium ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
}`}
>
{showTabNumbers && tab.type !== "dashboard" && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
{tabs.slice(0, index).filter((t) => t.type !== "dashboard")
.length + 1}
</span>
)}
{tabIcon(tab.type)}
{tab.type !== "dashboard" && renamingTabId === tab.id ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
else if (e.key === "Escape") setRenamingTabId(null);
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-accent-brand outline-none text-sm w-28 min-w-0"
style={{ fontWeight: "inherit" }}
/>
) : (
tab.type !== "dashboard" && tab.label
)}
{tab.type !== "dashboard" && renamingTabId !== tab.id && (
<div
className={`flex items-center gap-0.5 ml-1 ${active ? "opacity-100" : "opacity-0 group-hover/tab:opacity-100"}`}
>
{isConnectionTab(tab.type) && (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onRefreshTab(tab.id);
}}
title={t("nav.refreshTab")}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
<RefreshCw className="size-3" />
</button>
)}
{/* Plugins add small buttons here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.inline"
when={{ tab, handle: tab.terminalRef?.current }}
context={() => [tab.terminalRef?.current, tab]}
renderItem={(contribution, invoke) => (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
invoke();
}}
title={t(contribution.titleKey)}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
{contribution.icon && (
<contribution.icon className="size-3" />
)}
</button>
)}
/>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X className="size-3" />
</button>
</div>
)}
</div>
);
})}
{dragTabId &&
dragPos &&
!draggingOut &&
(() => {
const tab = tabs.find((t) => t.id === dragTabId)!;
const active = tab.id === activeTabId;
const dragTabWidth = tabEls.current.get(dragTabId)?.offsetWidth;
const dragTabHeight = tabEls.current.get(dragTabId)?.offsetHeight;
return (
<div
style={{
position: "fixed",
left: dragPos.x,
top: dragPos.y,
width:
dragTabWidth !== undefined ? dragTabWidth + 2 : undefined,
height:
dragTabHeight !== undefined
? dragTabHeight + 1
: undefined,
pointerEvents: "none",
zIndex: 9999,
opacity: 0.85,
}}
className={`flex items-center gap-2 shrink-0 border-x border-b border-border text-sm shadow-lg
${
tab.type === "dashboard"
? `px-3.5 ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "bg-sidebar text-muted-foreground"}`
: `px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "bg-sidebar text-muted-foreground"}`
}`}
>
{tabIcon(tab.type)}
{tab.type !== "dashboard" && tab.label}
</div>
);
})()}
</div>
<div
className={`flex items-center h-full shrink-0 ${open ? "" : "invisible"}`}
>
<Separator orientation="vertical" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="w-56 border-t-0 [clip-path:inset(0px_-4px_-4px_-4px)] p-0"
>
{tabs.map((tab, index) => (
<div
key={tab.id}
onClick={() => onSetActiveTab(tab.id)}
className={`flex items-center justify-between px-2 py-2 text-xs cursor-default hover:bg-accent hover:text-accent-foreground ${tab.id === activeTabId ? "text-foreground" : "text-muted-foreground"}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{showTabNumbers && tab.type !== "dashboard" && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
{tabs
.slice(0, index)
.filter((t) => t.type !== "dashboard").length + 1}
</span>
)}
{tabIcon(tab.type)}
<span className="truncate">
{tab.type === "dashboard"
? t("nav.dashboard")
: tab.label}
</span>
</div>
{tab.type !== "dashboard" && (
<button
onClick={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
className="shrink-0 ml-2 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" />
<SplitLayoutMenu
activeIsSplit={activeIsSplit}
canAddPane={!activeSplitFull}
onSplit={(edge) => onSplitAction({ kind: "splitActive", edge })}
onPreset={(presetId) => onSplitAction({ kind: "preset", presetId })}
/>
{onToggleRightDock && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className={`h-full w-12.5 rounded-none ${rightDockOpen ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
title={t("nav.toggleRightDock")}
aria-label={t("nav.toggleRightDock")}
aria-pressed={!!rightDockOpen}
onClick={onToggleRightDock}
>
<PanelRight className="size-4" />
</Button>
</>
)}
{!isElectron() && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title={
isAppFullscreen
? t("nav.exitFullscreen")
: t("nav.enterFullscreen")
}
aria-label={
isAppFullscreen
? t("nav.exitFullscreen")
: t("nav.enterFullscreen")
}
onClick={onToggleAppFullscreen}
>
{isAppFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</Button>
</>
)}
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
onClick={() => setOpen((o) => !o)}
>
<ChevronUp
className={`size-4 transition-transform ${open ? "" : "rotate-180"}`}
/>
</Button>
</div>
</div>
{!open && (
<button
onClick={() => setOpen(true)}
className="flex items-center justify-center w-full h-6 bg-sidebar border-b border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors shrink-0"
>
<ChevronDown className="size-3.5" />
</button>
)}
{/* Right-click context menu */}
{contextTabId &&
contextPos &&
(() => {
const ctxTab = tabs.find((t) => t.id === contextTabId);
if (!ctxTab) return null;
const joinable = canJoinSplit(ctxTab);
const itemClass =
"flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none";
const runSplit = (action: TabSplitAction) => {
onSplitAction(action);
setContextTabId(null);
};
const menuTop = Math.min(contextPos.y, window.innerHeight - 160);
const menuLeft = Math.min(contextPos.x, window.innerWidth - 240);
return (
<div
data-context-menu
style={{
position: "fixed",
left: Math.max(4, menuLeft),
top: Math.max(4, menuTop),
maxHeight: window.innerHeight - Math.max(4, menuTop) - 8,
zIndex: 10000,
}}
className="bg-popover border border-border shadow-lg py-1 min-w-[200px] max-w-[260px] overflow-y-auto"
>
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground truncate max-w-[200px]">
{ctxTab.label}
</div>
<div className="h-px bg-border my-1" />
{isConnectionTab(ctxTab.type) && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onRefreshTab(contextTabId);
setContextTabId(null);
}}
>
<RefreshCw className="size-3" />
{t("nav.refreshTab")}
</button>
)}
{onReconnectDisconnected && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onReconnectDisconnected();
setContextTabId(null);
}}
>
<RefreshCw className="size-3" />
{t("nav.reconnectDisconnectedTerminals")}
</button>
)}
{/* Plugins add entries here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.menu"
when={{ tab: ctxTab, handle: ctxTab.terminalRef?.current }}
context={() => [ctxTab.terminalRef?.current, ctxTab]}
renderItem={(contribution, invoke) => (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
invoke();
setContextTabId(null);
}}
>
{contribution.icon && (
<contribution.icon className="size-3" />
)}
{t(contribution.titleKey)}
</button>
)}
/>
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setRenameValue(ctxTab.label);
setRenamingTabId(contextTabId);
setContextTabId(null);
setContextPos(null);
}}
>
<Pencil className="size-3" />
{t("nav.renameTab")}
</button>
{isSplitTab(ctxTab) && (
<>
<div className="h-px bg-border my-1" />
<button
className={itemClass}
onClick={() =>
runSplit({ kind: "unsplit", splitTabId: ctxTab.id })
}
>
<Ungroup className="size-3" />
{t("splitScreen.unsplit")}
</button>
</>
)}
{joinable && (
<>
<div className="h-px bg-border my-1" />
<button
className={itemClass}
onClick={() =>
runSplit({
kind: "split",
tabId: ctxTab.id,
edge: "right",
})
}
>
<Columns2 className="size-3" />
{t("splitScreen.splitRight")}
</button>
<button
className={itemClass}
onClick={() =>
runSplit({
kind: "split",
tabId: ctxTab.id,
edge: "bottom",
})
}
>
<Rows2 className="size-3" />
{t("splitScreen.splitDown")}
</button>
{splits.map((split) => (
<div key={split.id} className="flex flex-col">
<div className="h-px bg-border my-1" />
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">
{t("splitScreen.addToSplit", { split: split.label })}
</div>
{split.panes.map((pane) => (
<button
key={pane.id}
className={`${itemClass} pl-5`}
onClick={() =>
runSplit({
kind: "addToPane",
tabId: ctxTab.id,
splitTabId: split.id,
paneId: pane.id,
})
}
>
<span
className={`truncate ${pane.label ? "" : "text-muted-foreground"}`}
>
{pane.label
? t("splitScreen.paneItem", {
index: pane.index,
label: pane.label,
})
: t("splitScreen.paneEmptyItem", {
index: pane.index,
})}
</span>
</button>
))}
{(["right", "bottom"] as const).map((edge) => (
<button
key={edge}
className={`${itemClass} pl-5`}
disabled={split.full}
title={
split.full
? t("splitScreen.maxPanes", { count: MAX_PANES })
: undefined
}
onClick={() =>
runSplit({
kind: "addPane",
tabId: ctxTab.id,
splitTabId: split.id,
edge,
})
}
>
<Plus className="size-3" />
{edge === "right"
? t("splitScreen.newPaneRight")
: t("splitScreen.newPaneBelow")}
</button>
))}
</div>
))}
</>
)}
<div className="h-px bg-border my-1" />
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground text-destructive"
onClick={() => {
onCloseTab(contextTabId);
setContextTabId(null);
}}
>
<X className="size-3" />
{t("nav.close")}
</button>
</div>
);
})()}
</div>
);
}
@@ -21,7 +21,11 @@ import type { Host, TabType } from "@/types/ui-types";
import { requestManage } from "@/manage/manage-requests";
import { useRailItems } from "@/sidebar/rail-items";
import { usePanels } from "@/shell/panel-registry";
import { getTabType, type TabShellCallbacks } from "@/shell/tab-registry";
import {
getTabType,
useTabTypes,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { setShellCallbacks } from "@/plugin-host/shell-bridge";
import {
@@ -83,6 +87,7 @@ export function ShellControllerProvider({
// Re-render when plugins add or remove rail items, panels or tabs.
useRailItems();
usePanels();
useTabTypes();
const { settled: pluginsSettled } = usePluginStore();
const isMobile = useIsMobile();
const [stageStore] = useState(createTabStageStore);
+3 -1
View File
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { EditingWidth } from "@/components/surface/surface-scope";
import { remScale } from "@/lib/rem";
import { rightDockableIds } from "@/sidebar/rail-items";
import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel";
import type { ShellRegions } from "./types";
const MIN_WIDTH = 160;
@@ -48,6 +47,9 @@ function startDockDrag(
}
/** The left sidebar and the right dock: what they show, open state and size. */
/** A dock's width after Reset width, in Normal-size pixels. */
export const DOCK_DEFAULT_WIDTH = 291;
export function useRegions(isMobile: boolean): ShellRegions {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState<string>("hosts");
-3
View File
@@ -12,9 +12,6 @@ import { layoutStatus } from "@/look/layout-status";
import { safeLayoutInUrl } from "@/look/resolve-look";
import { useLookTheme } from "@/look/use-look-theme";
import { RepairView } from "@/look/RepairView";
import { registerBuiltinLayout } from "./builtin";
registerBuiltinLayout();
/** Catches a crash while a layout draws, so the repair view can take over. */
class LayoutErrorBoundary extends Component<
@@ -1,396 +0,0 @@
import { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent } from "@/components/sheet";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { MobileBar } from "@/shell/MobileBar";
import { AppRail } from "@/sidebar/AppRail";
import { ComponentSlot } from "@/shell/ActionSlot";
import {
isCoreRailView,
railItemLabel,
promotableIds,
rightDockableIds,
} from "@/sidebar/rail-items";
import { DockPanel, DockReopenStrip } from "@/shell/DockPanel";
import { TabBar } from "@/shell/TabBar";
import { requestManage, type ManageMode } from "@/manage/manage-requests";
import { getTabType } from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
import type { TabType } from "@/types/ui-types";
import {
ShellConfirmCapture,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
import { TabStage } from "@/shell/stage/TabStage";
const HostsPanel = lazy(() =>
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
);
const QuickConnectPanel = lazy(() =>
import("@/sidebar/QuickConnectPanel").then((m) => ({
default: m.QuickConnectPanel,
})),
);
const SyncPanel = lazy(() =>
import("@/settings/sync/SyncPanel").then((m) => ({
default: m.SyncPanel,
})),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
const ConnectionsPanel = lazy(() =>
import("@/sidebar/ConnectionsPanel").then((m) => ({
default: m.ConnectionsPanel,
})),
);
function SidebarPanelFallback() {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
/**
* The look Termix has always had: icon rail, left sidebar, tab bar, right
* dock and the mobile bottom bar. Built in until it moves to its own plugin.
*/
export function DefaultLayout() {
const { t } = useTranslation();
const shell = useShellController();
const registeredPanels = usePanels();
const { tabs, regions, hosts, isMobile } = shell;
const { left, right } = regions;
const closeOnMobile = () => {
if (isMobile) left.setOpen(false);
};
// 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 = "",
) => (
<SurfaceScope
key={view}
kind="panel"
onEditingChange={owned ? regions.scopeEditingSetter(view) : undefined}
className={`${shown ? "" : "hidden"} ${className}`}
>
{children}
</SurfaceScope>
);
/**
* Sidebar panel content, shared between the desktop sidebar, the mobile
* sheet and the right dock. Takes the view rather than reading the left
* dock's so both docks can render from the same code.
*
* `owned` marks the dock responsible for the panels that stay mounted while
* hidden (hosts, credentials, fleets). Only one dock may own them, otherwise
* two live instances fight over the shared editing state.
*/
const renderSidebarPanels = (railView: string, owned = true) => (
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{owned && (
<>
{panelScope(
"hosts",
owned,
railView === "hosts",
<HostsPanel
onOpenTab={(host, type, options) => {
tabs.connectHost(host, type, options);
closeOnMobile();
}}
onEditHost={hosts.edit}
onCloseSidebar={() => left.setOpen(false)}
hostTree={hosts.tree ?? undefined}
loading={hosts.loading}
onEditingChange={regions.panelEditingSetter("hosts")}
active={railView === "hosts"}
/>,
)}
{panelScope(
"credentials",
owned,
railView === "credentials",
<CredentialsPanel
onEditingChange={regions.panelEditingSetter("credentials")}
active={railView === "credentials"}
/>,
)}
</>
)}
{railView === "quick-connect" &&
panelScope(
"quick-connect",
owned,
true,
<QuickConnectPanel
onConnect={(host, type) => {
tabs.open(host, type);
closeOnMobile();
}}
/>,
)}
{registeredPanels.map((panel) => {
const shown = railView === panel.id;
// A kept-mounted panel lives in the owning dock only, so two live
// copies never fight over the same state.
if (panel.keepMounted ? !owned : !shown) return null;
const Panel = panel.component;
return panelScope(
panel.id,
owned,
shown,
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<Panel
targetTab={tabs.targetTerminal}
active={shown}
shell={shell.panelCallbacks}
setEditing={
owned ? regions.panelEditingSetter(panel.id) : () => undefined
}
activeTabType={tabs.active?.type ?? undefined}
placement={owned ? "left" : "right"}
/>
</div>,
);
})}
{!isCoreRailView(railView) && !getPanel(railView) && (
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
)}
{railView === "connections" &&
panelScope(
"connections",
owned,
true,
<div className="flex-1 min-h-0 overflow-y-auto">
<ConnectionsPanel
tabs={tabs.list}
activeTabId={tabs.activeId}
allHosts={hosts.list}
backgroundTabRecords={tabs.background}
onSwitchToTab={(tabId) => {
tabs.setActive(tabId);
closeOnMobile();
}}
onCloseTab={tabs.close}
onReopenTab={(record, restoredSessionId) => {
const host = record.hostId
? hosts.list.find((h) => h.id === String(record.hostId))
: undefined;
if (!host && !getTabType(record.tabType)?.hostless) return;
tabs.setBackground((prev) =>
prev.filter((r) => r.id !== record.id),
);
if (host) {
const effectiveSessionId =
restoredSessionId ?? record.backendSessionId ?? null;
tabs.open(host, record.tabType as TabType, {
instanceId: record.id,
restoredSessionId: effectiveSessionId,
savedLabel: record.label,
});
} else {
tabs.openSingleton(record.tabType as TabType);
}
closeOnMobile();
}}
onForgetBackground={(recordId) => {
tabs.setBackground((prev) =>
prev.filter((r) => r.id !== recordId),
);
}}
onRenameTab={tabs.rename}
onReorderTabs={tabs.setList}
/>
</div>,
)}
{railView === "sync" &&
panelScope(
"sync",
owned,
true,
<div className="flex-1 min-h-0 overflow-y-auto">
<SyncPanel />
</div>,
)}
</div>
</Suspense>
);
const sidebarPanelContent = renderSidebarPanels(left.view);
const dockProps = (view: string) => ({
// Titles come from the shared rail definitions so they stay translated
// and in step with the rail itself.
title: railItemLabel(view, t),
onOpenAsTab: isMobile
? undefined
: view === "hosts" || view === "credentials"
? () => requestManage({ kind: "browse", mode: view as ManageMode })
: promotableIds().includes(view)
? () => tabs.openSingleton(view as TabType)
: undefined,
});
return (
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail, desktop only */}
<AppRail
railView={left.view}
sidebarOpen={left.open}
username={shell.username}
isAdmin={shell.isAdmin}
pluginsSettled={shell.pluginsSettled}
onRailClick={regions.railClick}
onOpenTab={tabs.openSingleton}
onOpenInRightDock={regions.openInRightDock}
onOpenSettings={() => tabs.openSingleton("settings")}
onOpenNavigationSettings={() =>
tabs.openSingleton("settings", undefined, undefined, {
section: "appearance",
reveal: "settings-navigation",
revealAt: Date.now(),
})
}
onOpenPalette={shell.palette.open}
onLogout={shell.logout}
/>
{/* Desktop: inline resizable sidebar */}
{!isMobile && (
<DockPanel
side="left"
{...dockProps(left.view)}
open={left.open}
width={left.width}
editing={left.editing}
dragging={left.dragging}
onResizeStart={left.startResize}
onClose={() => left.setOpen(false)}
onMoveToRightDock={
rightDockableIds().includes(left.view)
? () => regions.openInRightDock(left.view)
: undefined
}
onResetWidth={left.resetWidth}
>
{sidebarPanelContent}
</DockPanel>
)}
{/* Mobile: sidebar as overlay sheet */}
{isMobile && (
<Sheet open={left.open} onOpenChange={left.setOpen}>
<SheetContent
side="left"
showCloseButton={false}
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${left.editing ? "w-screen" : "w-[min(85vw,360px)]"}`}
style={{ height: "100dvh" }}
>
<DockPanel
side="left"
{...dockProps(left.view)}
open
fill
width={left.width}
onClose={() => left.setOpen(false)}
>
{sidebarPanelContent}
</DockPanel>
</SheetContent>
</Sheet>
)}
{/* Main content area */}
<div
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !left.open ? "pl-6" : ""}`}
>
{!isMobile && !left.open && (
<DockReopenStrip onClick={() => left.setOpen(true)} />
)}
<SurfaceScope kind="tab" className="overflow-hidden">
<ShellConfirmCapture />
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
<TabBar
tabs={tabs.topLevel}
activeTabId={tabs.activeId}
splits={shell.split.summaries}
activeSplitFull={shell.split.activeFull}
onSetActiveTab={tabs.setActive}
onCloseTab={tabs.close}
onRefreshTab={tabs.refresh}
onReconnectDisconnected={tabs.reconnectAll}
onReorderTabs={tabs.reorder}
onSplitAction={shell.split.handleAction}
onRenameTab={tabs.rename}
isAppFullscreen={shell.fullscreen.active}
onToggleAppFullscreen={shell.fullscreen.toggle}
rightDockOpen={right.view !== null}
onToggleRightDock={
isMobile ? undefined : regions.toggleRightDock
}
showTabNumbers={tabs.showNumbers}
/>
<TabStage />
</div>
</SurfaceScope>
{/* Bottom nav bar, mobile only. The desktop app keeps the rail. */}
{!shell.isElectron && (
<MobileBar
railView={left.view}
sidebarOpen={left.open}
username={shell.username}
onRailClick={regions.railClick}
onOpenTab={(type) => tabs.openSingleton(type)}
onOpenSettings={() => tabs.openSingleton("settings")}
onOpenPalette={shell.palette.open}
onLogout={() => shell.logout({ manual: true })}
/>
)}
</div>
{/* Right dock, desktop only, holds a second reference panel */}
{!isMobile && right.view && (
<DockPanel
side="right"
{...dockProps(right.view)}
open
width={right.width}
dragging={right.dragging}
onResizeStart={right.startResize}
onClose={() => right.setView(null)}
onResetWidth={right.resetWidth}
>
{renderSidebarPanels(right.view, false)}
</DockPanel>
)}
{/* Plugin panels pinned to the right edge, sized by the plugin */}
<ComponentSlot slotId="shell.rightEdge" />
</div>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { BUILTIN_LAYOUT_ID, registerLayout } from "@/look/layout-registry";
import { DefaultLayout } from "./DefaultLayout";
let registered = false;
/** Core's own layout, there whatever plugins are installed. */
export function registerBuiltinLayout(): void {
if (registered) return;
registered = true;
registerLayout({ id: BUILTIN_LAYOUT_ID, component: DefaultLayout });
}
-1
View File
@@ -50,7 +50,6 @@ export function TabPortals() {
>
{renderTabContent(tab, {
shell: shell.callbacks,
settings: shell.settings,
panelTargetTab: targetTerminal,
isVisible: inPane || activeInline,
isFocusedPane,
+1 -85
View File
@@ -1,16 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import {
requestFromLegacyEvent,
requestManage,
} from "@/manage/manage-requests";
import {
LayoutDashboard,
LayoutPanelLeft,
Puzzle,
LibraryBig,
Server,
Settings,
} from "lucide-react";
import { LayoutPanelLeft, Puzzle, Server, Settings } from "lucide-react";
import { lazy, memo, Suspense } from "react";
import { useTranslation } from "react-i18next";
import type { Tab, TabType } from "@/types/ui-types";
@@ -23,27 +12,15 @@ import {
type TabTypeDef,
} from "./tab-registry";
import { getPanel, type PanelDef } from "./panel-registry";
import type { SettingsTabProps } from "@/settings/SettingsTab";
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
// Heavy tab surfaces, kept out of the AppShell critical path.
const ManageTab = lazy(() =>
import("@/manage/ManageTab").then((m) => ({ default: m.ManageTab })),
);
const SettingsTab = lazy(() =>
import("@/settings/SettingsTab").then((m) => ({ default: m.SettingsTab })),
);
const PluginsTab = lazy(() =>
import("@/plugins/PluginsTab").then((m) => ({ default: m.PluginsTab })),
);
const DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
})),
);
/** Download a likely next tab without starting a connection or mounting UI. */
export function preloadTabSurface(type: TabType): void {
@@ -104,11 +81,6 @@ function PanelTabFrame({ children }: { children: React.ReactNode }) {
export function tabIcon(type: TabType) {
switch (type) {
case "dashboard":
return <LayoutDashboard className="size-3.5" />;
case "host-manager":
return <LibraryBig className="size-3.5" />;
case "settings":
case "user-profile":
case "admin-settings":
return <Settings className="size-3.5" />;
@@ -176,29 +148,6 @@ const RegisteredTab = memo(function RegisteredTab({
);
});
const DashboardTabHost = memo(function DashboardTabHost({
shell,
isVisible,
}: {
shell: TabShellCallbacks;
isVisible: boolean;
}) {
return withTabSuspense(
<DashboardTab
onOpenSingletonTab={(type, pendingEvent) => {
// "Add host" and friends say what to open in the Manage tab.
const request = pendingEvent
? requestFromLegacyEvent(pendingEvent)
: null;
if (request) requestManage(request);
else shell.openSingletonTab(type);
}}
onOpenTab={(host, type) => shell.openTab(host, type)}
isVisible={isVisible}
/>,
);
});
/** A rail panel opened as a tab when its plugin registers no tab of its own. */
const PanelAsTab = memo(function PanelAsTab({
panel,
@@ -229,8 +178,6 @@ function noop() {}
export interface TabRenderContext {
shell: TabShellCallbacks;
/** What the Settings tab needs from the shell. */
settings?: Omit<SettingsTabProps, "section" | "sectionAt">;
/** The terminal a panel shown as a tab sends commands to. */
panelTargetTab?: Tab;
isVisible?: boolean;
@@ -247,43 +194,12 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
} = context;
switch (tab.type) {
case "dashboard":
return <DashboardTabHost shell={shell} isVisible={isVisible} />;
case "split-screen":
return null;
case "host-manager":
return withTabSuspense(<ManageTab />);
case "plugins":
return withTabSuspense(<PluginsTab />);
case "settings":
return context.settings
? withTabSuspense(
<SettingsTab
{...context.settings}
section={
typeof tab.data?.section === "string"
? tab.data.section
: undefined
}
sectionAt={
typeof tab.data?.revealAt === "number" ||
typeof tab.data?.revealAt === "string"
? tab.data.revealAt
: undefined
}
reveal={
typeof tab.data?.reveal === "string"
? `${tab.data.reveal}@${String(tab.data.revealAt ?? "")}`
: undefined
}
/>,
)
: null;
case "user-profile":
case "admin-settings":
return null;
-259
View File
@@ -1,259 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { deleteApiKey, type ApiKey } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
type AdminApiKeysSectionProps = {
open: boolean;
onToggle: () => void;
apiKeys: ApiKey[];
setApiKeys: Dispatch<SetStateAction<ApiKey[]>>;
loadApiKeys: () => void;
showCreateKey: boolean;
setShowCreateKey: Dispatch<SetStateAction<boolean>>;
createdKeyToken: string | null;
setCreatedKeyToken: Dispatch<SetStateAction<string | null>>;
newKeyName: string;
setNewKeyName: Dispatch<SetStateAction<string>>;
newKeyUserId: string;
setNewKeyUserId: Dispatch<SetStateAction<string>>;
newKeyExpiry: string;
setNewKeyExpiry: Dispatch<SetStateAction<string>>;
users: AdminUser[];
handleCreateApiKey: () => void;
newKeyLoading: boolean;
};
export function AdminApiKeysSection({
open,
onToggle,
apiKeys,
setApiKeys,
loadApiKeys,
showCreateKey,
setShowCreateKey,
createdKeyToken,
setCreatedKeyToken,
newKeyName,
setNewKeyName,
newKeyUserId,
setNewKeyUserId,
newKeyExpiry,
setNewKeyExpiry,
users,
handleCreateApiKey,
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
label={t("admin.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
<br />
{t("admin.apiKeysCliHint")}{" "}
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadApiKeys}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setShowCreateKey((o) => !o);
setCreatedKeyToken(null);
}}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
</div>
{showCreateKey && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newApiKey")}
</span>
{createdKeyToken ? (
<div className="flex flex-col gap-2">
<span className="text-[10px] text-accent-brand font-semibold">
{t("admin.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">
{createdKeyToken}
</span>
<button
onClick={() => {
copyToClipboard(createdKeyToken);
toast.info(t("admin.copiedToClipboard"));
}}
className="text-muted-foreground hover:text-accent-brand shrink-0"
>
<Copy className="size-3.5" />
</button>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs self-end"
onClick={() => {
setShowCreateKey(false);
setCreatedKeyToken(null);
}}
>
{t("admin.done")}
</Button>
</div>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("placeholders.apiKeyName")}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyUser")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Select2
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
value={newKeyUserId}
onChange={(e) => setNewKeyUserId(e.target.value)}
>
<option value="">{t("admin.apiKeySelectUser")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyExpiresAt")}
</label>
<Input
type="date"
value={newKeyExpiry}
onChange={(e) => setNewKeyExpiry(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setShowCreateKey(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateApiKey}
disabled={newKeyLoading}
>
{newKeyLoading ? t("admin.creating") : t("admin.createKey")}
</Button>
</div>
</>
)}
</div>
)}
{apiKeys.map((key) => (
<div
key={key.id}
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{key.name}
</span>
{!key.isActive && (
<span className="text-[9px] font-semibold px-1 py-px border border-destructive/40 bg-destructive/10 text-destructive">
{t("admin.revokedBadge")}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeyUser")}: {key.username}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate">
{key.tokenPrefix}…
</span>
<span className="flex gap-2.5 text-[10px] text-muted-foreground">
<span>{key.createdAt.split("T")[0]}</span>
<span>
{key.expiresAt
? key.expiresAt.split("T")[0]
: t("admin.apiKeyNoExpiry")}
</span>
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", { name: key.name }),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
toast.success(
t("admin.revokeKeySuccess", { name: key.name }),
);
} catch {
toast.error(t("admin.revokeKeyFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
</AccordionSection>
);
}
-491
View File
@@ -1,491 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
ChevronLeft,
ChevronRight,
ClipboardList,
RefreshCw,
X,
} from "lucide-react";
import { AccordionSection } from "./AdminSettingsShared";
import {
getAuditLogs,
getAuditLogActions,
getAuditForwarding,
updateAuditForwarding,
type AuditLog,
type AuditLogFilters,
} from "@/api/audit-log-api";
import { toast } from "sonner";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { parseServerTime } from "@/lib/server-time";
const RESOURCE_TYPES = [
"user",
"host",
"credential",
"plugin",
"session",
"api_key",
"setting",
];
type AdminAuditLogSectionProps = {
open: boolean;
onToggle: () => void;
users: AdminUser[];
};
export function AdminAuditLogSection({
open,
onToggle,
users,
}: AdminAuditLogSectionProps) {
const { t } = useTranslation();
const [logs, setLogs] = useState<AuditLog[]>([]);
const [forwardUrl, setForwardUrl] = useState("");
const [forwardToken, setForwardToken] = useState("");
const [forwardHasToken, setForwardHasToken] = useState(false);
const [forwardEnvConfigured, setForwardEnvConfigured] = useState(false);
const [forwardSaving, setForwardSaving] = useState(false);
useEffect(() => {
if (!open) return;
getAuditForwarding()
.then((settings) => {
setForwardUrl(settings.url);
setForwardHasToken(settings.hasToken);
setForwardEnvConfigured(settings.envConfigured);
})
.catch(() => {});
}, [open]);
async function handleSaveForwarding() {
setForwardSaving(true);
try {
const result = await updateAuditForwarding(
forwardUrl,
forwardToken || undefined,
);
setForwardHasToken(result.hasToken || (!!result.url && forwardHasToken));
setForwardToken("");
toast.success(t("admin.auditForwardingSaved"));
} catch {
toast.error(t("admin.auditForwardingSaveError"));
} finally {
setForwardSaving(false);
}
}
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [actions, setActions] = useState<string[]>([]);
const [filterUserId, setFilterUserId] = useState("");
const [filterAction, setFilterAction] = useState("");
const [filterResourceType, setFilterResourceType] = useState("");
const [filterSuccess, setFilterSuccess] = useState<"" | "true" | "false">("");
const [filterStartDate, setFilterStartDate] = useState("");
const [filterEndDate, setFilterEndDate] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchLogs = useCallback(
async (filters: AuditLogFilters, pg: number) => {
setLoading(true);
try {
const result = await getAuditLogs({ ...filters, page: pg, limit: 50 });
setLogs(result.logs);
setTotal(result.total);
setTotalPages(result.totalPages);
} catch {
// non-fatal
} finally {
setLoading(false);
}
},
[],
);
const buildFilters = useCallback((): AuditLogFilters => {
const f: AuditLogFilters = {};
if (filterUserId) f.userId = filterUserId;
if (filterAction) f.action = filterAction;
if (filterResourceType) f.resourceType = filterResourceType;
if (filterSuccess !== "") f.success = filterSuccess === "true";
if (filterStartDate) f.startDate = filterStartDate;
if (filterEndDate) f.endDate = filterEndDate;
return f;
}, [
filterUserId,
filterAction,
filterResourceType,
filterSuccess,
filterStartDate,
filterEndDate,
]);
useEffect(() => {
if (!open) return;
getAuditLogActions()
.then((r) => setActions(r.actions))
.catch(() => {});
fetchLogs(buildFilters(), 1);
setPage(1);
}, [open, buildFilters, fetchLogs]);
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
fetchLogs(buildFilters(), 1);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [
open,
buildFilters,
fetchLogs,
filterUserId,
filterAction,
filterResourceType,
filterSuccess,
filterStartDate,
filterEndDate,
]);
function clearFilters() {
setFilterUserId("");
setFilterAction("");
setFilterResourceType("");
setFilterSuccess("");
setFilterStartDate("");
setFilterEndDate("");
}
function handlePageChange(newPage: number) {
setPage(newPage);
fetchLogs(buildFilters(), newPage);
}
function formatTimestamp(ts: string) {
try {
return parseServerTime(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return ts;
}
}
const hasFilters =
filterUserId ||
filterAction ||
filterResourceType ||
filterSuccess !== "" ||
filterStartDate ||
filterEndDate;
return (
<AccordionSection
label={t("admin.sectionAuditLog")}
icon={<ClipboardList className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2 gap-2">
{/* SIEM forwarding */}
<div className="flex flex-col gap-1.5 pb-2 border-b border-border">
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditForwardingTitle")}
</span>
<p className="text-[10px] text-muted-foreground">
{t("admin.auditForwardingDesc")}
{forwardEnvConfigured && (
<> {t("admin.auditForwardingEnvNotice")}</>
)}
</p>
<Input
placeholder="https://siem.example.com/ingest"
value={forwardUrl}
onChange={(e) => setForwardUrl(e.target.value)}
className="text-[10px] h-7"
/>
<Input
type="password"
placeholder={
forwardHasToken
? t("admin.auditForwardingTokenStored")
: t("admin.auditForwardingToken")
}
value={forwardToken}
onChange={(e) => setForwardToken(e.target.value)}
className="text-[10px] h-7"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] self-start"
onClick={handleSaveForwarding}
disabled={forwardSaving}
>
{t("common.save")}
</Button>
</div>
{/* Filters */}
<div className="grid grid-cols-2 gap-1.5">
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterUser")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{(Array.isArray(users) ? users : []).map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterAction")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{actions.map((a) => (
<option key={a} value={a}>
{a.replace(/_/g, " ")}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterResourceType")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterResourceType}
onChange={(e) => setFilterResourceType(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{RESOURCE_TYPES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterStatus")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterSuccess}
onChange={(e) =>
setFilterSuccess(e.target.value as "" | "true" | "false")
}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
<option value="true">{t("admin.auditLogSuccess")}</option>
<option value="false">{t("admin.auditLogFailed")}</option>
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterFrom")}
</label>
<Input
type="datetime-local"
value={filterStartDate}
onChange={(e) => setFilterStartDate(e.target.value)}
className="text-[10px] h-7"
/>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterTo")}
</label>
<Input
type="datetime-local"
value={filterEndDate}
onChange={(e) => setFilterEndDate(e.target.value)}
className="text-[10px] h-7"
/>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-[10px] text-muted-foreground">
{t("admin.auditLogTotal", { total })}
</span>
<div className="flex items-center gap-1">
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] text-muted-foreground hover:text-foreground gap-1"
onClick={clearFilters}
>
<X className="size-3" />
{t("admin.auditLogClearFilters")}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => fetchLogs(buildFilters(), page)}
disabled={loading}
>
<RefreshCw
className={`size-3 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Log table */}
{logs.length === 0 && !loading ? (
<div className="py-6 text-center text-[10px] text-muted-foreground">
{t("admin.auditLogEmpty")}
</div>
) : (
<div className="flex flex-col">
{logs.map((log) => (
<div
key={log.id}
className="border-b border-border last:border-0 cursor-pointer hover:bg-muted/20 transition-colors"
onClick={() =>
setExpandedId(expandedId === log.id ? null : log.id)
}
>
<div className="flex items-start gap-2 py-2">
<span
className={`shrink-0 mt-0.5 w-1.5 h-1.5 rounded-full ${log.success ? "bg-green-500" : "bg-destructive"}`}
/>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] font-semibold text-foreground">
{(log.action || "unknown").replace(/_/g, " ")}
</span>
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
{log.resourceType}
</span>
{log.resourceName && (
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{log.resourceName}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground font-semibold">
{log.username}
</span>
<span className="text-[9px] text-muted-foreground">
{formatTimestamp(log.timestamp)}
</span>
</div>
</div>
<span
className={`shrink-0 text-[9px] font-semibold px-1 py-px border ${
log.success
? "border-green-500/40 text-green-500 bg-green-500/10"
: "border-destructive/40 text-destructive bg-destructive/10"
}`}
>
{log.success
? t("admin.auditLogSuccess")
: t("admin.auditLogFailed")}
</span>
</div>
{expandedId === log.id && (
<div className="ml-3.5 pb-2 flex flex-col gap-1">
{log.ipAddress && (
<span className="text-[9px] text-muted-foreground">
{t("admin.auditLogIp")}: {log.ipAddress}
</span>
)}
{log.resourceId && (
<span className="text-[9px] text-muted-foreground">
{t("admin.auditLogResourceId")}: {log.resourceId}
</span>
)}
{log.details && (
<span className="text-[9px] text-muted-foreground font-mono break-all">
{log.details}
</span>
)}
{log.errorMessage && (
<span className="text-[9px] text-destructive">
{log.errorMessage}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-1 border-t border-border">
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={page <= 1 || loading}
onClick={() => handlePageChange(page - 1)}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] text-muted-foreground">
{t("admin.auditLogPage", { page, totalPages, total })}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={page >= totalPages || loading}
onClick={() => handlePageChange(page + 1)}
>
<ChevronRight className="size-3" />
</Button>
</div>
)}
</div>
</AccordionSection>
);
}
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more