mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move the classic layout into its own bundled plugin
This commit is contained in:
1 parent
86161d66e2
commit
d049588f7f
234 files changed
+3066
-76205
No files matched your search
@@ -11,4 +11,3 @@ db
|
||||
*.min.css
|
||||
openapi.json
|
||||
drizzle/
|
||||
src/ui/settings/settings-search-index.ts
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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>>
|
||||
@@ -33,6 +33,7 @@ const OFFICIAL_IDS = [
|
||||
"ai",
|
||||
"alerts",
|
||||
"automations",
|
||||
"classic-layout",
|
||||
"docker",
|
||||
"file-manager",
|
||||
"fleets",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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}:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" }])];
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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
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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -50,7 +50,6 @@ export function TabPortals() {
|
||||
>
|
||||
{renderTabContent(tab, {
|
||||
shell: shell.callbacks,
|
||||
settings: shell.settings,
|
||||
panelTargetTab: targetTerminal,
|
||||
isVisible: inPane || activeInline,
|
||||
isFocusedPane,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user