feat: port the demo redesign with Manage and Settings tabs

This commit is contained in:
LukeGus committed 2026-10-05 16:23:21 -05:00
1 parent f8151cb885
commit 35c954b79a
128 files changed
+13262 -8143

No files matched your search

+2
View File
@@ -23,3 +23,5 @@ drizzle/
# Helm templates contain Go template syntax, not plain YAML
charts/*/templates/
src/ui/settings/settings-search-index.ts
+1 -1
View File
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-test-locations.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
+63 -2
View File
@@ -210,6 +210,21 @@ export interface ShellApi {
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */
closeRailView: (id: string) => void;
/**
* Asks a yes or no question over the main area, for code that runs outside
* a panel or tab (a keybinding, a toolbar action). Inside one, prefer
* useConfirm from @termix/plugin-sdk/ui, which asks over that surface.
*/
confirm?: (options: ConfirmRequest) => Promise<boolean>;
}
export interface ConfirmRequest {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Red confirm button. Defaults to true. */
destructive?: boolean;
}
export interface TabsApi extends Pick<
@@ -260,6 +275,11 @@ export interface RailItemContribution {
/** Desktop app only. */
electronOnly?: boolean;
separatorAfter?: boolean;
/**
* Which band of the rail it sits in: things you keep (objects), things you
* do (tools) or the instance itself (system). Default "tools".
*/
group?: "objects" | "tools" | "system";
/** Hidden without being unregistered, e.g. while a feature is switched off. */
hidden?: boolean;
/**
@@ -293,8 +313,11 @@ export interface PanelProps {
/** Whether the panel is the one currently shown. */
active: boolean;
shell: ShellApi;
/** Tells the shell the panel is editing, which widens the sidebar. */
setEditing: (editing: boolean) => void;
/**
* Tells the shell the panel is editing, which widens the sidebar. "wide"
* widens it further for tables. InlineView does this on its own.
*/
setEditing: (editing: boolean | "wide") => void;
/** Type of the focused tab, if any. */
activeTabType?: string;
/** Where the panel is rendered. */
@@ -323,6 +346,8 @@ export interface TabHandle {
/** Repaint only. The shell calls it every time the tab becomes active. */
refresh?: () => void;
notifyResize?: () => void;
/** Asked before the tab closes; resolve false to keep it open. */
confirmClose?: () => boolean | Promise<boolean>;
[key: string]: unknown;
}
@@ -629,6 +654,11 @@ export interface DashboardCardContribution {
defaultHeight?: number;
/** Which column a preset places this card in by default. Defaults to "main". */
defaultPanel?: "main" | "side";
/**
* "framed" draws a titled header over the card, "bare" lets it run edge to
* edge, for a strip of numbers. Defaults to "framed".
*/
frame?: "framed" | "bare";
component: ComponentType<DashboardCardProps>;
}
@@ -909,6 +939,12 @@ export interface TermixApp extends TermixAppInfo {
options?: { origin?: unknown },
) => Promise<PluginWsTarget | null>;
tabs: TabsApi;
/**
* Asks a yes or no question over the main area. For code that runs outside
* a panel or tab; inside one, useConfirm from @termix/plugin-sdk/ui asks
* over that surface instead.
*/
confirm: (options: ConfirmRequest) => Promise<boolean>;
/** The desktop app, when the frontend runs in it. */
desktop: DesktopApi;
/**
@@ -1333,6 +1369,31 @@ export function usePluginUiPreferences<
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
}
/**
* Grid or list and row density for one of this plugin's views, kept in its
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
* keys in contributes.uiPresets so each preset level can pick a default.
*/
export function usePluginPanelView(prefix = ""): {
view: "grid" | "list";
density: "comfortable" | "compact";
compact: boolean;
setView: (view: "grid" | "list") => void;
setDensity: (density: "comfortable" | "compact") => void;
} {
const { values, set } = usePluginUiPreferences();
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
const density =
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
return {
view,
density,
compact: density === "compact",
setView: (next) => set(`${prefix}viewMode`, next),
setDensity: (next) => set(`${prefix}density`, next),
};
}
export function useTabs(): TabsApi {
return requireHost().useTabs();
}
+49 -4
View File
@@ -13,6 +13,7 @@ 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
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
@@ -27,17 +28,25 @@ CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttribu
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardMasonry (value): ({ columns, stack, className, children, }: { columns: number; stack?: boolean; className?: string; children: ReactNode; }) => Element
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
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
ConfirmOptions (type): ConfirmOptions
ConnectionGate (value): (props: ConnectionGateProps) => Element
ConnectionGateController (type): ConnectionGateController
ConnectionGateProps (type): ConnectionGateProps
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
ConnectionOrigin (type): ConnectionOrigin
ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
ConnectionScreen (value): ({ status, message, detail, errorMessage, errorDetail, unavailable, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
ConnectionStage (type): ConnectionStage
ConnectionStatus (type): ConnectionStatus
ConnectionUnavailable (type): ConnectionUnavailable
CustomKeybinding (type): CustomKeybinding
DataColumn (type): DataColumn<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
Dialog (value): ({ ...props }: DialogProps) => Element
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
@@ -63,20 +72,27 @@ DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
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
FOLDER_COLORS (value): string[]
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
FullScreenAppPhase (type): FullScreenAppPhase
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<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>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
@@ -93,8 +109,19 @@ MFAPromptMode (type): MFAPromptMode
ManagerCardError (type): ManagerCardError
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
PanelDensity (type): PanelDensity
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; className?: string; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element
PanelViewMode (type): PanelViewMode
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
@@ -106,14 +133,18 @@ PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLB
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RecentActivityItem (type): RecentActivityItem
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
RobustClipboardProvider (value): typeof RobustClipboardProvider
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element
Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element
SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes<HTMLDivElement>) => Element
SelectField (value): <T extends string>({ label, value, onChange, options, hint, error, disabled, className, placeholder, }: { label: string; value: T; onChange: (value: T) => void; options: readonly { value: T; label: string; }[]; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; placeholder?: string; }) => Element
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes<HTMLDivElement>) => Element
@@ -123,7 +154,7 @@ SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAtt
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, defaultKey, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; children: ReactNode; }) => Element
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
@@ -137,8 +168,16 @@ Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderP
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
SparklineProps (type): SparklineProps
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, className, children, }: { kind?: SurfaceKind; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<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
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
TabStripItem (type): TabStripItem
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
Tooltip (value): ({ ...props }: TooltipProps) => Element
@@ -146,7 +185,9 @@ TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipC
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
UptimeInfo (type): UptimeInfo
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
@@ -177,12 +218,16 @@ resolveRemoteHostId (value): (syncId: string) => Promise<number>
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
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
usageBarColor (value): (percent: number) => string
usageColor (value): (percent: number) => string
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
useAppTheme (value): () => ThemeProviderState
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
useConnectionGate (value): () => ConnectionGateController
useConnectionLog (value): () => ConnectionLogContextType
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
useOptionalConnectionLog (value): () => ConnectionLogContextType
useSurfaceKind (value): () => SurfaceKind
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType
+46 -1
View File
@@ -34,6 +34,7 @@ const ALLOWED = [
"AlertDialogTitle",
"AlertDialogTrigger",
"AlertTitle",
"BackButton",
"Badge",
"BarSeries",
"BarSeriesItem",
@@ -48,17 +49,25 @@ const ALLOWED = [
"CardFooter",
"CardGridCanvas",
"CardHeader",
"CardMasonry",
"CardTitle",
"Checkbox",
"ColumnCountStepper",
"ComponentSlot",
"ConfirmOptions",
"ConnectionGate",
"ConnectionGateController",
"ConnectionGateProps",
"ConnectionLogPanel",
"ConnectionLogProvider",
"ConnectionOrigin",
"ConnectionScreen",
"ConnectionStage",
"ConnectionStatus",
"ConnectionUnavailable",
"CustomKeybinding",
"DataColumn",
"DataView",
"Dialog",
"DialogClose",
"DialogContent",
@@ -84,20 +93,27 @@ const ALLOWED = [
"DropdownMenuSubContent",
"DropdownMenuSubTrigger",
"DropdownMenuTrigger",
"EditingWidth",
"EmptyState",
"FOLDER_COLORS",
"Facts",
"FakeSwitch",
"Field",
"FieldPair",
"FullScreenAppPhase",
"FullScreenAppWrapper",
"GridCardCatalogEntry",
"GridColSpan",
"GridColumns",
"GridLayout",
"GridSlot",
"GroupHeading",
"HostDefaultBadge",
"HostDefaultField",
"HostFeatureFields",
"HostFeatureFieldsProps",
"HostKeyVerificationDialog",
"InlineView",
"Input",
"Kbd",
"KbdKey",
@@ -114,8 +130,19 @@ const ALLOWED = [
"ManagerCardError",
"ManagerCardShell",
"ManagerSearch",
"Meter",
"MetricCard",
"MiniStat",
"NumberField",
"PANEL",
"PanePrompt",
"PROMPT_BUTTON",
"PROMPT_DESTRUCTIVE_BUTTON",
"PROMPT_PRIMARY_BUTTON",
"PanelDensity",
"PanelSearch",
"PanelShell",
"PanelViewMode",
"PassphraseDialog",
"PasswordInput",
"PluginComponent",
@@ -127,14 +154,18 @@ const ALLOWED = [
"RadialGauge",
"RadialGaugeProps",
"RecentActivityItem",
"Repeater",
"RobustClipboardProvider",
"SSHAuthDialog",
"ScrollArea",
"ScrollBar",
"SecretField",
"SectionCard",
"Segmented",
"Select",
"Select2",
"SelectContent",
"SelectField",
"SelectGroup",
"SelectItem",
"SelectLabel",
@@ -158,8 +189,16 @@ const ALLOWED = [
"Sparkline",
"SparklineProps",
"StatRow",
"SurfaceKind",
"SurfaceScope",
"Switch",
"SwitchRow",
"TOTPDialog",
"TabStrip",
"TabStripItem",
"TagInput",
"TextAreaField",
"TextField",
"Textarea",
"TextareaProps",
"Tooltip",
@@ -167,7 +206,9 @@ const ALLOWED = [
"TooltipProvider",
"TooltipTrigger",
"UptimeInfo",
"UsagePair",
"VersionInfo",
"ViewToggle",
"WebSocketConnectionTarget",
"WidgetTitle",
"buildOriginWsUrl",
@@ -198,13 +239,17 @@ const ALLOWED = [
"runAdaptiveBackgroundTask",
"runAdaptivePolling",
"runVisibleInterval",
"usageBarColor",
"usageColor",
"useAdaptivePolling",
"useAppTheme",
"useConfirmation",
"useConfirm",
"useConnectionGate",
"useConnectionLog",
"useIsDefaultsEditor",
"useIsMobile",
"useOptionalConnectionLog",
"useSurfaceKind",
"useTabs",
"useTabsSafe",
];
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
* settings section shows, so the Settings search can match on any label in
* a section without mounting it. Run it after changing a settings section.
*
* --check fails when the committed file is out of date.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
const OUT = path.join(
ROOT,
"src",
"ui",
"settings",
"settings-search-index.ts",
);
function keysIn(text) {
return [
...new Set([...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => m[1])),
];
}
function read(file) {
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
}
/** The body of one exported or local function, by name. */
function functionBody(source, name) {
const start = source.search(new RegExp(`function ${name}\\b`));
if (start < 0) throw new Error(`No function ${name}`);
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
}
/** Profile sections, split on their AccordionSection ids. */
function profileSections() {
const source = read("UserProfilePanel.tsx");
const lines = source.split("\n");
const starts = [];
lines.forEach((line, i) => {
if (/^\s{6}<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": keysIn(
functionBody(settings, "AdminGeneralSettingsSection"),
),
"admin-users": [
...new Set([
...keysIn(functionBody(management, "AdminUsersSection")),
...keysIn(read("AdminUserDialogs.tsx")),
]),
],
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
"admin-host-defaults": [
...new Set([
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
...keysIn(read("AdminHostTags.tsx")),
]),
],
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
};
}
function render() {
const index = { ...profileSections(), ...adminSections() };
const body = Object.entries(index)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([id, keys]) =>
` ${JSON.stringify(id)}: [\n${keys
.sort()
.map((key) => ` ${JSON.stringify(key)},`)
.join("\n")}\n ],`,
)
.join("\n");
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<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)}`);
}
+3
View File
@@ -146,6 +146,7 @@ export type HostFolder = {
type KnownTabType =
| "dashboard"
| "host-manager"
| "settings"
| "user-profile"
| "admin-settings"
| "split-screen";
@@ -189,6 +190,8 @@ export type Tab = {
refresh?: () => void;
getApplicationCursorKeysMode?: () => boolean;
focus?: () => void;
/** Asked before the tab closes; resolve false to keep it open. */
confirmClose?: () => boolean | Promise<boolean>;
} | null>;
};
+419 -484
View File
File diff suppressed because it is too large. Load diff
+21 -54
View File
@@ -48,6 +48,7 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
import { startPreLoginPlugins } from "@/plugin-host/loader";
import { Checkbox } from "@/components/checkbox";
import { useBranding } from "@/contexts/BrandingContext";
import { BrandPanel, CompactBrand } from "./auth-bits";
import {
changeAppLanguage,
normalizeLanguageCode,
@@ -1228,39 +1229,26 @@ export function Auth({ onLogin }: AuthProps) {
return (
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
<div
className="absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in oklch, var(--border) 80%, transparent) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10 px-12">
<span className="text-4xl font-bold tracking-[0.3em] font-mono">
TERMIX
</span>
<div className="w-8 h-px bg-accent-brand" />
<span className="text-[11px] font-mono text-muted-foreground uppercase tracking-[0.25em]">
{t("auth.tagline")}
</span>
</div>
</div>
<BrandPanel
logo={branding.logo}
appName={branding.appName}
tagline={branding.tagline || t("auth.tagline")}
/>
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
<div className="w-full max-w-sm flex flex-col gap-6">
<div className="w-full max-w-[380px] flex flex-col gap-6 border border-border bg-card p-5">
<CompactBrand logo={branding.logo} appName={branding.appName} />
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-bold">
{desktopManualLogoutActive
? "Local desktop signed out"
: "Local desktop session unavailable"}
? t("auth.localDesktopSignedOut")
: t("auth.localDesktopUnavailable")}
</h1>
<p className="text-xs text-muted-foreground leading-relaxed">
{desktopManualLogoutActive
? "You signed out of the local desktop session. Continue locally to use this device's embedded Termix server again."
: "Termix could not create a local desktop session. Retry the embedded local session instead of registering a new account."}
? t("auth.localDesktopSignedOutDesc")
: t("auth.localDesktopUnavailableDesc")}
</p>
</div>
<Button
@@ -1270,8 +1258,8 @@ export function Auth({ onLogin }: AuthProps) {
onClick={continueLocalDesktopSession}
>
{desktopManualLogoutActive
? "Continue with local desktop"
: "Retry local desktop session"}
? t("auth.continueLocalDesktop")
: t("auth.retryLocalDesktop")}
</Button>
<Separator />
<LanguageRow
@@ -1308,37 +1296,16 @@ export function Auth({ onLogin }: AuthProps) {
return (
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* Left decorative panel */}
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
<div
className="absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in oklch, var(--border) 80%, transparent) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10 px-12">
{branding.logo && (
<img
src={branding.logo}
alt=""
className="w-16 h-16 object-contain mb-1"
/>
)}
<span className="text-4xl font-bold tracking-[0.3em] font-mono uppercase">
{branding.appName}
</span>
<div className="w-8 h-px bg-accent-brand" />
<span className="text-[11px] font-mono text-muted-foreground uppercase tracking-[0.25em]">
{branding.tagline || t("auth.tagline")}
</span>
</div>
</div>
<BrandPanel
logo={branding.logo}
appName={branding.appName}
tagline={branding.tagline || t("auth.tagline")}
/>
{/* Right panel */}
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
<div className="w-full max-w-sm flex flex-col gap-6">
<div className="w-full max-w-[380px] flex flex-col gap-6 border border-border bg-card p-5">
<CompactBrand logo={branding.logo} appName={branding.appName} />
{/* TOTP view */}
{view === "second-factor" && (
<div className="flex flex-col gap-5">
+58
View File
@@ -0,0 +1,58 @@
/** The left half of the sign-in screen: brand mark, tagline, a rule. */
export function BrandPanel({
logo,
appName,
tagline,
}: {
logo?: string | null;
appName: string;
tagline: string;
}) {
return (
<div className="relative hidden w-[46%] max-w-[560px] shrink-0 select-none flex-col justify-between overflow-hidden border-r border-border bg-sidebar p-10 lg:flex">
<div
className="absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in oklch, var(--border) 80%, transparent) 1px, transparent 1px)",
backgroundSize: "22px 22px",
}}
/>
<div className="absolute inset-x-0 top-0 h-px bg-accent-brand/60" />
<div className="relative flex items-center gap-3">
{logo && <img src={logo} alt="" className="size-8 object-contain" />}
<span className="font-mono text-lg font-bold uppercase tracking-[0.35em]">
{appName}
</span>
</div>
<div className="relative max-w-[380px]">
<h2 className="text-2xl font-semibold leading-tight tracking-tight">
{tagline}
</h2>
<div className="mt-4 h-px w-10 bg-accent-brand" />
</div>
<div className="relative h-3" />
</div>
);
}
/** The brand line shown above the form when the brand panel is hidden. */
export function CompactBrand({
logo,
appName,
}: {
logo?: string | null;
appName: string;
}) {
return (
<div className="flex items-center gap-2.5 lg:hidden">
{logo && <img src={logo} alt="" className="size-7 object-contain" />}
<span className="font-mono text-sm font-bold uppercase tracking-[0.3em]">
{appName}
</span>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
/* eslint-disable react-refresh/only-export-components */
import {
Children,
Fragment,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { PANEL } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
/** Column counts per container width tier, for each column preference. */
const COLUMN_STEPS: Record<number, [number, number, number, number]> = {
1: [1, 1, 1, 1],
2: [1, 2, 2, 2],
3: [1, 2, 3, 3],
4: [1, 2, 3, 4],
};
const TIERS = { md: 640, lg: 960, xl: 1240 };
/** How many columns fit a container of this width for a column preference. */
export function masonryColumns(width: number, columns: number): number {
const steps = COLUMN_STEPS[columns] ?? COLUMN_STEPS[3];
if (width >= TIERS.xl) return steps[3];
if (width >= TIERS.lg) return steps[2];
if (width >= TIERS.md) return steps[1];
return steps[0];
}
function flattenChildren(children: React.ReactNode): React.ReactNode[] {
return Children.toArray(children).flatMap((child) =>
isValidElement(child) && child.type === Fragment
? flattenChildren(
(child.props as { children?: React.ReactNode }).children,
)
: [child],
);
}
/**
* Cards dealt round-robin into columns that stack tight. The assignment only
* depends on child order and column count, so live updates never reshuffle
* cards between columns. Width comes from the container, not the window, so
* split panes and docks get the right count.
*/
export function CardMasonry({
columns,
stack = false,
className,
children,
}: {
columns: number;
/** Force one full-width column, for cards holding wide tables. */
stack?: boolean;
className?: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
const count = stack ? 1 : masonryColumns(width, columns);
const items = useMemo(() => flattenChildren(children), [children]);
const buckets = useMemo(() => {
const next: React.ReactNode[][] = Array.from({ length: count }, () => []);
items.forEach((child, i) => next[i % count].push(child));
return next;
}, [items, count]);
return (
<div
ref={ref}
className={cn(
count === 1
? `flex flex-col ${PANEL.gap}`
: `flex items-start ${PANEL.gap}`,
className,
)}
>
{count === 1
? items
: buckets.map((bucket, i) => (
<div
key={i}
className={`flex min-w-0 flex-1 flex-col ${PANEL.gap}`}
>
{bucket}
</div>
))}
</div>
);
}
@@ -0,0 +1,267 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { useConnectionRetry } from "@termix/plugin-sdk/frontend";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext";
import {
ConnectionScreen,
type ConnectionUnavailable,
} from "@/components/connection/ConnectionScreen";
import type { ConnectionStatus } from "@/components/connection/connection-status";
import type { ConnectionStage } from "@/types/connection-log";
import { getErrorMessage } from "@/lib/error-message";
function messageOf(err: unknown): string {
return typeof err === "string" ? err : getErrorMessage(err);
}
type LogFn = (message: string, stage?: ConnectionStage) => void;
export interface ConnectionGateController {
status: ConnectionStatus;
attempt: number;
maxAttempts: number;
nextRetryInMs: number | null;
error: string | null;
unavailable: ConnectionUnavailable | null;
log: { info: LogFn; success: LogFn; warning: LogFn; error: LogFn };
markConnected: () => void;
/** It did not come up. Logs the reason and schedules a retry. */
markFailed: (error?: unknown) => void;
/** It was up and dropped. No automatic retry. */
markDisconnected: (error?: unknown) => void;
/** It cannot run here, like a feature that is off for this host. */
markUnavailable: (info: ConnectionUnavailable) => void;
retryNow: () => void;
reset: () => void;
}
export interface ConnectionGateProps {
/** Opens the connection. Throwing or rejecting counts as a failure. */
connect: (gate: ConnectionGateController) => void | Promise<void>;
message: string;
detail?: string;
disconnectedMessage?: string;
retry?: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
autoStart?: boolean;
enabled?: boolean;
};
/** Keep children mounted under the screen, for a terminal or display. */
keepMounted?: boolean;
backgroundColor?: string;
extraActions?: React.ReactNode;
className?: string;
children:
React.ReactNode | ((gate: ConnectionGateController) => React.ReactNode);
}
const GateContext = createContext<ConnectionGateController | null>(null);
/** The controller of the ConnectionGate this renders inside. */
export function useConnectionGate(): ConnectionGateController {
const gate = useContext(GateContext);
if (!gate) {
throw new Error("useConnectionGate must be used inside a ConnectionGate");
}
return gate;
}
/**
* One connection lifecycle for every tab: connecting, failed with retry and
* a log, dropped, or unavailable. Children render once it is up.
*/
export function ConnectionGate(props: ConnectionGateProps) {
return (
<ConnectionLogProvider>
<Gate {...props} />
</ConnectionLogProvider>
);
}
function Gate({
connect,
message,
detail,
disconnectedMessage,
retry,
keepMounted,
backgroundColor,
extraActions,
className,
children,
}: ConnectionGateProps) {
const { addLog, clearLogs } = useConnectionLog();
const [error, setError] = useState<string | null>(null);
const [unavailable, setUnavailable] = useState<ConnectionUnavailable | null>(
null,
);
const [dropped, setDropped] = useState(false);
const maxAttempts = retry?.maxAttempts ?? 8;
const gateRef = useRef<ConnectionGateController | null>(null);
const write = useCallback(
(type: "info" | "success" | "warning" | "error"): LogFn =>
(text, stage = "connection") =>
addLog({ type, stage, message: text }),
[addLog],
);
const retryState = useConnectionRetry({
connect: async () => {
setError(null);
setUnavailable(null);
setDropped(false);
try {
await connect(gateRef.current!);
} catch (err) {
gateRef.current?.markFailed(err);
}
},
maxAttempts,
baseDelayMs: retry?.baseDelayMs,
maxDelayMs: retry?.maxDelayMs,
autoStart: retry?.autoStart,
enabled: retry?.enabled,
});
const {
markConnected: retryConnected,
markFailed: retryFailed,
retryNow: retryAgain,
reset: retryReset,
} = retryState;
const log = useMemo(
() => ({
info: write("info"),
success: write("success"),
warning: write("warning"),
error: write("error"),
}),
[write],
);
const markFailed = useCallback(
(err?: unknown) => {
if (err !== undefined) {
const text = messageOf(err);
setError(text);
log.error(text, "error");
}
retryFailed();
},
[log, retryFailed],
);
const markDisconnected = useCallback(
(err?: unknown) => {
if (err !== undefined) {
const text = messageOf(err);
setError(text);
log.warning(text, "connection");
}
setDropped(true);
},
[log],
);
const markUnavailable = useCallback((info: ConnectionUnavailable) => {
setUnavailable(info);
}, []);
const retryNow = useCallback(() => {
clearLogs();
setError(null);
setUnavailable(null);
setDropped(false);
retryAgain();
}, [clearLogs, retryAgain]);
const reset = useCallback(() => {
setError(null);
setUnavailable(null);
setDropped(false);
retryReset();
}, [retryReset]);
let status: ConnectionStatus = retryState.status;
if (dropped) status = "disconnected";
const controller = useMemo<ConnectionGateController>(
() => ({
status,
attempt: retryState.attempt,
maxAttempts,
nextRetryInMs: retryState.nextRetryInMs,
error,
unavailable,
log,
markConnected: () => {
setDropped(false);
setError(null);
retryConnected();
},
markFailed,
markDisconnected,
markUnavailable,
retryNow,
reset,
}),
[
status,
retryState.attempt,
retryState.nextRetryInMs,
maxAttempts,
error,
unavailable,
log,
retryConnected,
markFailed,
markDisconnected,
markUnavailable,
retryNow,
reset,
],
);
gateRef.current = controller;
const up = status === "connected" && !unavailable;
const content =
typeof children === "function" ? children(controller) : children;
return (
<GateContext.Provider value={controller}>
<div className={`relative h-full w-full ${className ?? ""}`}>
{(up || keepMounted) && content}
<ConnectionScreen
status={status}
message={message}
detail={detail}
errorDetail={error}
unavailable={unavailable}
disconnectedMessage={disconnectedMessage}
attempt={retryState.attempt}
maxAttempts={maxAttempts}
nextRetryInMs={retryState.nextRetryInMs}
onManualRetry={retryNow}
extraActions={extraActions}
backgroundColor={backgroundColor}
logPosition={status === "connecting" ? "bottom" : "top"}
/>
</div>
</GateContext.Provider>
);
}
@@ -1,18 +1,10 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { Button } from "@/components/button.tsx";
import { cn } from "@/lib/utils.ts";
import {
ChevronDown,
ChevronUp,
Copy,
Info,
CheckCircle2,
AlertTriangle,
XCircle,
} from "lucide-react";
import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react";
import { toast } from "sonner";
interface ConnectionLogPanelProps {
@@ -23,8 +15,23 @@ interface ConnectionLogPanelProps {
className?: string;
}
const COLLAPSED_HEIGHT = "h-[140px]";
const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]";
const COLLAPSED_HEIGHT = "h-[136px]";
const EXPANDED_HEIGHT = "h-[46%] min-h-[220px]";
/** A dot per line, so the log reads as one column of text. */
const DOT: Record<string, string> = {
info: "bg-muted-foreground/50",
success: "bg-accent-brand",
warning: "bg-warning",
error: "bg-destructive",
};
const TEXT: Record<string, string> = {
info: "text-foreground-secondary",
success: "text-foreground",
warning: "text-warning",
error: "text-destructive",
};
export function ConnectionLogPanel({
isConnecting,
@@ -39,6 +46,7 @@ export function ConnectionLogPanel({
connectionLog ?? {};
const lastLogRef = useRef<HTMLDivElement>(null);
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (hasConnectionError && setIsExpanded) {
@@ -56,7 +64,7 @@ export function ConnectionLogPanel({
useEffect(() => {
if (lastLogRef.current) {
lastLogRef.current.scrollIntoView({ block: "end" });
lastLogRef.current.scrollIntoView?.({ block: "end" });
}
}, [logs]);
@@ -88,113 +96,101 @@ export function ConnectionLogPanel({
.join("\n");
const ok = await copyToClipboard(logsText);
if (ok) toast.success(t("sshAuth.connectionLogCopied"));
else toast.error(t("sshAuth.connectionLogCopyFailed"));
};
const getIcon = (type: string) => {
switch (type) {
case "info":
return <Info className="h-4 w-4 text-blue-500 shrink-0" />;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
case "warning":
return <AlertTriangle className="h-4 w-4 text-yellow-500 shrink-0" />;
case "error":
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
default:
return <Info className="h-4 w-4 shrink-0" />;
}
};
const getTextColor = (type: string) => {
switch (type) {
case "info":
return "text-blue-400";
case "success":
return "text-green-400";
case "warning":
return "text-yellow-400";
case "error":
return "text-red-400";
default:
return "text-muted-foreground";
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
toast.success(t("sshAuth.connectionLogCopied"));
} else {
toast.error(t("sshAuth.connectionLogCopyFailed"));
}
};
return (
<div
className={cn(
"relative z-10 shrink-0 flex flex-col bg-bg-base",
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
position === "bottom"
? "border-t border-border"
: "border-b border-border",
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
"transition-[height] duration-150",
"transition-[height] duration-200",
className,
)}
>
<div className="flex items-center justify-between px-3 py-1.5 shrink-0 border-b border-border/60">
<Button
variant="ghost"
size="sm"
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-2 pr-1.5">
<button
type="button"
onClick={handleToggle}
className="flex items-center gap-2 -ml-2"
aria-expanded={expanded}
className="flex min-w-0 items-center gap-1.5 px-1.5 py-1 text-foreground-secondary transition-colors hover:text-foreground"
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronUp className="h-4 w-4" />
<ChevronUp className="size-3.5 shrink-0" />
)}
<span className="text-sm font-medium">
{t("sshAuth.connectionLogTitle")} ({logs.length})
<span className="text-[10px] font-semibold uppercase tracking-widest">
{t("sshAuth.connectionLogTitle")}
</span>
</Button>
</button>
<span className="text-xs tabular-nums text-muted-foreground">
{logs.length}
</span>
{logs.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={copyLogsToClipboard}
title={t("sshAuth.connectionLogCopy")}
className="ml-auto text-muted-foreground"
>
<Copy className="h-4 w-4" />
{copied ? (
<Check className="size-3.5 text-accent-brand" />
) : (
<Copy className="size-3.5" />
)}
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden thin-scrollbar">
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{isConnecting
? t("sshAuth.connectionLogWaiting")
: t("sshAuth.connectionLogEmpty")}
</div>
) : (
<div className="space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-start gap-2"
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
{logs.length === 0 ? (
<p className="px-3 py-4 text-xs text-muted-foreground">
{isConnecting
? t("sshAuth.connectionLogWaiting")
: t("sshAuth.connectionLogEmpty")}
</p>
) : (
<div className="px-2 py-1.5 font-mono text-[11px] leading-[1.7]">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-baseline gap-2 px-1 hover:bg-foreground/[0.03]"
>
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{log.timestamp.toLocaleTimeString([], { hour12: false })}
</span>
<span
className={cn(
"size-1.5 shrink-0 translate-y-[-1px] rounded-full",
DOT[log.type] ?? DOT.info,
)}
/>
<span
className={cn(
"min-w-0 flex-1 whitespace-pre-wrap break-all",
TEXT[log.type] ?? TEXT.info,
)}
>
<span className="shrink-0 text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>
{getIcon(log.type)}
<span
className={cn(
"flex-1 min-w-0 break-all whitespace-pre-wrap",
getTextColor(log.type),
)}
>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
+146 -43
View File
@@ -2,13 +2,29 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/button.tsx";
import { Loader2, RefreshCw } from "lucide-react";
import { RefreshCw } from "lucide-react";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
interface ConnectionScreenProps {
export interface ConnectionUnavailable {
title: string;
hint?: string;
/** Another way forward, like opening the host editor. */
action?: React.ReactNode;
}
export interface ConnectionScreenProps {
status: ConnectionStatus;
/** Headline while connecting. */
message?: string;
/** Second line under the headline, usually user@host:port. */
detail?: string;
/** Headline once it has failed and is not retrying on its own. */
errorMessage?: string;
/** The reason it failed, shown under the headline. */
errorDetail?: string | null;
/** Shown instead of a failure when the feature cannot run here. */
unavailable?: ConnectionUnavailable | null;
backgroundColor?: string;
attempt?: number;
maxAttempts?: number;
@@ -22,9 +38,14 @@ interface ConnectionScreenProps {
className?: string;
}
/** The one screen every connection shows until it is up. */
export function ConnectionScreen({
status,
message,
detail,
errorMessage,
errorDetail,
unavailable,
backgroundColor,
attempt = 0,
maxAttempts = 0,
@@ -39,83 +60,165 @@ export function ConnectionScreen({
}: ConnectionScreenProps) {
const { t } = useTranslation();
if (status === "connected" && !emptyState) {
if (status === "connected" && !emptyState && !unavailable) {
return null;
}
const showSpinner = status === "connecting";
const showRetryButton = status === "disconnected" && !!onManualRetry;
const showLog = status !== "connected";
const connecting = status === "connecting" && !unavailable;
const failed = status === "error" || status === "disconnected";
const retrying =
status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0;
const showRetryButton = (failed || !!unavailable) && !!onManualRetry;
const showLog = !emptyState && (status !== "connected" || !!unavailable);
let headline = message;
if (unavailable) headline = unavailable.title;
else if (status === "disconnected")
headline = disconnectedMessage || t("connection.disconnected");
else if (retrying) headline = t("connection.failedRetrying");
else if (status === "error")
headline = errorMessage || t("connection.failed");
const showCountdown = attempt > 0 && (connecting || retrying);
return (
<div
role="status"
aria-live="polite"
data-status={status}
data-status={unavailable ? "unavailable" : status}
className={cn(
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
className,
)}
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
>
<div className="flex-1 min-h-0 flex items-center justify-center">
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
{emptyState ? (
emptyState
) : (
<div className="flex flex-col items-center gap-4 px-6 text-center">
{showSpinner && (
<Loader2
className="size-8 animate-spin text-accent-brand"
aria-hidden="true"
/>
)}
{message && (
<p className="text-sm text-foreground-secondary font-medium">
{message}
</p>
)}
{attempt > 0 && status !== "disconnected" && (
<p className="text-xs text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", { attempt, max: maxAttempts })}
</p>
)}
{showRetryButton && (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-foreground-secondary font-medium">
{disconnectedMessage || t("connection.disconnected")}
<div className="flex max-w-md flex-col items-center gap-3.5 text-center">
<ConnectionMark
state={
unavailable
? "unavailable"
: connecting || retrying
? "busy"
: "failed"
}
/>
<div className="space-y-1.5">
{headline && (
<p className="text-sm font-semibold tracking-tight text-foreground">
{headline}
</p>
<div className="flex gap-2">
)}
{detail && (
<p className="font-mono text-xs text-muted-foreground">
{detail}
</p>
)}
{unavailable?.hint && (
<p className="text-xs leading-snug text-muted-foreground">
{unavailable.hint}
</p>
)}
{failed && !unavailable && errorDetail && (
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
{errorDetail}
</p>
)}
{showCountdown && (
<p className="text-xs tabular-nums text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", {
attempt,
max: maxAttempts,
})}
</p>
)}
</div>
{(showRetryButton ||
((failed || unavailable) && extraActions) ||
unavailable?.action) && (
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
{showRetryButton && (
<Button
variant="outline"
size="default"
size="sm"
onClick={onManualRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{retryLabel || t("connection.reconnect")}
{retrying
? t("connection.retryNow")
: retryLabel || t("connection.reconnect")}
</Button>
{extraActions}
</div>
)}
{unavailable?.action}
{(failed || unavailable) && extraActions}
</div>
)}
</div>
)}
</div>
{showLog && !emptyState && (
{showLog && (
<ConnectionLogPanel
isConnecting={status === "connecting"}
isConnecting={connecting}
isConnected={false}
hasConnectionError={status === "error" || status === "disconnected"}
hasConnectionError={failed || !!unavailable}
position={logPosition}
/>
)}
</div>
);
}
/** A thin arc while working, a still ring with a dot once it stops. */
function ConnectionMark({
state,
}: {
state: "busy" | "failed" | "unavailable";
}) {
return (
<div className="relative size-9" aria-hidden="true">
<svg viewBox="0 0 36 36" className="size-full">
<circle
cx="18"
cy="18"
r="15"
fill="none"
strokeWidth="2"
className="stroke-border"
/>
{state === "busy" && (
<circle
cx="18"
cy="18"
r="15"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="26 68"
className="origin-center animate-spin stroke-accent-brand [animation-duration:900ms]"
/>
)}
</svg>
{state !== "busy" && (
<span
className={cn(
"absolute inset-0 m-auto size-1.5 rounded-full",
state === "failed" ? "bg-destructive" : "bg-muted-foreground",
)}
/>
)}
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import type React from "react";
import { cn } from "@/lib/utils";
import {
PANEL,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
export interface DataColumn<T> {
key: string;
header: string;
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
width: string;
cell: (item: T) => React.ReactNode;
/** Dropped below md, for columns that are nice rather than necessary. */
hideBelow?: "md" | "lg";
align?: "start" | "end";
}
export interface GridColumns {
base?: number;
md?: number;
lg?: number;
xl?: number;
}
// Spelled out so Tailwind can see every class.
const COLS: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
};
const MD_COLS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const LG_COLS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
};
const XL_COLS: Record<number, string> = {
1: "xl:grid-cols-1",
2: "xl:grid-cols-2",
3: "xl:grid-cols-3",
4: "xl:grid-cols-4",
};
function gridClass(columns: GridColumns): string {
return cn(
COLS[columns.base ?? 1],
columns.md && MD_COLS[columns.md],
columns.lg && LG_COLS[columns.lg],
columns.xl && XL_COLS[columns.xl],
);
}
/** One collection as a card grid or a dense row table. */
export function DataView<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) => React.ReactNode;
columns: GridColumns;
listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean;
empty: React.ReactNode;
className?: string;
}) {
if (items.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">{empty}</div>
);
}
if (view === "grid") {
return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}>
{items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div>
))}
</div>
);
}
const template = listColumns.map((c) => c.width).join(" ");
const rowHeight = density === "compact" ? "h-7" : "h-9";
return (
<div className={cn("border border-border bg-card", className)} role="table">
<div
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<span
key={column.key}
className={cn(
"truncate py-2",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
)}
>
{column.header}
</span>
))}
</div>
<div>
{items.map((item) => {
const active = isRowActive?.(item) ?? false;
return (
<div
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)}
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<div
key={column.key}
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
)}
>
{column.cell(item)}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
+480
View File
@@ -0,0 +1,480 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, X } from "lucide-react";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/button";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { cn } from "@/lib/utils";
/** Label + control + optional hint, the shape every field shares. */
export function Field({
label,
hint,
error,
aside,
htmlFor,
className,
children,
}: {
label: string;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
htmlFor?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{(label || aside) && (
<div className="flex items-center gap-2">
<label
htmlFor={htmlFor}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{label}
</label>
{aside && <span className="ml-auto">{aside}</span>}
</div>
)}
{children}
{error ? (
<span className="text-[10px] text-destructive">{error}</span>
) : hint ? (
<span className="text-[10px] text-muted-foreground">{hint}</span>
) : null}
</div>
);
}
export function TextField({
label,
value,
onChange,
placeholder,
hint,
error,
aside,
disabled,
mono,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
disabled?: boolean;
mono?: boolean;
className?: string;
}) {
return (
<Field
label={label}
hint={hint}
error={error}
aside={aside}
className={className}
>
<Input
value={value}
placeholder={placeholder}
disabled={disabled}
aria-invalid={!!error}
className={mono ? "font-mono" : undefined}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
);
}
export function NumberField({
label,
value,
onChange,
placeholder,
hint,
error,
min,
max,
disabled,
className,
}: {
label: string;
value: number;
onChange: (value: number) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
min?: number;
max?: number;
disabled?: boolean;
className?: string;
}) {
return (
<Field label={label} hint={hint} error={error} className={className}>
<Input
type="number"
value={Number.isFinite(value) ? value : ""}
placeholder={placeholder}
min={min}
max={max}
disabled={disabled}
aria-invalid={!!error}
onChange={(e) => onChange(Number(e.target.value))}
/>
</Field>
);
}
/** Values the server sends in place of a stored secret. */
export const SECRET_SENTINELS = [
"existing_password",
"existing_key",
"existing_key_password",
"existing_sudo_password",
"existing_rdp_password",
"existing_vnc_password",
"existing_telnet_password",
];
export function isSecretSentinel(value: string): boolean {
return SECRET_SENTINELS.includes(value);
}
export function SecretField({
label,
value,
onChange,
placeholder,
hint,
error,
disabled,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
disabled?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const saved = isSecretSentinel(value);
return (
<Field
label={label}
hint={saved ? t("fields.secretSaved") : hint}
error={error}
className={className}
>
<PasswordInput
value={saved ? "" : value}
placeholder={saved ? "••••••••" : placeholder}
disabled={disabled}
aria-invalid={!!error}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
);
}
/** A Radix dropdown, so its list uses the theme tokens. */
export function SelectField<T extends string>({
label,
value,
onChange,
options,
hint,
error,
disabled,
className,
placeholder,
}: {
label: string;
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: string }[];
hint?: ReactNode;
error?: string;
disabled?: boolean;
className?: string;
placeholder?: string;
}) {
// Radix treats "" as no selection, so empty options get a stand-in value.
const EMPTY = "__empty__";
return (
<Field label={label} hint={hint} error={error} className={className}>
<Select
value={value === ("" as T) ? EMPTY : value}
disabled={disabled}
onValueChange={(next) => onChange((next === EMPTY ? "" : next) as T)}
>
<SelectTrigger aria-invalid={!!error} className="h-8 w-full text-xs">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value === "" ? EMPTY : option.value}
className="text-xs"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
export function TextAreaField({
label,
value,
onChange,
placeholder,
rows = 3,
hint,
error,
aside,
mono,
disabled,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
mono?: boolean;
disabled?: boolean;
className?: string;
}) {
return (
<Field
label={label}
hint={hint}
error={error}
aside={aside}
className={className}
>
<textarea
rows={rows}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={cn(
"w-full resize-none border border-input bg-background px-3 py-2 text-xs text-foreground transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30",
mono && "font-mono text-[10px]",
)}
/>
</Field>
);
}
/** A labelled switch. */
export function SwitchRow({
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;
}) {
return (
<SettingRow
label={label}
description={description}
badge={badge}
defaultKey={defaultKey}
rowId={rowId}
>
<FakeSwitch checked={checked} onChange={onChange} disabled={disabled} />
</SettingRow>
);
}
/** A list of rows you add to and remove from. */
export function Repeater<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;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 py-3">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<Button
type="button"
variant="outline"
size="xs"
className="ml-auto"
onClick={() => onChange([...items, makeItem()])}
>
<Plus className="mr-1 size-3" />
{addLabel}
</Button>
</div>
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
{items.length === 0 ? (
<span className="border border-dashed border-border px-3 py-2 text-[10px] text-muted-foreground">
{empty}
</span>
) : (
<div className="flex flex-col gap-2">
{items.map((item, index) => (
<div
key={index}
className="flex items-start gap-2 border border-border bg-muted/10 p-2"
>
<div className="min-w-0 flex-1">
{renderItem(
item,
(next) =>
onChange(items.map((x, i) => (i === index ? next : x))),
index,
)}
</div>
<button
type="button"
title={t("common.remove")}
aria-label={t("common.remove")}
onClick={() => onChange(items.filter((_, i) => i !== index))}
className="mt-0.5 shrink-0 text-muted-foreground transition-colors hover:text-destructive focus-visible:ring-1 focus-visible:ring-ring"
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}
/** Chip input for tags. */
export function TagInput({
tags,
input,
placeholder,
onTagsChange,
onInputChange,
}: {
tags: string[];
input: string;
placeholder: string;
onTagsChange: (tags: string[]) => void;
onInputChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-8 flex-wrap items-center gap-1 border border-input bg-background px-2 py-1 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/50 dark:bg-input/30">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 border border-border/60 bg-muted px-1.5 py-0.5 text-[10px] text-foreground"
>
{tag}
<button
type="button"
aria-label={t("common.remove")}
onClick={() => onTagsChange(tags.filter((tg) => tg !== tag))}
className="ml-0.5 text-muted-foreground hover:text-destructive"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="min-w-16 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
placeholder={tags.length === 0 ? placeholder : ""}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && input.trim()) {
e.preventDefault();
const tag = input.trim();
if (!tags.includes(tag)) onTagsChange([...tags, tag]);
onInputChange("");
} else if (e.key === "Backspace" && !input && tags.length > 0) {
onTagsChange(tags.slice(0, -1));
}
}}
/>
</div>
);
}
/** Two fields side by side above md, stacked below. */
export function FieldPair({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">{children}</div>
);
}
/** Reads a picked file's text, for keys and certificates. */
export function useFileText(onText: (text: string) => void) {
const [reading, setReading] = useState(false);
return {
reading,
onFile: async (file: File | null | undefined) => {
if (!file) return;
setReading(true);
try {
onText((await file.text()).trim());
} finally {
setReading(false);
}
},
};
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function ManagerCardShell({
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<AlertTriangle className="size-6 text-warning" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
+55
View File
@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import { usageBarColor, usageColor } from "@/lib/usage-color";
/** A usage bar on the shared color ramp. */
export function Meter({
percent,
className,
}: {
percent: number | null;
className?: string;
}) {
const clamped = percent === null ? 0 : Math.min(100, Math.max(0, percent));
return (
<div className={cn("h-1.5 w-full overflow-hidden bg-muted", className)}>
<div
className={cn("motion-meter h-full", usageBarColor(percent))}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/**
* A meter with its readout. Pass `label` for the stacked card form; leave it off
* for the bare form a table cell wants.
*/
export function UsagePair({
label,
percent,
detail,
}: {
label?: string;
percent: number;
detail?: string;
}) {
const clamped = Math.min(100, Math.max(0, percent));
return (
<div className="flex min-w-0 items-center gap-2">
{label && (
<span className="w-10 shrink-0 text-[10px] uppercase text-muted-foreground">
{label}
</span>
)}
<Meter percent={clamped} className="flex-1" />
<span
className={cn(
"shrink-0 text-right text-[11px] tabular-nums",
detail ? "text-muted-foreground" : usageColor(clamped),
)}
>
{detail ?? `${Math.round(clamped)}%`}
</span>
</div>
);
}
+417
View File
@@ -0,0 +1,417 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
body: "p-2.5",
gap: "gap-2",
band: "px-3 py-2",
cardHead: "px-3 py-2.5",
header: "h-12.5",
} as const;
export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact";
/** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({
chrome = true,
icon,
title,
status,
leading,
actions,
toolbar,
tabs,
footer,
scroll = true,
className,
children,
}: {
/** False inside a sidebar, which draws its own header. */
chrome?: boolean;
icon?: React.ReactNode;
title?: React.ReactNode;
/** Short caption beside the title, like "12 of 18 running". */
status?: React.ReactNode;
/** Sits before the title, usually a BackButton. */
leading?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
/** A full-bleed tab strip in place of a toolbar. */
tabs?: React.ReactNode;
/** A bar pinned under the body, like Cancel and Save. */
footer?: React.ReactNode;
/** False when the body scrolls itself. */
scroll?: boolean;
className?: string;
children: React.ReactNode;
}) {
const body = (
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
scroll && "overflow-y-auto thin-scrollbar",
className,
)}
>
{children}
</div>
);
const bands = (
<>
{toolbar && (
<div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
>
{toolbar}
</div>
)}
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
</>
);
const foot = footer && (
<div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-2">
{footer}
</div>
);
if (!chrome) {
return (
<div className="flex min-h-0 flex-1 flex-col">
{bands}
{body}
{foot}
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-background">
<header
className={`flex ${PANEL.header} shrink-0 flex-row items-center border-b border-border`}
>
{leading && (
<>
{leading}
<Separator orientation="vertical" />
</>
)}
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
{icon && <span className="shrink-0 text-accent-brand">{icon}</span>}
<span className="truncate text-base font-bold tracking-tight">
{title}
</span>
{status && (
<>
<Separator orientation="vertical" className="h-4 self-center" />
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{status}
</span>
</>
)}
</div>
{actions && (
<div className="flex shrink-0 items-center gap-1 px-2">{actions}</div>
)}
</header>
{bands}
{body}
{foot}
</div>
);
}
/** Square back button sized for the PanelShell header. */
export function BackButton({
onClick,
label,
className,
}: {
onClick: () => void;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.back");
return (
<button
type="button"
onClick={onClick}
title={text}
aria-label={text}
className={cn(
"flex h-full w-12.5 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
<ArrowLeft className="size-4" />
</button>
);
}
/** Search box sized to match toolbar buttons. Esc clears it. */
export function PanelSearch({
value,
onChange,
placeholder,
fill,
autoFocus,
className,
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
/** Takes the whole toolbar, for panels where search is the only control. */
fill?: boolean;
autoFocus?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLInputElement>(null);
const text = placeholder ?? t("common.search");
return (
<div
className={cn(
"relative",
fill ? "w-full min-w-0 flex-1" : "w-40 md:w-56",
className,
)}
>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
ref={ref}
type="text"
value={value}
autoFocus={autoFocus}
aria-label={text}
placeholder={text}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && value) {
e.stopPropagation();
onChange("");
}
}}
className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 dark:bg-input/30"
/>
{value && (
<button
type="button"
aria-label={t("panel.clearSearch")}
title={t("panel.clearSearch")}
onClick={() => {
onChange("");
ref.current?.focus();
}}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
);
}
/** Square segmented control. */
export function Segmented<T extends string>({
value,
onChange,
options,
className,
}: {
value: T;
onChange: (next: T) => void;
options: readonly {
value: NoInfer<T>;
label: string;
icon?: React.ReactNode;
count?: number;
title?: string;
}[];
className?: string;
}) {
return (
<div
role="radiogroup"
className={cn("flex items-center border border-border", className)}
>
{options.map((option, i) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
title={option.title ?? option.label}
onClick={() => onChange(option.value)}
className={cn(
"flex h-8 items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
i > 0 && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.icon}
{option.label}
{option.count !== undefined && (
<span className="text-[10px] tabular-nums opacity-60">
{option.count}
</span>
)}
</button>
);
})}
</div>
);
}
/** Grid or list, plus the compact rows toggle. */
export function ViewToggle({
view,
onView,
density,
onDensity,
}: {
view: PanelViewMode;
onView: (next: PanelViewMode) => void;
density?: PanelDensity;
onDensity?: (next: PanelDensity) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center border border-border">
<ToggleButton
active={view === "grid"}
onClick={() => onView("grid")}
title={t("panel.gridView")}
>
<Grid3X3 className="size-4" />
</ToggleButton>
<ToggleButton
active={view === "list"}
onClick={() => onView("list")}
title={t("panel.listView")}
bordered
>
<List className="size-4" />
</ToggleButton>
{density && onDensity && (
<ToggleButton
active={density === "compact"}
onClick={() =>
onDensity(density === "compact" ? "comfortable" : "compact")
}
title={
density === "compact"
? t("panel.comfortableRows")
: t("panel.compactRows")
}
bordered
>
<Rows3 className="size-4" />
</ToggleButton>
)}
</div>
);
}
function ToggleButton({
active,
onClick,
title,
bordered,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
bordered?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
aria-pressed={active}
className={cn(
"flex size-8 items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
bordered && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
/**
* A run of peer facts with a rule between each pair, like "12 online | 40
* hosts". Never wraps; a fact that does not fit is truncated.
*/
export function Facts({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const items = Children.toArray(children).filter(Boolean);
if (items.length === 0) return null;
return (
<span className={cn("flex min-w-0 items-center gap-2", className)}>
{items.map((child, i) => (
<Fragment key={i}>
{i > 0 && (
<span aria-hidden className="h-3 w-px shrink-0 bg-border" />
)}
<span className="min-w-0 truncate">{child}</span>
</Fragment>
))}
</span>
);
}
/** Section label with a rule running to the edge. */
export function GroupHeading({
title,
count,
action,
className,
}: {
title: string;
count?: number;
action?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{count !== undefined && (
<span className="text-[10px] tabular-nums text-muted-foreground/60">
{count}
</span>
)}
<div className="h-px flex-1 bg-border" />
{action}
</div>
);
}
+17 -4
View File
@@ -8,20 +8,24 @@ export function SectionCard({
action,
children,
className,
rowId,
}: {
title: string;
icon: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
/** Lets settings search jump to this card. */
rowId?: string;
}) {
return (
<div
data-setting-row={rowId}
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{title}
</span>
{action && <div className="ml-auto">{action}</div>}
@@ -36,6 +40,7 @@ export function SettingRow({
badge,
description,
defaultKey,
rowId,
children,
}: {
label: string;
@@ -46,18 +51,23 @@ export function SettingRow({
* field key, or "core.<key>". Shows where the value comes from.
*/
defaultKey?: string;
/** Lets settings search jump to this row. */
rowId?: string;
children: React.ReactNode;
}) {
return (
<DefaultsOnly settingKey={defaultKey ?? ""}>
{/* In a gap-4 form stack the gap already spaces the row, so its own
padding and divider would double up. */}
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0">
<div
data-setting-row={rowId}
className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0"
>
<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-sm font-medium leading-snug">{label}</span>
{badge && (
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
<span className="text-[10px] font-bold text-warning border border-warning/40 px-1 shrink-0">
{badge}
</span>
)}
@@ -90,13 +100,16 @@ export function FakeSwitch({
const on = checked !== undefined ? checked : internalOn;
return (
<button
type="button"
role="switch"
aria-checked={on}
disabled={disabled}
onClick={() => {
const next = !on;
if (checked === undefined) setInternalOn(next);
onChange?.(next);
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
@@ -0,0 +1,9 @@
/** Buttons shared by every PanePrompt, so confirms and prompts match. */
export const PROMPT_BUTTON =
"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";
export const PROMPT_PRIMARY_BUTTON =
"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";
export const PROMPT_DESTRUCTIVE_BUTTON =
"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";
+472
View File
@@ -0,0 +1,472 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout";
import {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "./prompt-styles";
/**
* A surface is a sidebar panel, a tab or a split pane. Inline views and
* confirmations draw over the surface that asked, never over the whole app.
*/
export type EditingWidth = boolean | "wide";
export type SurfaceKind = "panel" | "tab" | "pane";
export interface ConfirmOptions {
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Red confirm button. Defaults to true, since most confirms delete. */
destructive?: boolean;
}
interface PendingConfirm extends ConfirmOptions {
id: number;
resolve: (ok: boolean) => void;
}
interface ScopeValue {
kind: SurfaceKind;
overlay: HTMLElement | null;
openView: (id: string, width: EditingWidth) => void;
closeView: (id: string) => void;
topView: string | null;
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ScopeContext = createContext<ScopeValue | null>(null);
function widest(widths: EditingWidth[]): EditingWidth {
if (widths.includes("wide")) return "wide";
return widths.some(Boolean);
}
// Only the most recently opened confirm, anywhere in the app, owns the keyboard.
const keyOwners: number[] = [];
export function SurfaceScope({
kind = "panel",
onEditingChange,
className,
children,
}: {
kind?: SurfaceKind;
/** Gets the widest open inline view, so a sidebar can widen for it. */
onEditingChange?: (editing: EditingWidth) => void;
className?: string;
children: React.ReactNode;
}) {
const [overlay, setOverlay] = useState<HTMLElement | null>(null);
const [views, setViews] = useState<{ id: string; width: EditingWidth }[]>([]);
const [confirms, setConfirms] = useState<PendingConfirm[]>([]);
const nextConfirm = useRef(0);
const editingRef = useRef(onEditingChange);
editingRef.current = onEditingChange;
const openView = useCallback((id: string, width: EditingWidth) => {
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
}, []);
const closeView = useCallback((id: string) => {
setViews((prev) => prev.filter((v) => v.id !== id));
}, []);
const editing = widest(views.map((v) => v.width));
useEffect(() => {
editingRef.current?.(editing);
}, [editing]);
useEffect(() => () => editingRef.current?.(false), []);
const confirm = useCallback(
(options: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
const id = ++nextConfirm.current;
setConfirms((prev) => [...prev, { ...options, id, resolve }]);
}),
[],
);
const settle = useCallback((id: number, ok: boolean) => {
setConfirms((prev) => {
prev.find((c) => c.id === id)?.resolve(ok);
return prev.filter((c) => c.id !== id);
});
}, []);
// A scope that unmounts with a question open answers it with no.
const confirmsRef = useRef(confirms);
confirmsRef.current = confirms;
useEffect(
() => () => confirmsRef.current.forEach((c) => c.resolve(false)),
[],
);
const value = useMemo<ScopeValue>(
() => ({
kind,
overlay,
openView,
closeView,
topView: views.length ? views[views.length - 1].id : null,
confirm,
}),
[kind, overlay, openView, closeView, views, confirm],
);
const current = confirms[0];
return (
<ScopeContext.Provider value={value}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
className,
)}
>
{children}
<div
ref={setOverlay}
className={cn(
"absolute inset-0 z-40 flex flex-col bg-background",
views.length === 0 && "hidden",
)}
/>
{current && (
<ConfirmCard
key={current.id}
options={current}
onSettle={(ok) => settle(current.id, ok)}
/>
)}
</div>
</ScopeContext.Provider>
);
}
/** Which kind of surface this code renders in, or null outside any. */
export function useSurfaceKind(): SurfaceKind | null {
return useContext(ScopeContext)?.kind ?? null;
}
/**
* Asks a yes or no question over the surface that called it. Enter confirms
* and Esc cancels, before a terminal can see the key.
*/
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
const scope = useContext(ScopeContext);
return useCallback(
(options: ConfirmOptions) => {
if (scope) return scope.confirm(options);
const text = [options.title, options.description]
.filter((part) => typeof part === "string")
.join("\n\n");
return Promise.resolve(window.confirm(text));
},
[scope],
);
}
function ConfirmCard({
options,
onSettle,
}: {
options: ConfirmOptions;
onSettle: (ok: boolean) => void;
}) {
const { t } = useTranslation();
const confirmRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const destructive = options.destructive ?? true;
const settleRef = useRef(onSettle);
settleRef.current = onSettle;
useEffect(() => {
const owner = Math.random();
keyOwners.push(owner);
const previous = document.activeElement as HTMLElement | null;
confirmRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (keyOwners[keyOwners.length - 1] !== owner) return;
if (e.key !== "Enter" && e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
settleRef.current(e.key === "Enter");
};
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("keydown", onKey, true);
keyOwners.splice(keyOwners.indexOf(owner), 1);
if (previous && document.contains(previous)) previous.focus();
};
}, []);
return (
<PanePrompt
open
role="alertdialog"
labelledBy={titleId}
title={options.title}
titleId={titleId}
description={options.description}
tone={destructive ? "destructive" : "default"}
onCancel={() => onSettle(false)}
actions={
<>
<button
type="button"
onClick={() => onSettle(false)}
className={PROMPT_BUTTON}
>
{options.cancelLabel ?? t("common.cancel")}
</button>
<button
ref={confirmRef}
type="button"
onClick={() => onSettle(true)}
className={
destructive ? PROMPT_DESTRUCTIVE_BUTTON : PROMPT_PRIMARY_BUTTON
}
>
{options.confirmLabel ??
(destructive ? t("common.delete") : t("common.confirm"))}
<span aria-hidden className="ml-1.5 opacity-60">
↵
</span>
</button>
</>
}
/>
);
}
/**
* A small card over the surface it sits in. Confirmations use it, and so do
* prompts that belong to a connection, like a sudo password.
*/
export function PanePrompt({
open,
title,
titleId,
description,
icon,
tone = "default",
actions,
onCancel,
role = "dialog",
labelledBy,
layer = "pane",
backgroundColor,
className,
children,
}: {
open: boolean;
title: React.ReactNode;
titleId?: string;
description?: React.ReactNode;
icon?: React.ReactNode;
tone?: "default" | "destructive";
actions?: React.ReactNode;
/** Clicking the dimmed backdrop. */
onCancel?: () => void;
role?: "dialog" | "alertdialog";
labelledBy?: string;
/**
* "connection" covers a connecting surface (a terminal waiting on a login)
* with an opaque backdrop, above everything the surface draws.
*/
layer?: "pane" | "connection";
/** Backdrop color for the connection layer, usually the terminal theme. */
backgroundColor?: string;
className?: string;
children?: React.ReactNode;
}) {
const fallbackId = useId();
if (!open) return null;
const headingId = titleId ?? fallbackId;
return (
<div
className={cn(
"motion-context-enter absolute inset-0 flex items-center justify-center overflow-y-auto p-4",
layer === "connection"
? "z-500 bg-canvas"
: "z-50 bg-background/80 backdrop-blur-sm",
)}
style={
layer === "connection" && backgroundColor
? { backgroundColor }
: undefined
}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel?.();
}}
>
<div
role={role}
aria-modal="true"
aria-labelledby={labelledBy ?? headingId}
className={cn(
"my-auto flex w-full max-w-sm flex-col border bg-popover shadow-xl",
tone === "destructive" ? "border-destructive/40" : "border-border",
className,
)}
>
<div className="flex items-start gap-2.5 px-4 pt-4">
{icon && (
<span
className={cn(
"mt-0.5 shrink-0",
tone === "destructive"
? "text-destructive"
: "text-accent-brand",
)}
>
{icon}
</span>
)}
<div className="flex min-w-0 flex-col gap-1">
<p
id={headingId}
className="text-sm font-semibold tracking-tight text-foreground"
>
{title}
</p>
{description && (
<div className="text-xs leading-snug text-muted-foreground">
{description}
</div>
)}
</div>
</div>
{children && <div className="px-4 pt-3">{children}</div>}
{actions && (
<div className="flex justify-end gap-2 px-4 py-3">{actions}</div>
)}
{!actions && <div className="h-4" />}
</div>
</div>
);
}
/**
* What used to be a modal: a view that takes over the surface it was opened
* from, with a Back button. Opened from a sidebar it widens the sidebar.
*/
export function InlineView({
open,
onOpenChange,
title,
icon,
status,
actions,
toolbar,
footer,
width = true,
onBeforeClose,
scroll = true,
bare = false,
className,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
icon?: React.ReactNode;
status?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
/** Pinned under the body, usually Cancel and Save. */
footer?: React.ReactNode;
/** How far a sidebar widens while this is open. */
width?: EditingWidth;
/** Return false to stay open, for an unsaved changes guard. */
onBeforeClose?: () => boolean | Promise<boolean>;
scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */
bare?: boolean;
className?: string;
children: React.ReactNode;
}) {
const scope = useContext(ScopeContext);
const id = useId();
const { openView, closeView } = scope ?? {};
useLayoutEffect(() => {
if (!open || !openView || !closeView) return;
openView(id, width);
return () => closeView(id);
}, [open, id, width, openView, closeView]);
const requestClose = useCallback(async () => {
if (onBeforeClose && !(await onBeforeClose())) return;
onOpenChange(false);
}, [onBeforeClose, onOpenChange]);
if (!open) return null;
const padded = scope?.kind === "tab" || scope?.kind === "pane";
const view = (
<div
className={cn(
"absolute inset-0 flex flex-col",
scope && scope.topView !== id && "hidden",
)}
onKeyDown={(e) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.stopPropagation();
void requestClose();
}
}}
>
<PanelShell
leading={<BackButton onClick={() => void requestClose()} />}
icon={icon}
title={title}
status={status}
actions={actions}
toolbar={toolbar}
footer={footer}
scroll={scroll}
>
{bare ? (
children
) : (
<div
className={cn(
"flex w-full flex-col gap-3",
padded ? "mx-auto max-w-3xl p-3" : "p-3",
!scroll && "min-h-0 flex-1",
className,
)}
>
{children}
</div>
)}
</PanelShell>
</div>
);
if (scope?.overlay) return createPortal(view, scope.overlay);
if (scope) return null;
return createPortal(
<div className="fixed inset-0 z-50 bg-background">{view}</div>,
document.body,
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export interface TabStripItem {
id: string;
label: string;
icon?: ReactNode;
/** Small count after the label. */
count?: number;
}
/** Scrollable underline tabs with overflow arrows. */
export function TabStrip({
tabs,
activeTab,
onTabChange,
isActive,
variant = "primary",
trailing,
className,
}: {
tabs: TabStripItem[];
activeTab: string;
onTabChange: (id: string) => void;
isActive?: (id: string) => boolean;
variant?: "primary" | "secondary";
/** Sits after the tabs, outside the scroll area. */
trailing?: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer = new ResizeObserver(update);
observer.observe(el);
if (el.firstElementChild) observer.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const renderTab = (tab: TabStripItem) => {
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 ${
variant === "secondary" ? "py-1.5 text-[11px]" : "py-2 text-xs"
} font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
active
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
{tab.count !== undefined && (
<span className="text-[10px] tabular-nums opacity-60">
{tab.count}
</span>
)}
</button>
);
};
const overflow = scroll.left || scroll.right;
return (
<div
className={cn(
"flex min-w-0 items-center",
variant === "secondary" && "border-t border-border bg-card",
className,
)}
>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
{trailing && (
<div className="flex shrink-0 items-center gap-1 px-1">{trailing}</div>
)}
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function VersionBadge({
status === "beta"
? "bg-blue-500/20 text-blue-400"
: status === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
? "bg-warning/20 text-warning"
: "bg-accent-brand/20 text-accent-brand"
}${className ? ` ${className}` : ""}`;
File diff suppressed because it is too large. Load diff
@@ -21,6 +21,8 @@ export interface RegisteredDashboardCard {
defaultHeight?: number;
/** Which column a preset places this card in by default. Defaults to "main". */
defaultPanel?: "main" | "side";
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */
frame?: "framed" | "bare";
component: ComponentType<DashboardCardRenderProps>;
}
+412
View File
@@ -0,0 +1,412 @@
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";
export type VersionStatus =
"up_to_date" | "requires_update" | "beta" | "unknown";
function Stat({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
{children}
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
</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")}>
<div className="flex flex-wrap items-baseline gap-1.5">
<span className={big}>{versionText || "-"}</span>
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="w-fit"
/>
</div>
</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,
}: {
icon: React.ElementType;
label: string;
hint: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group/btn flex min-w-0 items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
>
<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}`;
};
return (
<div className="flex flex-col">
<div className="grid grid-cols-1 gap-1 p-2 sm:grid-cols-2">
<ActionButton
icon={Plus}
label={t("dashboard.addHost")}
hint={t("dashboardTab.registerNewServer")}
onClick={() =>
onOpenSingletonTab("host-manager", "host-manager:add-host")
}
/>
<ActionButton
icon={KeyRound}
label={t("dashboard.addCredential")}
hint={t("dashboardTab.storeSshKeysOrPasswords")}
onClick={() =>
onOpenSingletonTab("host-manager", "host-manager:add-credential")
}
/>
{isAdmin && (
<ActionButton
icon={Settings}
label={t("dashboard.adminSettings")}
hint={t("dashboardTab.manageUsersAndRoles")}
onClick={() => onOpenSingletonTab("admin-settings")}
/>
)}
<ActionButton
icon={User}
label={t("dashboard.userProfile")}
hint={t("dashboardTab.manageYourAccount")}
onClick={() => onOpenSingletonTab("user-profile")}
/>
</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();
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 border-b border-border/60 px-3 py-1.5 last:border-0 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. */}
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
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() - new Date(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)}`}
/>
<span className="truncate text-xs font-medium">
{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>
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
{ago(item.timestamp)}
</span>
</div>
);
})}
</div>
);
}
-167
View File
@@ -1,167 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
interface ConfirmationOptions {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive";
}
interface ToastConfirmOptions {
confirmOnEnter?: boolean;
duration?: number;
}
export function useConfirmation() {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
const [activeToastId, setActiveToastId] = useState<string | number | null>(
null,
);
const [pendingConfirmCallback, setPendingConfirmCallback] = useState<
(() => void) | null
>(null);
const [pendingResolve, setPendingResolve] = useState<
((value: boolean) => void) | null
>(null);
const handleEnterKey = useCallback(
(event: KeyboardEvent) => {
if (event.key === "Enter" && activeToastId !== null) {
event.preventDefault();
event.stopPropagation();
if (pendingConfirmCallback) {
pendingConfirmCallback();
}
if (pendingResolve) {
pendingResolve(true);
}
toast.dismiss(activeToastId);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
}
},
[activeToastId, pendingConfirmCallback, pendingResolve],
);
useEffect(() => {
if (activeToastId !== null) {
// Use capture phase to intercept Enter before terminal receives it
window.addEventListener("keydown", handleEnterKey, true);
return () => {
window.removeEventListener("keydown", handleEnterKey, true);
};
}
}, [activeToastId, handleEnterKey]);
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
setOptions(opts);
setOnConfirm(() => callback);
setIsOpen(true);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
setIsOpen(false);
setOptions(null);
setOnConfirm(null);
};
const handleCancel = () => {
setIsOpen(false);
setOptions(null);
setOnConfirm(null);
};
const confirmWithToast = (
opts: ConfirmationOptions | string,
callback?: () => void,
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
cancelLabel: string = "Cancel",
toastOptions: ToastConfirmOptions = { confirmOnEnter: false },
): Promise<boolean> => {
return new Promise((resolve) => {
const isVariant =
variantOrConfirmLabel === "default" ||
variantOrConfirmLabel === "destructive";
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
const { confirmOnEnter = false, duration = 8000 } = toastOptions;
const handleToastConfirm = () => {
if (callback) callback();
resolve(true);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
};
const handleToastCancel = () => {
resolve(false);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
};
const message = typeof opts === "string" ? opts : opts.description;
const actualConfirmLabel =
typeof opts === "object" && opts.confirmText
? opts.confirmText
: confirmLabel;
const actualCancelLabel =
typeof opts === "object" && opts.cancelText
? opts.cancelText
: cancelLabel;
const toastId = toast(message, {
duration,
action: {
label: confirmOnEnter
? `${actualConfirmLabel} ↵`
: actualConfirmLabel,
onClick: handleToastConfirm,
},
cancel: {
label: actualCancelLabel,
onClick: handleToastCancel,
},
onDismiss: () => {
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
},
onAutoClose: () => {
resolve(false);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
},
} as NonNullable<Parameters<typeof toast>[1]>);
if (confirmOnEnter) {
setActiveToastId(toastId);
setPendingConfirmCallback(() => () => {
if (callback) callback();
});
setPendingResolve(() => resolve);
}
});
};
return {
isOpen,
options,
confirm,
handleConfirm,
handleCancel,
confirmWithToast,
};
}
+3 -3
View File
@@ -56,9 +56,9 @@ export function getStatusClasses(
const online = status === true || status === "online";
if (loading || status === "unknown") {
if (scheme === "status") {
if (variant === "dot") return "bg-yellow-400 animate-pulse";
if (variant === "stripe") return "bg-yellow-400/40 animate-pulse";
return "border-yellow-400/40 text-yellow-400 bg-yellow-400/10 animate-pulse";
if (variant === "dot") return "bg-warning animate-pulse";
if (variant === "stripe") return "bg-warning/40 animate-pulse";
return "border-warning/40 text-warning bg-warning/10 animate-pulse";
}
if (variant === "dot") return "bg-muted-foreground/40 animate-pulse";
if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
+31
View File
@@ -88,6 +88,14 @@
--sidebar-accent-foreground: oklch(0.2115 0.0042 128.68);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--warning: oklch(0.795 0.162 86.05);
--terminal-bg: #ffffff;
--surface-dim: oklch(0.945 0 0);
--bg-base: var(--background);
--border-base: var(--border);
--foreground-secondary: var(--muted-foreground);
--canvas: var(--background);
--overlay: oklch(0 0 0 / 50%);
}
.dark {
@@ -124,6 +132,8 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0);
--warning: oklch(0.852 0.156 91.94);
--terminal-bg: #0c0d0b;
}
/* ── Dracula ────────────────────────────────────────────────── */
@@ -162,6 +172,8 @@
--sidebar-accent-foreground: #f8f8f2;
--sidebar-border: rgba(168, 179, 217, 0.18);
--sidebar-ring: #8be9fd;
--warning: #f1fa8c;
--terminal-bg: #282a36;
}
/* ── Catppuccin Mocha ───────────────────────────────────────── */
@@ -200,6 +212,8 @@
--sidebar-accent-foreground: #cdd6f4;
--sidebar-border: rgba(186, 194, 222, 0.18);
--sidebar-ring: #89b4fa;
--warning: #f9e2af;
--terminal-bg: #1e1e2e;
}
/* ── Nord ───────────────────────────────────────────────────── */
@@ -238,6 +252,8 @@
--sidebar-accent-foreground: #eceff4;
--sidebar-border: rgba(180, 192, 212, 0.18);
--sidebar-ring: #88c0d0;
--warning: #ebcb8b;
--terminal-bg: #2e3440;
}
/* ── Solarized Dark ─────────────────────────────────────────── */
@@ -276,6 +292,8 @@
--sidebar-accent-foreground: #d5dddd;
--sidebar-border: rgba(159, 179, 179, 0.2);
--sidebar-ring: #268bd2;
--warning: #b58900;
--terminal-bg: #002b36;
}
/* ── Tokyo Night ────────────────────────────────────────────── */
@@ -315,6 +333,8 @@
--sidebar-accent-foreground: #c0caf5;
--sidebar-border: rgba(139, 147, 189, 0.2);
--sidebar-ring: #7aa2f7;
--warning: #e0af68;
--terminal-bg: #1a1b26;
}
/* ── One Dark ───────────────────────────────────────────────── */
@@ -353,6 +373,8 @@
--sidebar-accent-foreground: #abb2bf;
--sidebar-border: rgba(154, 163, 178, 0.18);
--sidebar-ring: #61afef;
--warning: #e5c07b;
--terminal-bg: #282c34;
}
/* ── Gruvbox Dark ───────────────────────────────────────────── */
@@ -392,6 +414,8 @@
--sidebar-accent-foreground: #ebdbb2;
--sidebar-border: rgba(196, 181, 157, 0.18);
--sidebar-ring: #fabd2f;
--warning: #fabd2f;
--terminal-bg: #282828;
}
@layer base {
@@ -784,3 +808,10 @@ html.fs-xl {
transition-duration: 1ms !important;
}
}
/* A settings search result, marked briefly after the page opens. */
.settings-search-hit {
outline: 1px solid var(--accent-brand);
outline-offset: 2px;
transition: outline-color 1.2s ease;
}
+17
View File
@@ -0,0 +1,17 @@
/** Usage ramp for resource readouts: warning from 75, destructive from 90. */
/** Text color class for a percentage. */
export function usageColor(percent: number | null): string {
if (percent === null) return "text-muted-foreground";
if (percent >= 90) return "text-destructive";
if (percent >= 75) return "text-warning";
return "text-accent-brand";
}
/** Background class for the same ramp. */
export function usageBarColor(percent: number | null): string {
if (percent === null) return "bg-muted-foreground/30";
if (percent >= 90) return "bg-destructive";
if (percent >= 75) return "bg-warning";
return "bg-accent-brand";
}
+128 -8
View File
@@ -3,7 +3,10 @@
"retryingIn": "Retrying in {{seconds}}s ({{attempt}}/{{max}})",
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
"disconnected": "Unable to connect",
"reconnect": "Reconnect"
"reconnect": "Reconnect",
"failed": "Could not connect",
"failedRetrying": "Connection failed, trying again",
"retryNow": "Retry now"
},
"credentials": {
"folders": "Folders",
@@ -314,7 +317,21 @@
"openSidebar": "Open sidebar",
"confirmCloseHost": "Close {{host}}?",
"enterFullscreen": "Enter fullscreen (Ctrl+Shift+F)",
"exitFullscreen": "Exit fullscreen (Ctrl+Shift+F)"
"exitFullscreen": "Exit fullscreen (Ctrl+Shift+F)",
"group": {
"objects": "Yours",
"tools": "Tools",
"system": "System"
},
"searchEverything": "Search hosts, tabs and actions",
"search": "Search",
"hiddenCount": "{{count}} hidden",
"showInRail": "Show in sidebar",
"hideFromRail": "Hide from sidebar",
"openSettings": "Open settings",
"settings": "Settings",
"manage": "Manage",
"mobileNavigation": "Navigation"
},
"hosts": {
"hosts": "Hosts",
@@ -975,7 +992,9 @@
"expiresIn": "Expires in {{duration}}",
"search": "Search connections...",
"noSearchResults": "No connections match your search",
"rename": "Rename session"
"rename": "Rename session",
"forgetConfirm": "End the background session for {{name}}?",
"forget": "End session"
},
"sshAuth": {
"totpRequired": "Two-Factor Authentication Required",
@@ -1064,7 +1083,13 @@
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
"serverAuthentication": "Server sign-in",
"minChars": "min. {{min}} characters",
"retrySecondFactorUI": "Reload verification methods"
"retrySecondFactorUI": "Reload verification methods",
"localDesktopSignedOut": "Local desktop signed out",
"localDesktopUnavailable": "Local desktop session unavailable",
"localDesktopSignedOutDesc": "You signed out of the local desktop session. Continue locally to use this device's built-in Termix server again.",
"localDesktopUnavailableDesc": "Termix could not start a local desktop session. Retry it instead of registering a new account.",
"continueLocalDesktop": "Continue with local desktop",
"retryLocalDesktop": "Retry local desktop session"
},
"hostKey": {
"verifyNewHost": "Verify SSH Host Key",
@@ -1205,7 +1230,19 @@
"clear": "Clear",
"viewServerDetails": "View server details",
"dragToResizeColumns": "Drag to resize columns",
"hostsAvailable": "Hosts Available"
"hostsAvailable": "Hosts Available",
"removeSection": "Remove section",
"addSection": "Add a section",
"allSectionsShown": "Everything is already on the dashboard.",
"fromPlugins": "From plugins",
"dropHere": "Drop a section here",
"dragToReorder": "Drag sections to reorder them",
"clearActivityConfirm": "Clear your recent activity?",
"resetConfirm": "Reset the dashboard to its default layout?",
"onlineCount_one": "{{count}} online",
"onlineCount_other": "{{count}} online",
"hostCount_one": "{{count}} host",
"hostCount_other": "{{count}} hosts"
},
"admin": {
"sectionGeneral": "General",
@@ -1515,7 +1552,13 @@
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting",
"hostTags": "Predefined host tags",
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
"hostTagsError": "Could not load or save host tag suggestions."
"hostTagsError": "Could not load or save host tag suggestions.",
"revokeAllSessionsConfirm": "Sign {{username}} out everywhere?",
"revokeSessionConfirm": "Sign out this session?",
"revokeSession": "Sign out",
"deleteRoleConfirm": "Delete the {{name}} role?",
"revokeKeyConfirm": "Revoke the API key {{name}}?",
"revokeKey": "Revoke"
},
"newUi": {
"sidebar": {
@@ -1582,7 +1625,9 @@
"actionOpenCommandPalette": "Open quick search",
"actionReconnectSession": "Reconnect current session",
"unavailableAction": "{{type}} (not available)",
"actionRequiredError": "Pick an action first"
"actionRequiredError": "Pick an action first",
"deleteConfirm": "Delete this keybinding?",
"resetAllConfirm": "Reset every keybinding to its default?"
},
"userProfile": {
"donateTitle": "Keep Termix alive",
@@ -1917,7 +1962,33 @@
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
"pluginSettingsSaveFailed": "Failed to save settings",
"pluginSettingsLoadFailed": "Failed to load settings",
"featureUnavailable": "{{name}} is unavailable right now. These settings are saved but will not take effect."
"featureUnavailable": "{{name}} is unavailable right now. These settings are saved but will not take effect.",
"account": "Account",
"security": "Security",
"apiKeys": "API keys",
"data": "Data and sync",
"interface": "Interface",
"appearance": "Appearance",
"keybindings": "Keybindings",
"adminGeneral": "General",
"adminUsers": "Users",
"adminSessions": "Sessions",
"adminRoles": "Roles",
"adminHostDefaults": "Host defaults",
"adminBranding": "Branding",
"adminDatabase": "Database",
"adminSsl": "SSL",
"adminApiKeys": "API keys",
"adminAuditLog": "Audit log",
"bandYou": "Your account",
"bandApp": "This app",
"bandPlugins": "Plugins",
"bandInstance": "This server",
"searchPlaceholder": "Search settings",
"noResults": "No settings match.",
"pages": "Pages",
"instanceSettings": "Server settings",
"nothingHere": "Nothing to set here."
},
"managerCard": {
"refresh": "Refresh",
@@ -2091,5 +2162,54 @@
"resetCore": "Connection and SSH to defaults",
"resetDone_one": "{{count}} host now follows its defaults.",
"resetDone_other": "{{count}} hosts now follow their defaults."
},
"panel": {
"clearSearch": "Clear search",
"gridView": "Grid",
"listView": "List",
"compactRows": "Compact rows",
"comfortableRows": "Comfortable rows"
},
"fields": {
"secretSaved": "Saved. Type to replace it."
},
"sharing": {
"revokeConfirm": "Remove access for {{name}}?",
"revoke": "Remove access"
},
"manage": {
"sections": "Sections",
"sectionNeedsAttention": "Something here needs attention",
"needsAttention_one": "One thing needs attention.",
"needsAttention_other": "{{count}} things need attention.",
"bandHost": "Host",
"bandFeatures": "Protocols and features",
"bandCredential": "Credential",
"sshConnection": "SSH connection",
"errorAddressRequired": "An address is required.",
"errorPortRange": "Use a port between 1 and 65535.",
"defaults": "Defaults",
"searchHosts": "Search hosts",
"searchCredentials": "Search credentials",
"searchDefaults": "Search defaults",
"addCredential": "Add credential",
"nothingMatches": "Nothing matches",
"nothingYet": "Nothing here yet",
"emptyHostsHint": "Add a host and it shows up here, in whatever folder you give it.",
"emptyCredentialsHint": "Credentials you save can be reused by any host.",
"collapse": "Collapse",
"expand": "Expand",
"cannotBeUndone": "This cannot be undone.",
"hostCount_one": "{{count}} host",
"hostCount_other": "{{count}} hosts",
"credentialCount_one": "{{count}} credential",
"credentialCount_other": "{{count}} credentials",
"newHost": "New host",
"newCredential": "New credential",
"advanced": "Advanced",
"pickHost": "Pick a host to edit",
"pickCredential": "Pick a credential to edit",
"pickDefaults": "Pick a level of defaults to edit",
"pickHint": "Everything about a connection lives here, grouped so you can find it."
}
}
+256
View File
@@ -0,0 +1,256 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import { TabStrip } from "@/components/tab-strip";
import { cn } from "@/lib/utils";
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[];
}
/** 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 top = container.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 (
container.scrollHeight > container.clientHeight &&
container.scrollTop + container.clientHeight >=
container.scrollHeight - 8
) {
current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
onScroll();
container.addEventListener("scroll", onScroll, { passive: true });
return () => container.removeEventListener("scroll", onScroll);
// 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);
container.scrollTo?.({
top:
el.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop -
8,
behavior: "smooth",
});
},
};
}
/** 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 [shown, setShown] = useState(
() => typeof IntersectionObserver === "undefined",
);
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 ids = bands.flatMap((band) => band.items.map((item) => item.id));
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
const showNav = ids.length > 1;
if (revealRef) revealRef.current = scrollTo;
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"
>
{bands.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={() => scrollTo(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">
{showNav && (
<div className="shrink-0 border-b border-border px-1 lg:hidden">
<TabStrip
tabs={bands.flatMap((band) =>
band.items.map((item) => ({ id: item.id, label: item.label })),
)}
activeTab={active}
onTabChange={scrollTo}
/>
</div>
)}
<div
ref={scrollRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
<div className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}>
{banner}
{children}
</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>
);
}
+422
View File
@@ -0,0 +1,422 @@
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, Segmented } 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-sidebar md:w-72",
)}
>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<Segmented
value={mode}
onChange={onMode}
options={[
{ value: "hosts", label: t("nav.hosts"), count: hosts.length },
{
value: "credentials",
label: t("nav.credentials"),
count: credentials.length,
},
{ value: "defaults", label: t("manage.defaults") },
]}
className="w-full [&>button]:flex-1"
/>
<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>
);
}
+550
View File
@@ -0,0 +1,550 @@
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/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 { 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";
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) => setHosts(raw.map(sshHostToHost)))
.catch(() => {});
}, []);
const reloadCredentials = useCallback(() => {
return getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.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 host = request.hostId
? (hostsRef.current.find((h) => h.id === request.hostId) ?? null)
: null;
if (request.hostId && !host) return;
openHost(host, request.draft);
} else if (request.kind === "credential") {
const credential = request.credentialId
? (credentialsRef.current.find(
(c) => c.id === request.credentialId,
) ?? null)
: null;
void openCredential(credential);
} else if (request.kind === "defaults") {
void openDefaults(request);
} else {
setDirty(false);
setEditing(null);
setMode(request.mode);
}
},
[confirmDiscard, openHost, openCredential, openDefaults],
);
// 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}
leading={
editing ? (
<span className="flex h-full md:hidden">
<BackButton
onClick={() =>
void guarded(
editing.kind === "credential" ? backFromCredential : close,
)()
}
/>
</span>
) : undefined
}
actions={
<>
{editing?.kind === "host" && (
<label className="flex items-center gap-2 px-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("manage.advanced")}
</span>
<FakeSwitch checked={advanced} onChange={setAdvanced} />
</label>
)}
{canDelete && (
<Button
variant="ghost"
size="icon-sm"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={() => void deleteCurrent()}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
)}
</>
}
>
<div className="flex min-h-0 flex-1">
<ManageList
editing={!!editing}
mode={mode}
onMode={(next) =>
void guarded(() => {
setMode(next);
close();
})()
}
hosts={hosts}
credentials={credentials}
canEditInstanceDefaults={has("admin.settings.manage")}
isOnline={isOnline}
selectedKey={selectedKey}
onPickHost={(host) => void guarded(() => openHost(host))()}
onPickCredential={(cred) =>
void guarded(() => void openCredential(cred))()
}
onPickDefaults={(row) => void guarded(() => void openDefaults(row))()}
onAdd={() =>
void guarded(() =>
mode === "credentials"
? void openCredential(null)
: openHost(null),
)()
}
/>
{editing ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{renderEditor()}
</div>
) : (
<div className="hidden min-h-0 flex-1 items-center justify-center md:flex">
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={
mode === "hosts"
? t("manage.pickHost")
: mode === "credentials"
? t("manage.pickCredential")
: t("manage.pickDefaults")
}
hint={t("manage.pickHint")}
action={
mode !== "defaults" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
mode === "hosts"
? openHost(null)
: void openCredential(null)
}
>
<Plus className="mr-1 size-3" />
{mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")}
</Button>
) : undefined
}
/>
</div>
)}
</div>
</PanelShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Host } from "@/types/ui-types";
import type { HostDefaultsLevel } from "@/types/host-defaults";
export interface DefaultsRow {
key: string;
level: HostDefaultsLevel;
folderName?: string;
label: string;
}
/** The levels of host defaults a user can open: instance, their own, folders. */
export function defaultsRows(
hosts: Host[],
canEditInstance: boolean,
t: (key: string, options?: Record<string, unknown>) => string,
): DefaultsRow[] {
const rows: DefaultsRow[] = [];
if (canEditInstance)
rows.push({
key: "admin",
level: "admin",
label: t("hostDefaults.titleAdmin"),
});
rows.push({ key: "user", level: "user", label: t("hostDefaults.titleUser") });
const folders = new Set<string>();
for (const host of hosts) {
if (!host.folder) continue;
let path = "";
for (const part of host.folder.split(" / ")) {
path = path ? `${path} / ${part}` : part;
folders.add(path);
}
}
for (const folder of [...folders].sort((a, b) => a.localeCompare(b))) {
rows.push({
key: `folder:${folder}`,
level: "folder",
folderName: folder,
label: folder,
});
}
return rows;
}
+21
View File
@@ -0,0 +1,21 @@
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;
}
+73
View File
@@ -0,0 +1,73 @@
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { HostDefaultsLevel } from "@/types/host-defaults";
export type ManageMode = "hosts" | "credentials" | "defaults";
/** What to open in the Manage tab. */
export type ManageRequest =
| { kind: "host"; hostId: string | null; draft?: HostDraft }
| { kind: "credential"; credentialId: string | null }
| { kind: "defaults"; level: HostDefaultsLevel; folderName?: string }
| { kind: "browse"; mode: ManageMode };
export const MANAGE_REQUEST_EVENT = "manage:request";
let pending: ManageRequest | null = null;
/**
* Parks a request for the Manage tab and pokes it. The shell opens the tab on
* the same event, and the tab claims the parked request once it mounts.
*/
export function requestManage(request: ManageRequest): void {
pending = request;
window.dispatchEvent(new Event(MANAGE_REQUEST_EVENT));
}
/** Takes the parked request, leaving none behind. */
export function takePendingManageRequest(): ManageRequest | null {
const request = pending;
pending = null;
return request;
}
/**
* The window events older callers fire (the dashboard, folder menus, the
* palette), turned into a request. Null for anything else.
*/
export function requestFromLegacyEvent(
name: string,
detail?: unknown,
): ManageRequest | null {
switch (name) {
case "host-manager:add-host":
return {
kind: "host",
hostId: null,
draft: (detail as HostDraft | undefined) ?? undefined,
};
case "host-manager:edit-host":
return detail ? { kind: "host", hostId: String(detail) } : null;
case "host-manager:add-credential":
return { kind: "credential", credentialId: null };
case "host-manager:show-credentials":
return { kind: "browse", mode: "credentials" };
case "host-manager:edit-defaults":
case "termix:open-host-defaults": {
const target = detail as
{ level?: HostDefaultsLevel; folderName?: string } | undefined;
if (!target?.level) return { kind: "browse", mode: "defaults" };
return {
kind: "defaults",
level: target.level,
folderName: target.folderName,
};
}
default:
return null;
}
}
/** Test seam. */
export function resetManageRequests(): void {
pending = null;
}
+256
View File
@@ -0,0 +1,256 @@
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;
}
+4
View File
@@ -159,6 +159,7 @@ export function createPluginApp(
mobilePrimary: item.mobilePrimary,
electronOnly: item.electronOnly,
separatorAfter: item.separatorAfter ?? true,
group: item.group,
hidden: item.hidden,
after: item.after,
order: item.order,
@@ -392,6 +393,7 @@ export function createPluginApp(
titleKey: key(card.titleKey),
defaultHeight: card.defaultHeight,
defaultPanel: card.defaultPanel,
frame: card.frame,
component: scoped(
card.component as unknown as ComponentType<DashboardCardRenderProps>,
),
@@ -546,6 +548,8 @@ export function createPluginApp(
onReady: (listener) => track(tabsApi.onReady(listener)),
},
confirm: (options) => shell.confirm!(options),
desktop: {
available: isElectron(),
remoteServerUrl,
+63 -1
View File
@@ -194,7 +194,6 @@ export {
ManagerSearch,
type ManagerCardError,
} from "@/components/manager-card";
export { useConfirmation } from "@/hooks/use-confirmation";
export { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
// Homepage widget pieces, for plugins that register a widget.
export { WidgetTitle } from "@/lib/widget-title";
@@ -276,3 +275,66 @@ export { getDeviceId } from "@/lib/device-id";
// A host that is never saved, for connecting to an address straight away.
export { createQuickConnectHost } from "@/sidebar/quick-connect-host";
// The panel kit every tab and sidebar panel is built from.
export {
BackButton,
Facts,
GroupHeading,
PANEL,
PanelSearch,
PanelShell,
Segmented,
ViewToggle,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
export {
DataView,
type DataColumn,
type GridColumns,
} from "@/components/data-view";
export { TabStrip, type TabStripItem } from "@/components/tab-strip";
export { CardMasonry } from "@/components/card-masonry";
export { Meter, UsagePair } from "@/components/meter";
export { usageBarColor, usageColor } from "@/lib/usage-color";
export {
Field,
FieldPair,
NumberField,
Repeater,
SecretField,
SelectField,
SwitchRow,
TagInput,
TextAreaField,
TextField,
} from "@/components/form-fields";
// Views and questions drawn over the panel, tab or pane that asked, instead
// of modals over the whole app.
export {
InlineView,
PanePrompt,
SurfaceScope,
useConfirm,
useSurfaceKind,
type ConfirmOptions,
type EditingWidth,
type SurfaceKind,
} from "@/components/surface/surface-scope";
export {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "@/components/surface/prompt-styles";
// One connection lifecycle: connecting, failed with retry and a log, dropped,
// or unavailable.
export {
ConnectionGate,
useConnectionGate,
type ConnectionGateController,
type ConnectionGateProps,
} from "@/components/connection/ConnectionGate";
export { type ConnectionUnavailable } from "@/components/connection/ConnectionScreen";
+8
View File
@@ -116,6 +116,14 @@ export const shell: TabShellCallbacks = {
openHostEditor: (...args) => callbacks?.openHostEditor?.(...args),
saveQuickConnect: (...args) =>
callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(),
confirm: (options) =>
callbacks?.confirm
? callbacks.confirm(options)
: Promise.resolve(
window.confirm(
[options.title, options.description].filter(Boolean).join(" "),
),
),
};
export const tabsApi = {
+2 -1
View File
@@ -22,6 +22,7 @@ import type {
import i18n from "@/i18n/i18n";
import type { PluginSummary } from "@/api/plugins-api";
import { ThemeProvider } from "@/components/theme-provider";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import {
getTabType,
@@ -98,7 +99,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks {
function wrap(element: ReactElement): HTMLElement {
return render(
<ThemeProvider defaultTheme="dark" storageKey="termix-test-theme">
{element}
<SurfaceScope className="h-full">{element}</SurfaceScope>
</ThemeProvider>,
).container;
}
+2 -2
View File
@@ -96,7 +96,7 @@ export function FeatureSettingsSection({
);
}
function FeatureSettingsForm({
export function FeatureSettingsForm({
plugin,
scope,
fields,
@@ -208,7 +208,7 @@ function FeatureSettingsForm({
return (
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
+1 -1
View File
@@ -157,7 +157,7 @@ function HostPluginSection({
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
>
{!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", { name: plugin.name })}
</div>
)}
+477
View File
@@ -0,0 +1,477 @@
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>
<select
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>
))}
</select>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
</InlineView>
</>
);
}
+1 -1
View File
@@ -117,7 +117,7 @@ export function SettingsFieldRow({
{label(field.labelKey)}
</span>
{error && (
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
<span className="text-[10px] font-bold text-warning border border-warning/40 px-1 shrink-0">
{t("common.error")}
</span>
)}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, 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 { cn } from "@/lib/utils";
import { pluginKey } 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 { SettingsEmbedContext } from "./settings-embed";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
searchSettingsPages,
visibleSettingsPages,
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: 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,
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 contentRef = 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: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
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]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
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);
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 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);
if (items.length === 0) 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>
);
})}
</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}
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-sidebar 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
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
<div
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 />}
{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>
);
}
+11
View File
@@ -0,0 +1,11 @@
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);
}
+230
View File
@@ -0,0 +1,230 @@
import {
Database,
FileText,
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 { 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;
/** 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;
/** 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",
band: "you",
labelKey: "settings.account",
icon: User,
profileSection: "account",
}),
core({
id: "security",
band: "you",
labelKey: "settings.security",
icon: Shield,
profileSection: "security",
browserOnly: true,
}),
core({
id: "api-keys",
band: "you",
labelKey: "settings.apiKeys",
icon: KeyRound,
profileSection: "api-keys",
}),
core({
id: "data",
band: "you",
labelKey: "settings.data",
icon: Database,
profileSection: "data",
}),
core({
id: "interface",
band: "app",
labelKey: "settings.interface",
icon: SlidersHorizontal,
profileSection: "interface",
}),
core({
id: "appearance",
band: "app",
labelKey: "settings.appearance",
icon: Palette,
profileSection: "appearance",
}),
core({
id: "keybindings",
band: "app",
labelKey: "settings.keybindings",
icon: Keyboard,
profileSection: "keybindings",
}),
core({
id: "admin-general",
band: "instance",
labelKey: "settings.adminGeneral",
icon: Settings,
admin: true,
adminSection: "general",
}),
core({
id: "admin-users",
band: "instance",
labelKey: "settings.adminUsers",
icon: Users,
admin: true,
adminSection: "users",
}),
core({
id: "admin-sessions",
band: "instance",
labelKey: "settings.adminSessions",
icon: Monitor,
admin: true,
adminSection: "sessions",
}),
core({
id: "admin-roles",
band: "instance",
labelKey: "settings.adminRoles",
icon: Lock,
admin: true,
adminSection: "roles",
}),
core({
id: "admin-host-defaults",
band: "instance",
labelKey: "settings.adminHostDefaults",
icon: Server,
admin: true,
adminSection: "host-defaults",
}),
core({
id: "admin-branding",
band: "instance",
labelKey: "settings.adminBranding",
icon: Palette,
admin: true,
adminSection: "branding",
}),
core({
id: "admin-database",
band: "instance",
labelKey: "settings.adminDatabase",
icon: Database,
admin: true,
adminSection: "database",
}),
core({
id: "admin-ssl",
band: "instance",
labelKey: "settings.adminSsl",
icon: Globe,
admin: true,
adminSection: "ssl",
}),
core({
id: "admin-api-keys",
band: "instance",
labelKey: "settings.adminApiKeys",
icon: KeyRound,
admin: true,
adminSection: "api-keys",
}),
core({
id: "admin-audit-log",
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.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;
}
+427
View File
@@ -0,0 +1,427 @@
// 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.betaProgramDescription",
"newUi.sidebar.userProfile.betaProgramFeedback",
"newUi.sidebar.userProfile.betaProgramLearnMore",
"newUi.sidebar.userProfile.betaProgramTitle",
"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.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.setUpSync",
"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",
"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",
],
"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",
"admin.rolePermissions.editAction",
"admin.rolePermissions.loadError",
"admin.rolePermissions.pluginDisabled",
"admin.rolePermissions.save",
"admin.rolePermissions.saveError",
"admin.rolePermissions.saved",
"admin.rolePermissions.saving",
"admin.rolesCount",
"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",
"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",
"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",
"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": [
"newUi.sidebar.userProfile.accentColorLabel",
"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",
],
};
+239 -212
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { InlineView, useSurfaceKind } from "@/components/surface/surface-scope";
import { toast } from "sonner";
import { AlertCircle, Loader2, X } from "lucide-react";
import { Button } from "@/components/button";
@@ -222,6 +223,241 @@ export function LinkServerDialog({
0,
);
const wide = step === "signin";
const inSurface = useSurfaceKind() !== null;
const title = relogin
? t("sync.wizard.reloginTitle")
: t("sync.wizard.title");
const description =
step === "server"
? t("sync.wizard.serverDescription")
: step === "signin"
? t("sync.wizard.signinDescription", { url: target.serverUrl })
: step === "choose"
? t("sync.wizard.chooseDescription")
: t("sync.wizard.finishingDescription");
const content = (
<>
{step === "server" && (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleCheck();
}}
>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold" htmlFor="sync-server-url">
{t("sync.wizard.serverUrl")}
</label>
<Input
id="sync-server-url"
autoFocus
className="rounded-none"
placeholder="https://termix.example.com"
value={target.serverUrl}
onChange={(event) =>
setTarget({ ...target, serverUrl: event.target.value })
}
/>
<p className="text-[10px] text-muted-foreground">
{t("sync.wizard.serverUrlHint")}
</p>
{!relogin && savedUrls.length > 0 && (
<div className="flex flex-col gap-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("sync.wizard.savedServers")}
</span>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center border border-border hover:bg-muted"
>
<button
type="button"
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
onClick={() => setTarget({ ...target, serverUrl: url })}
>
{url}
</button>
<button
type="button"
title={t("sync.wizard.removeServer")}
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
onClick={() => setSavedUrls(forgetServerUrl(url))}
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
{problem && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{problem}</span>
</div>
)}
{(problemKind === "tls_untrusted" ||
target.allowInvalidCertificate) && (
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={!!target.allowInvalidCertificate}
onCheckedChange={(checked) =>
setTarget({ ...target, allowInvalidCertificate: checked })
}
/>
</div>
)}
<button
type="button"
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((value) => !value)}
>
{showAdvanced
? t("sync.wizard.hideProxySettings")
: t("sync.wizard.showProxySettings")}
</button>
{showAdvanced && (
<ProxySettingsFields
customHeaders={target.customHeaders ?? []}
basicAuth={target.basicAuth ?? null}
onChange={(next) => setTarget({ ...target, ...next })}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
className="rounded-none"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
{t("sync.wizard.continue")}
</Button>
</div>
</form>
)}
{step === "signin" && (
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={target.serverUrl}
targetPurpose="link"
onAuthSuccess={handleSignedIn}
onChangeServer={() =>
relogin ? onOpenChange(false) : setStep("server")
}
/>
</div>
)}
{step === "choose" && (
<div className="flex flex-col gap-3">
{finishError && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{finishError}</span>
</div>
)}
{(
[
{
value: "merge",
title: t("sync.wizard.mergeTitle"),
body: t("sync.wizard.mergeBody", { count: localTotal }),
},
{
value: "replace",
title: t("sync.wizard.replaceTitle"),
body: t("sync.wizard.replaceBody", { count: localTotal }),
},
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
mode === 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 className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="rounded-none"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
{t("sync.wizard.link")}
</Button>
</div>
</div>
)}
{step === "finishing" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin text-accent-brand" />
{t("sync.wizard.linking")}
</div>
)}
</>
);
// From a sidebar panel the wizard takes over the panel. Outside one (the
// first-run setup) it stays a dialog.
if (inSurface) {
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={title}
width="wide"
scroll={!wide}
>
{!wide && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{content}
</InlineView>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -235,220 +471,11 @@ export function LinkServerDialog({
onInteractOutside={(event) => wide && event.preventDefault()}
>
<DialogHeader className={wide ? "sr-only" : undefined}>
<DialogTitle>
{relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")}
</DialogTitle>
<DialogDescription>
{step === "server" && t("sync.wizard.serverDescription")}
{step === "signin" &&
t("sync.wizard.signinDescription", { url: target.serverUrl })}
{step === "choose" && t("sync.wizard.chooseDescription")}
{step === "finishing" && t("sync.wizard.finishingDescription")}
</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{step === "server" && (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleCheck();
}}
>
<div className="flex flex-col gap-1.5">
<label
className="text-xs font-semibold"
htmlFor="sync-server-url"
>
{t("sync.wizard.serverUrl")}
</label>
<Input
id="sync-server-url"
autoFocus
className="rounded-none"
placeholder="https://termix.example.com"
value={target.serverUrl}
onChange={(event) =>
setTarget({ ...target, serverUrl: event.target.value })
}
/>
<p className="text-[10px] text-muted-foreground">
{t("sync.wizard.serverUrlHint")}
</p>
{!relogin && savedUrls.length > 0 && (
<div className="flex flex-col gap-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("sync.wizard.savedServers")}
</span>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center border border-border hover:bg-muted"
>
<button
type="button"
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
onClick={() => setTarget({ ...target, serverUrl: url })}
>
{url}
</button>
<button
type="button"
title={t("sync.wizard.removeServer")}
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
onClick={() => setSavedUrls(forgetServerUrl(url))}
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
{problem && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{problem}</span>
</div>
)}
{(problemKind === "tls_untrusted" ||
target.allowInvalidCertificate) && (
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={!!target.allowInvalidCertificate}
onCheckedChange={(checked) =>
setTarget({ ...target, allowInvalidCertificate: checked })
}
/>
</div>
)}
<button
type="button"
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((value) => !value)}
>
{showAdvanced
? t("sync.wizard.hideProxySettings")
: t("sync.wizard.showProxySettings")}
</button>
{showAdvanced && (
<ProxySettingsFields
customHeaders={target.customHeaders ?? []}
basicAuth={target.basicAuth ?? null}
onChange={(next) => setTarget({ ...target, ...next })}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
className="rounded-none"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
{t("sync.wizard.continue")}
</Button>
</div>
</form>
)}
{step === "signin" && (
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={target.serverUrl}
targetPurpose="link"
onAuthSuccess={handleSignedIn}
onChangeServer={() =>
relogin ? onOpenChange(false) : setStep("server")
}
/>
</div>
)}
{step === "choose" && (
<div className="flex flex-col gap-3">
{finishError && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{finishError}</span>
</div>
)}
{(
[
{
value: "merge",
title: t("sync.wizard.mergeTitle"),
body: t("sync.wizard.mergeBody", { count: localTotal }),
},
{
value: "replace",
title: t("sync.wizard.replaceTitle"),
body: t("sync.wizard.replaceBody", { count: localTotal }),
},
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
mode === 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 className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="rounded-none"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
{t("sync.wizard.link")}
</Button>
</div>
</div>
)}
{step === "finishing" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin text-accent-brand" />
{t("sync.wizard.linking")}
</div>
)}
{content}
</DialogContent>
</Dialog>
);
+47 -50
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import {
useCallback,
useEffect,
@@ -17,14 +18,6 @@ import {
} from "lucide-react";
import { Button } from "@/components/button";
import { Switch } from "@/components/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
@@ -565,45 +558,12 @@ export function SyncPanel() {
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
/>
<Dialog open={unlinkOpen} onOpenChange={setUnlinkOpen}>
<DialogContent className="bg-card border border-border rounded-none sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.unlinkTitle")}</DialogTitle>
<DialogDescription>{t("sync.unlinkDescription")}</DialogDescription>
</DialogHeader>
<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>
<DialogFooter>
<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"
@@ -626,9 +586,46 @@ export function SyncPanel() {
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
{t("sync.unlink")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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>
);
+339 -442
View File
@@ -8,26 +8,26 @@ import {
CommandItem,
CommandList,
CommandGroup,
CommandSeparator,
CommandEmpty,
} from "@/components/command";
import {
Server,
Settings,
Globe,
Plus,
MessagesSquare,
LifeBuoy,
Search,
User,
KeyRound,
Clock,
Folder,
Globe,
KeyRound,
LifeBuoy,
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 { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
import { useRailItems } from "@/sidebar/rail-items";
import {
defaultConnectAction,
hostActionsFor,
@@ -47,6 +47,10 @@ import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
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[];
@@ -65,6 +69,8 @@ export function CommandPalette({
activeTabId = "",
onOpenTab,
onOpenPanel,
onOpenSettings,
onEditHost,
}: CommandPaletteProps) {
const { t } = useTranslation();
const railItems = useRailItems();
@@ -120,22 +126,26 @@ export function CommandPalette({
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, setIsOpen]);
const filteredHosts = hosts.filter((h) => {
const query = search.toLowerCase();
return (
h.name.toLowerCase().includes(query) ||
h.ip.toLowerCase().includes(query) ||
h.username.toLowerCase().includes(query) ||
h.tags?.some((tag) => tag.toLowerCase().includes(query))
);
});
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 under an implicit root group
// 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) {
if (h.folder && searching) {
if (!folderMap.has(h.folder)) folderMap.set(h.folder, []);
folderMap.get(h.folder)!.push(h);
} else {
@@ -151,12 +161,9 @@ export function CommandPalette({
useEffect(() => {
if (!isOpen) return;
const firstHost = filteredHosts[0];
if (search.trim() && firstHost) {
setSelectedValue(`host-${firstHost.id}`);
return;
}
setSelectedValue("quick-action-add-host");
}, [filteredHosts, isOpen, search]);
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];
@@ -165,21 +172,83 @@ export function CommandPalette({
action();
setIsOpen(false);
};
const showHostResultsFirst = search.trim().length > 0;
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-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-support",
label: t("dashboard.support"),
icon: LifeBuoy,
url: "https://github.com/Termix-SSH/Support/issues/new",
},
].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={cn(
"fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] bg-background/40 backdrop-blur-sm transition-all duration-200 animate-in fade-in",
)}
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/40 pt-[15vh] backdrop-blur-sm"
onClick={() => setIsOpen(false)}
>
<div
className={cn(
"w-full max-w-2xl mx-4 overflow-hidden rounded-none border border-border bg-card shadow-2xl animate-in zoom-in-95 duration-200",
)}
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
@@ -189,247 +258,28 @@ export function CommandPalette({
value={selectedValue}
onValueChange={setSelectedValue}
>
<div className="flex items-center border-b border-border px-4 py-1">
<Search className="size-4 text-muted-foreground mr-3" />
<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="flex-1 h-12 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<div className="flex items-center gap-1.5 ml-2">
<Kbd className="bg-muted/50 border-none h-6 px-2 text-[11px] rounded-none">
ESC
</Kbd>
</div>
<Kbd className="ml-2">ESC</Kbd>
</div>
<CommandList className="max-h-[60vh] thin-scrollbar flex flex-col">
<CommandGroup
heading={t("commandPalette.quickActions")}
className="px-2"
>
<CommandItem
value="quick-action-add-host"
onSelect={() =>
handleAction(() =>
onOpenTab(
"host-manager",
undefined,
"host-manager:add-host",
),
)
}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
<Plus className="size-4 text-accent-brand" />
</div>
<div className="flex flex-col flex-1">
<span className="text-sm font-semibold">
{t("commandPalette.addNewHost")}
</span>
<span className="text-xs text-muted-foreground">
{t("commandPalette.addNewHostDesc")}
</span>
</div>
</CommandItem>
<CommandList className="thin-scrollbar max-h-[60vh]">
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
{t("commandPalette.noHostsFound", { search })}
</CommandEmpty>
<CommandItem
value="quick-action-admin-settings"
onSelect={() => handleAction(() => onOpenTab("admin-settings"))}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
<Settings className="size-4 text-accent-brand" />
</div>
<div className="flex flex-col flex-1">
<span className="text-sm font-semibold">
{t("commandPalette.adminSettings")}
</span>
<span className="text-xs text-muted-foreground">
{t("commandPalette.adminSettingsDesc")}
</span>
</div>
</CommandItem>
<CommandItem
value="quick-action-user-profile"
onSelect={() => handleAction(() => onOpenTab("user-profile"))}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
<User className="size-4 text-accent-brand" />
</div>
<div className="flex flex-col flex-1">
<span className="text-sm font-semibold">
{t("commandPalette.userProfile")}
</span>
<span className="text-xs text-muted-foreground">
{t("commandPalette.userProfileDesc")}
</span>
</div>
</CommandItem>
<CommandItem
value="quick-action-add-credential"
onSelect={() =>
handleAction(() =>
onOpenTab(
"host-manager",
undefined,
"host-manager:add-credential",
),
)
}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
<KeyRound className="size-4 text-accent-brand" />
</div>
<div className="flex flex-col flex-1">
<span className="text-sm font-semibold">
{t("commandPalette.addCredential")}
</span>
<span className="text-xs text-muted-foreground">
{t("commandPalette.addCredentialDesc")}
</span>
</div>
</CommandItem>
</CommandGroup>
{onOpenPanel && (
<>
<CommandSeparator className="my-2" />
<CommandGroup
heading={t("commandPalette.navigation")}
className="px-2"
>
{[...railItems, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.kind !== "tab")
.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={`nav-${item.id}`}
value={`nav-${item.id} ${t(item.labelKey)}`}
onSelect={() =>
handleAction(() => onOpenPanel(item.id))
}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
<Icon className="size-4 text-accent-brand" />
</div>
<span className="text-sm font-semibold flex-1">
{t(item.labelKey)}
</span>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{paletteGroups.map((group) => {
const items = filterPaletteItems(
groupItems[group.id] ?? [],
search,
group.showWhenEmpty,
);
if (items.length === 0) return null;
return (
<React.Fragment key={`group-${group.id}`}>
<CommandSeparator className="my-2" />
<CommandGroup heading={t(group.titleKey)} className="px-2">
{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(
"group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer",
blocked && "pointer-events-none opacity-50",
)}
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
{Icon && (
<Icon className="size-4 text-accent-brand" />
)}
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
{item.title}
</span>
{item.description && (
<span className="text-xs text-muted-foreground truncate font-mono">
{item.description}
</span>
)}
</div>
{(blocked || item.hint) && (
<span className="text-[10px] text-muted-foreground/60 shrink-0">
{blocked
? t("commandPalette.noTargetTab")
: item.hint}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</React.Fragment>
);
})}
{paletteEntriesFor(paletteEntries, "global").length > 0 && (
<>
<CommandSeparator className="my-2" />
<CommandGroup
heading={t("commandPalette.pluginActions")}
className="px-2"
>
{paletteEntriesFor(paletteEntries, "global").map((entry) => {
const Icon = entry.icon;
return (
<CommandItem
key={`plugin-${entry.id}`}
value={`plugin-${entry.id} ${t(entry.titleKey)} ${(entry.keywords ?? []).join(" ")}`}
onSelect={() => handleAction(() => entry.run(shell))}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
{Icon && (
<Icon className="size-4 text-accent-brand" />
)}
</div>
<span className="text-sm font-semibold flex-1">
{t(entry.titleKey)}
</span>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{recentActivity.length > 0 && (
<>
<CommandSeparator className="my-2" />
<CommandGroup
heading={t("commandPalette.recentActivity")}
className="px-2"
>
{recentActivity.map((item) => (
{!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}`}
@@ -439,53 +289,36 @@ export function CommandPalette({
if (target) onOpenTab(target.tab, item.hostName);
})
}
className="group flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
className={rowClass}
>
<div className="size-7 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors text-muted-foreground group-hover:text-accent-brand">
{(() => {
const Icon = activityTarget(item.type)?.icon;
return Icon ? <Icon className="size-3.5" /> : null;
})()}
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
{item.hostName}
</span>
<span className="text-xs text-muted-foreground capitalize">
{item.type.replace("_", " ")}
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground/50">
<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" />
<span className="text-[10px]">
{new Date(item.timestamp).toLocaleDateString()}
</span>
</div>
{new Date(item.timestamp).toLocaleDateString()}
</span>
</CommandItem>
))}
</CommandGroup>
</>
);
})}
</CommandGroup>
)}
<div className={cn(showHostResultsFirst && "order-first")}>
{!showHostResultsFirst && <CommandSeparator className="my-2" />}
<CommandGroup
heading={t("commandPalette.serversAndHosts")}
className="px-2"
>
{filteredHosts.length > 0 ? (
groupedHosts.map(({ folder, hosts: groupHosts }) => (
<div key={folder ?? "__root__"}>
{folder && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wide">
<Folder className="size-3" />
{folder}
</div>
)}
{groupHosts.map((host, i) => (
{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={i}
key={host.id}
value={`host-${host.id}`}
onSelect={() =>
handleAction(() => {
@@ -496,38 +329,32 @@ export function CommandPalette({
if (action) runHostAction(action, host, shell);
})
}
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
className={rowClass}
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
<Server
className={cn(
"size-4",
getLiveHostStatus(Number(host.id)) === "online"
? "text-accent-brand"
: "text-muted-foreground",
)}
/>
</div>
<div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate">
{host.name}
</span>
{host.isShared && (
<span className="text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
{t("hosts.sharing.sharedBadge")}
</span>
)}
{getLiveHostStatus(Number(host.id)) ===
"online" && (
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground font-mono">
{host.username}@{host.ip}
<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>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
)}
<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 =
@@ -535,16 +362,18 @@ export function CommandPalette({
return (
<button
key={action.id}
type="button"
title={label}
aria-label={label}
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
runHostAction(action, host, shell),
);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
className={hostButton}
>
<Icon className="size-3.5" />
<Icon className="size-3" />
</button>
);
})}
@@ -557,126 +386,194 @@ export function CommandPalette({
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="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
className={hostButton}
>
{Icon && <Icon className="size-3.5" />}
{Icon && <Icon className="size-3" />}
</button>
);
})}
{canEditHost(host) && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
<button
title={t("hosts.editHost")}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
onOpenTab("host-manager");
setTimeout(() => {
window.dispatchEvent(
new CustomEvent(
"host-manager:edit-host",
{
detail: host.id,
},
),
);
}, 100);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
</>
<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>
)}
</div>
</span>
</CommandItem>
))}
</div>
))
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{t("commandPalette.noHostsFound", { search })}
);
})}
</div>
)}
))}
</CommandGroup>
</div>
)}
<CommandSeparator className="my-2" />
{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>
);
})}
<CommandGroup heading={t("commandPalette.links")} className="px-2">
<div className="grid grid-cols-3 gap-1">
<CommandItem
value="link-github"
onSelect={() =>
window.open(
"https://github.com/Termix-SSH/Termix",
"_blank",
)
}
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<Globe className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">GitHub</span>
</CommandItem>
<CommandItem
value="link-discord"
onSelect={() =>
window.open(
"https://discord.com/invite/jVQGdvHDrf",
"_blank",
)
}
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<MessagesSquare className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Discord</span>
</CommandItem>
<CommandItem
value="link-support"
onSelect={() =>
window.open(
"https://github.com/Termix-SSH/Support/issues/new",
"_blank",
)
}
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<LifeBuoy className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t("dashboard.support")}
</span>
</CommandItem>
</div>
</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="border-t border-border px-4 py-3 bg-muted/30 flex items-center justify-between text-[11px] text-muted-foreground">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Kbd className="h-5 px-1 bg-background rounded-none">↑↓</Kbd>
<span>{t("commandPalette.navigate")}</span>
</div>
<div className="flex items-center gap-1">
<Kbd className="h-5 px-1 bg-background rounded-none">ENTER</Kbd>
<span>{t("commandPalette.select")}</span>
</div>
</div>
<div className="flex items-center gap-1">
<span>{t("commandPalette.toggleWith")}</span>
<Kbd className="h-5 px-1.5 bg-background rounded-none">Ctrl</Kbd>
<span>+</span>
<Kbd className="h-5 px-1.5 bg-background rounded-none">K</Kbd>
<span>{t("commandPalette.orShortcut")}</span>
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
<span>+</span>
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
</div>
<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>
+179
View File
@@ -0,0 +1,179 @@
/* 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 { Separator } from "@/components/separator";
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 (
<>
<Separator orientation="vertical" />
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="flex h-full w-12.5 shrink-0 items-center justify-center 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>
);
}
+298
View File
@@ -0,0 +1,298 @@
/* 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";
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();
}}
/>
<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>
);
}
-189
View File
@@ -1,189 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LogOut, MoreHorizontal, Settings, User } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/dropdown-menu";
import type { RailView } from "@/sidebar/AppRail";
import { useRailItems } from "@/sidebar/rail-items";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { RailBadge } from "@/sidebar/RailBadge";
export function MobileBottomBar({
railView,
sidebarOpen,
onRailClick,
}: {
railView: RailView;
sidebarOpen: boolean;
onRailClick: (view: RailView) => void;
}) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const [hidden, setHidden] = useState<Set<string>>(readHiddenRailTabs);
const railItems = useRailItems();
// The rail's visibility toggles apply on mobile too; this used to ignore
// them, so hiding a tab did nothing on a phone.
useEffect(() => {
const handler = () => setHidden(readHiddenRailTabs());
window.addEventListener("hiddenRailTabsChanged", handler);
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
const { primaryItems, moreItems } = useMemo(() => {
// Tab-opening entries (network graph) have no sidebar panel to show here.
const visible = railItems.filter(
(item) => item.kind !== "tab" && !hidden.has(item.id),
);
const preferred = visible.filter((item) => item.mobilePrimary);
// Keep four primary slots filled even when the user hides the defaults,
// so the bar never collapses to just "More".
const primary = [
...preferred,
...visible.filter((item) => !item.mobilePrimary),
].slice(0, 4);
const primaryIds = new Set(primary.map((item) => item.id));
return {
primaryItems: primary,
moreItems: visible.filter((item) => !primaryIds.has(item.id)),
};
}, [hidden, railItems]);
const moreActive =
sidebarOpen &&
(moreItems.some((item) => item.id === railView) ||
railView === "user-profile" ||
railView === "admin-settings");
return (
<div className="md:hidden flex items-stretch shrink-0 bg-sidebar border-t border-border safe-bottom">
{primaryItems.map((item) => {
const active = sidebarOpen && railView === item.id;
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => onRailClick(item.id as RailView)}
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
${active ? "text-accent-brand" : "text-muted-foreground"}`}
>
<span className="relative">
<Icon className="size-5" />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2.5"
/>
)}
</span>
<span className="max-w-full truncate px-0.5">
{t(item.labelKey)}
</span>
</button>
);
})}
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
<DropdownMenuTrigger asChild>
<button
className={`relative flex flex-col items-center justify-center flex-1 gap-0.5 py-2 min-h-[56px] transition-colors text-[10px] font-medium
${moreActive ? "text-accent-brand" : "text-muted-foreground"}`}
>
<span className="relative">
<MoreHorizontal className="size-5" />
{moreItems.map((item) =>
item.useBadge ? (
<RailBadge
key={item.id}
useBadge={item.useBadge}
variant="dot"
className="-top-0.5 -right-1"
/>
) : null,
)}
</span>
<span>{t("common.more")}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
className="mb-1 min-w-[180px] max-h-[60vh] overflow-y-auto"
>
{moreItems.map((item) => {
const active = sidebarOpen && railView === item.id;
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.id}
onClick={() => {
onRailClick(item.id as RailView);
setMoreOpen(false);
}}
className={active ? "text-accent-brand" : ""}
>
<Icon className="size-4" />
{t(item.labelKey)}
{item.useBadge && (
<span className="relative ml-auto w-5 h-3.5">
<RailBadge useBadge={item.useBadge} className="right-0" />
</span>
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
onRailClick("user-profile");
setMoreOpen(false);
}}
className={
sidebarOpen && railView === "user-profile"
? "text-accent-brand"
: ""
}
>
<User className="size-4" />
{t("nav.userProfile")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onRailClick("admin-settings");
setMoreOpen(false);
}}
className={
sidebarOpen && railView === "admin-settings"
? "text-accent-brand"
: ""
}
>
<Settings className="size-4" />
{t("nav.admin")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() =>
window.dispatchEvent(
new CustomEvent("termix:logout", {
detail: { manual: true },
}),
)
}
>
<LogOut className="size-4" />
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
+1
View File
@@ -22,6 +22,7 @@ import { listPanes, mapTabIds } from "./split/split-tree";
const CORE_UNSAVED = new Set([
"dashboard",
"host-manager",
"settings",
"user-profile",
"admin-settings",
"split-screen",
+20
View File
@@ -0,0 +1,20 @@
type Guard = () => boolean | Promise<boolean>;
const guards = new Map<string, Guard>();
/**
* Lets a tab with no session handle, like Manage, ask before it closes.
* Returns the unregister function.
*/
export function registerTabCloseGuard(tabId: string, guard: Guard): () => void {
guards.set(tabId, guard);
return () => {
if (guards.get(tabId) === guard) guards.delete(tabId);
};
}
/** Whether the tab may close: true when it has no guard or the guard agrees. */
export async function canCloseTab(tabId: string): Promise<boolean> {
const guard = guards.get(tabId);
return guard ? await guard() : true;
}
+3
View File
@@ -1,3 +1,4 @@
import type { ConfirmOptions } from "@/components/surface/surface-scope";
import type { ComponentType, Ref } from "react";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { Host, Tab } from "@/types/ui-types";
@@ -34,6 +35,8 @@ export interface TabShellCallbacks {
openHostEditor?: (draft?: HostDraft) => void;
/** Saves a quick-connect tab's host. Core terminal only. */
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
/** Asks a yes or no question over the main area. */
confirm?: (options: ConfirmOptions) => Promise<boolean>;
}
export interface TabRenderProps {
+29 -4
View File
@@ -2,9 +2,9 @@
import {
LayoutDashboard,
LayoutPanelLeft,
LibraryBig,
Server,
Settings,
User,
} from "lucide-react";
import { lazy, memo, Suspense } from "react";
import { useTranslation } from "react-i18next";
@@ -17,12 +17,19 @@ 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 — keep out of the AppShell critical path.
// 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 DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
@@ -91,9 +98,9 @@ export function tabIcon(type: TabType) {
case "dashboard":
return <LayoutDashboard className="size-3.5" />;
case "host-manager":
return <Server className="size-3.5" />;
return <LibraryBig className="size-3.5" />;
case "settings":
case "user-profile":
return <User className="size-3.5" />;
case "admin-settings":
return <Settings className="size-3.5" />;
case "split-screen":
@@ -202,6 +209,8 @@ function noop() {}
export interface TabRenderContext {
shell: TabShellCallbacks;
/** What the Settings tab needs from the shell. */
settings?: Omit<SettingsTabProps, "section">;
/** The terminal a panel shown as a tab sends commands to. */
panelTargetTab?: Tab;
isVisible?: boolean;
@@ -225,6 +234,22 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
return null;
case "host-manager":
return withTabSuspense(<ManageTab />);
case "settings":
return context.settings
? withTabSuspense(
<SettingsTab
{...context.settings}
section={
typeof tab.data?.section === "string"
? tab.data.section
: undefined
}
/>,
)
: null;
case "user-profile":
case "admin-settings":
return null;
+7
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -52,6 +53,7 @@ export function AdminApiKeysSection({
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
@@ -244,6 +246,11 @@ export function AdminApiKeysSection({
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));
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -112,6 +113,7 @@ export function AdminUsersSection({
onPageChange,
}: UsersSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 0;
const hasNext = page + 1 < pageCount;
@@ -249,6 +251,13 @@ export function AdminUsersSection({
className="size-6 text-muted-foreground hover:text-destructive"
disabled={user.isAdmin}
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: user.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteUser(user.username);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
@@ -321,6 +330,7 @@ export function AdminSessionsSection({
loadSessions,
}: SessionsSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
@@ -375,6 +385,13 @@ export function AdminSessionsSection({
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeAllSessionsConfirm", {
username: session.username ?? "",
}),
confirmLabel: t("admin.revokeAll"),
});
if (!ok) return;
try {
await revokeAllUserSessions(session.userId);
setSessions((prev) =>
@@ -393,6 +410,11 @@ export function AdminSessionsSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeSessionConfirm"),
confirmLabel: t("admin.revokeSession"),
});
if (!ok) return;
try {
await revokeSession(session.id);
setSessions((prev) =>
@@ -447,6 +469,7 @@ export function AdminRolesSection({
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
@@ -652,6 +675,12 @@ export function AdminRolesSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteRoleConfirm", {
name: role.displayName,
}),
});
if (!ok) return;
await deleteRole(role.id);
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
+33 -12
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useBranding } from "@/contexts/BrandingContext";
@@ -102,11 +103,15 @@ const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
section,
}: {
onEditingChange?: (editing: boolean) => void;
onOpenHostTab?: (host: Host) => void;
/** Shows just this section, flat, as a Settings page. */
section?: AdminSection;
} = {}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [openSections, setOpenSections] = useState<
Set<AdminSection | FeatureSectionId>
>(() => new Set(["general"]));
@@ -567,6 +572,13 @@ export function AdminSettingsPanel({
async function handleDeleteEditUser() {
if (!editUserTarget) return;
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: editUserTarget.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
setEditUserLoading(true);
try {
await deleteUser(editUserTarget.username);
@@ -734,6 +746,9 @@ export function AdminSettingsPanel({
}
}
const isOpen = (id: AdminSection | FeatureSectionId) =>
section ? section === id : openSections.has(id);
if (manageUser) {
return (
<AdminUserManagePanel
@@ -758,9 +773,15 @@ export function AdminSettingsPanel({
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"
}
>
<AdminGeneralSettingsSection
open={openSections.has("general")}
open={isOpen("general")}
onToggle={() => toggle("general")}
notificationPrivateEndpoints={notificationPrivateEndpoints}
onSaveNotificationPrivateEndpoints={
@@ -790,7 +811,7 @@ export function AdminSettingsPanel({
/>
<AdminUsersSection
open={openSections.has("users")}
open={isOpen("users")}
onToggle={() => toggle("users")}
users={users}
setUsers={setUsers}
@@ -812,7 +833,7 @@ export function AdminSettingsPanel({
/>
<AdminSessionsSection
open={openSections.has("sessions")}
open={isOpen("sessions")}
onToggle={() => toggle("sessions")}
sessions={sessions}
setSessions={setSessions}
@@ -820,7 +841,7 @@ export function AdminSettingsPanel({
/>
<AdminRolesSection
open={openSections.has("roles")}
open={isOpen("roles")}
onToggle={() => toggle("roles")}
roles={roles}
setRoles={setRoles}
@@ -837,12 +858,12 @@ export function AdminSettingsPanel({
/>
<AdminHostDefaultsSection
open={openSections.has("host-defaults")}
open={isOpen("host-defaults")}
onToggle={() => toggle("host-defaults")}
/>
<AdminBrandingSection
open={openSections.has("branding")}
open={isOpen("branding")}
onToggle={() => toggle("branding")}
settings={brandingSettings}
setSettings={setBrandingSettings}
@@ -852,7 +873,7 @@ export function AdminSettingsPanel({
/>
<AdminDatabaseSection
open={openSections.has("database")}
open={isOpen("database")}
onToggle={() => toggle("database")}
importFile={importFile}
setImportFile={setImportFile}
@@ -863,7 +884,7 @@ export function AdminSettingsPanel({
/>
<AdminSSLSection
open={openSections.has("ssl")}
open={isOpen("ssl")}
onToggle={() => toggle("ssl")}
status={tlsStatus}
manualCertDraft={manualCertDraft}
@@ -875,7 +896,7 @@ export function AdminSettingsPanel({
/>
<AdminApiKeysSection
open={openSections.has("api-keys")}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
@@ -896,7 +917,7 @@ export function AdminSettingsPanel({
/>
<AdminAuditLogSection
open={openSections.has("audit-log")}
open={isOpen("audit-log")}
onToggle={() => toggle("audit-log")}
users={users}
/>
@@ -906,7 +927,7 @@ export function AdminSettingsPanel({
key={plugin.id}
plugin={plugin}
scope="admin"
open={openSections.has(featureSectionId(plugin.id))}
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
+2 -2
View File
@@ -372,7 +372,7 @@ function certState(
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
expiring: "text-warning",
expired: "text-destructive",
};
@@ -470,7 +470,7 @@ export function AdminSSLSection({
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
<div className="p-2 border border-warning/40 bg-warning/10 text-[10px] text-warning">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
+10
View File
@@ -1,5 +1,6 @@
import type React from "react";
import { ChevronDown } from "lucide-react";
import { useSettingsEmbedded } from "@/settings/settings-embed";
export function AdminToggle({
on,
@@ -36,6 +37,15 @@ export function AccordionSection({
onToggle: () => void;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (embedded) {
if (!open) return null;
return (
<div className="flex flex-col border border-border bg-card px-3 pb-3">
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden shrink-0">
<button
+368 -395
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -9,13 +10,6 @@ import {
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
@@ -63,83 +57,80 @@ export function AdminCreateUserDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.createUserTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.createUserTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</InlineView>
);
}
@@ -173,222 +164,210 @@ export function AdminEditUserDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.editUserTitle", { username: editUserTarget?.username })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.editUserDesc")}
</DialogDescription>
</DialogHeader>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">
{t("admin.userRoles")}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
>
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter(
(r) => r.roleId !== ur.roleId,
),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(
editUserTarget.id,
r.id,
);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
)}
</DialogContent>
</Dialog>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">{t("admin.userRoles")}</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter((r) => r.roleId !== ur.roleId),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(editUserTarget.id, r.id);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</InlineView>
);
}
@@ -424,45 +403,42 @@ export function AdminUnlinkAccountDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.unlinkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</DialogContent>
</Dialog>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.unlinkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</p>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</InlineView>
);
}
@@ -529,74 +505,71 @@ export function AdminLinkAccountDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.linkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.linkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
/>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</InlineView>
);
}
+30 -82
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
import { getUserList } from "@/main-axios";
@@ -50,7 +51,8 @@ import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
import { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
type ApiErrorLike = {
@@ -114,14 +116,18 @@ export function AdminUserManagePanel({
.catch(() => {});
}, [user.id]);
const [editor, setEditor] = useState<EditorState>(null);
const [editorTab, setEditorTab] = useState("general");
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
@@ -323,57 +329,34 @@ export function AdminUserManagePanel({
</span>
);
// Full-panel host/credential editor view (mirrors HostManager's editor view)
// Editing one of the user's hosts or credentials takes over the panel.
if (editor) {
const isHost = editor.kind === "host";
const tabs = isHost
? makeHostTabs(
t,
editorProtocols as unknown as Record<string, boolean>,
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
: makeCredentialTabs(t);
const close = () => setEditor(null);
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
setEditor(null);
setEditorTab("general");
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{t("admin.manageEditorBack", { username: user.username })}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={editorTab}
onTabChange={setEditorTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<PanelShell
leading={<BackButton onClick={close} />}
title={t("admin.manageEditorBack", { username: user.username })}
status={
editor.kind === "host"
? (editor.host?.name ?? t("manage.newHost"))
: (editor.credential?.name ?? t("manage.newCredential"))
}
scroll={false}
>
{editor.kind === "host" ? (
<HostEditor
key={editor.host ? editor.host.id : "new-host"}
host={editor.host}
activeTab={editorTab}
onBack={() => {
setEditor(null);
setEditorTab("general");
}}
onBack={close}
onSave={() => {
setEditor(null);
setEditorTab("general");
close();
reloadHosts();
}}
protocols={editorProtocols}
onProtocolChange={(p) =>
setEditorProtocols((prev) => ({ ...prev, ...p }))
}
onTabChange={setEditorTab}
hosts={hosts}
credentials={credentials}
adminTargetUserId={user.id}
@@ -382,7 +365,6 @@ export function AdminUserManagePanel({
<CredentialEditorView
key={editor.credential ? editor.credential.id : "new-cred"}
credential={editor.credential}
activeTab={editorTab}
existingFolders={Array.from(
new Set(
credentials
@@ -390,19 +372,15 @@ export function AdminUserManagePanel({
.filter((f): f is string => !!f),
),
).sort()}
onBack={() => {
setEditor(null);
setEditorTab("general");
}}
onBack={close}
onSave={() => {
setEditor(null);
setEditorTab("general");
close();
reloadCredentials();
}}
adminTargetUserId={user.id}
/>
)}
</div>
</PanelShell>
</div>
);
}
@@ -678,7 +656,6 @@ export function AdminUserManagePanel({
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorProtocols(hostProtocolFlags(null));
setEditorTab("general");
setEditor({ kind: "host", host: null });
}}
>
@@ -724,7 +701,6 @@ export function AdminUserManagePanel({
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorProtocols(hostProtocolFlags(host));
setEditorTab("general");
setEditor({ kind: "host", host });
}}
>
@@ -790,7 +766,6 @@ export function AdminUserManagePanel({
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorTab("general");
setEditor({ kind: "credential", credential: null });
}}
>
@@ -827,7 +802,6 @@ export function AdminUserManagePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorTab("general");
setEditor({ kind: "credential", credential: cred });
}}
>
@@ -1013,32 +987,6 @@ export function AdminUserManagePanel({
</div>
)}
</div>
{/* Confirm dialog overlay */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("common.confirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+321 -443
View File
@@ -1,106 +1,122 @@
import { useState, useEffect, type ReactElement } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Eye,
EyeOff,
LogOut,
PanelRight,
Pin,
Settings,
Search,
SlidersHorizontal,
SquareArrowOutUpRight,
User,
} from "lucide-react";
import type { TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
import {
groupRailItems,
useRailItems,
type RailGroup,
type RailItemDef,
} from "./rail-items";
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
| "hosts"
| "credentials"
| "quick-connect"
| ToolsTab
| "connections"
| "user-profile"
| "admin-settings";
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
export type RailView = CoreRailView | (string & {});
type RailItem =
| {
kind?: undefined;
view: RailView;
icon: React.ReactNode;
title: string;
promotable?: boolean;
rightDockable?: boolean;
useBadge?: () => number | null | undefined;
}
| {
kind: "tab";
tabType: TabType;
icon: React.ReactNode;
title: string;
useBadge?: () => number | null | undefined;
}
| { kind: "separator" };
const btnBase =
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
const activeClass = "text-accent-brand bg-accent-brand/10";
function buildRailButtons(
items: RailItemDef[],
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
const all: RailItem[] = [];
for (const item of items) {
const Icon = item.icon;
if (item.kind === "tab") {
all.push({
kind: "tab",
tabType: item.id as TabType,
icon: <Icon size={16} />,
title: t(item.labelKey),
useBadge: item.useBadge,
});
} else {
all.push({
view: item.id as RailView,
icon: <Icon size={16} />,
title: t(item.labelKey),
promotable: item.promotable,
rightDockable: item.rightDockable,
useBadge: item.useBadge,
});
}
if (item.separatorAfter) all.push({ kind: "separator" });
}
// Filter out hidden items, then collapse consecutive/leading/trailing separators
const filtered = all.filter((item) => {
if (item.kind === "separator") return true;
if ("tabType" in item) return !hidden.has(item.tabType);
return !hidden.has(item.view);
});
const result: RailItem[] = [];
for (const item of filtered) {
if (item.kind === "separator") {
if (result.length === 0 || result[result.length - 1].kind === "separator")
continue;
result.push(item);
} else {
result.push(item);
}
}
if (result[result.length - 1]?.kind === "separator") result.pop();
return result;
function RailLabel({
expanded,
children,
}: {
expanded: boolean;
children: React.ReactNode;
}) {
return (
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{children}
</span>
);
}
const btnBase =
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
function RailIcon({
children,
useBadge,
}: {
children: React.ReactNode;
useBadge?: () => number | null | undefined;
}) {
return (
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{children}
{useBadge && (
<RailBadge useBadge={useBadge} className="-top-1.5 -right-2" />
)}
</span>
);
}
function Rule({ expanded }: { expanded: boolean }) {
return (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: expanded ? `calc(100% - ${rem(16)})` : rem(20) }}
/>
);
}
/**
* The band name rides on the divider between bands, so expanding the rail
* never pushes items down from under the pointer.
*/
function GroupDivider({
group,
expanded,
}: {
group: RailGroup;
expanded: boolean;
}) {
const { t } = useTranslation();
return (
<div className="my-0.5 flex h-3 shrink-0 items-center gap-1.5 px-2.5">
<span
aria-hidden={!expanded}
className={`shrink-0 overflow-hidden text-[10px] font-semibold uppercase leading-none tracking-widest whitespace-nowrap text-muted-foreground/60 transition-[opacity,max-width] duration-200 ${
expanded ? "max-w-32 opacity-100 delay-75" : "max-w-0 opacity-0"
}`}
>
{t(`nav.group.${group}`)}
</span>
<div className="h-px min-w-3 flex-1 bg-border" />
</div>
);
}
interface MenuTarget {
id: string;
title: string;
promotable?: boolean;
rightDockable?: boolean;
hideable: boolean;
}
export function AppRail({
railView,
@@ -111,6 +127,8 @@ export function AppRail({
onRailClick,
onOpenTab,
onOpenInRightDock,
onOpenSettings,
onOpenPalette,
onLogout,
}: {
railView: RailView;
@@ -122,6 +140,8 @@ export function AppRail({
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
onOpenSettings: () => void;
onOpenPalette?: () => void;
onLogout: (options?: { manual?: boolean }) => void;
}) {
const { t } = useTranslation();
@@ -134,22 +154,9 @@ export function AppRail({
readRailPreference("showPinAppRailButton"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
// Which promotable item was right-clicked, so the menu can offer to open it
// as a tab. Null when the right-click landed on empty rail space.
const [menuTarget, setMenuTarget] = useState<{
view: RailView;
title: string;
promotable?: boolean;
rightDockable?: boolean;
} | null>(null);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
const [managing, setManaging] = useState(false);
const hiddenTabs = useHiddenRailTabs();
useEffect(() => {
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
@@ -194,33 +201,20 @@ export function AppRail({
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
useEffect(() => {
const handler = () => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
} catch {
setHiddenTabs(new Set());
}
};
window.addEventListener("hiddenRailTabsChanged", handler);
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(
railItems.filter((item) => item.placement !== "footer"),
t,
hiddenTabs,
const railExpanded = pinned || (expandOnHover && hovered) || managing;
const mainItems = railItems.filter((item) => item.placement !== "footer");
const bands = useMemo(
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
[mainItems, hiddenTabs],
);
const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
const footerItems = railItems.filter(
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
);
const setRailPinned = (nextPinned: boolean) => {
setPinned(nextPinned);
localStorage.setItem("pinAppRail", String(nextPinned));
window.dispatchEvent(new Event("pinAppRailChanged"));
};
useEffect(() => {
if (hiddenItems.length === 0) setManaging(false);
}, [hiddenItems.length]);
const togglePinned = () => {
setRailPreference("pinAppRail", !pinned);
@@ -232,29 +226,92 @@ export function AppRail({
setMenuPos(null);
};
const renderItem = (item: RailItemDef) => {
const Icon = item.icon;
const title = t(item.labelKey);
const isTab = item.kind === "tab";
const active = !isTab && sidebarOpen && railView === item.id;
return (
<button
key={item.id}
type="button"
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() =>
setMenuTarget({
id: item.id,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
hideable: !item.alwaysVisible && item.hideable !== false,
})
}
data-rail-item=""
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle}`}
>
<RailIcon useBadge={item.useBadge}>
<Icon size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{title}</RailLabel>
</button>
);
};
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: rem(railExpanded ? 160 : 40) }}
style={{ width: rem(railExpanded ? 172 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
e.preventDefault();
// Keep the menu on screen when right-clicking near the viewport edges
const MENU_W = 190;
const MENU_H = 96;
const MENU_W = 200;
const MENU_H = 180;
setMenuPos({
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
});
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
setMenuTarget(null);
}
}}
>
{onOpenPalette && (
<>
<button
type="button"
onClick={onOpenPalette}
title={t("nav.searchEverything")}
aria-label={t("nav.searchEverything")}
style={btnStyle}
className={`${btnBase} ${idle}`}
>
<RailIcon>
<Search size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{!pluginsSettled
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
<div
key={`rail-skeleton-${i}`}
style={btnStyle}
@@ -263,307 +320,106 @@ export function AppRail({
<Skeleton className="size-4 shrink-0" />
</div>
))
: railButtons.map((item, i) =>
item.kind === "separator" ? (
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
) : "tabType" in item ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
: bands.map((band, i) => (
<div key={band.group} className="flex flex-col gap-1">
{i > 0 && (
<GroupDivider group={band.group} expanded={railExpanded} />
)}
{band.items.map(renderItem)}
</div>
))}
{pluginsSettled && hiddenItems.length > 0 && (
<>
<Rule expanded={railExpanded} />
<button
type="button"
onClick={() => setManaging((v) => !v)}
aria-expanded={managing}
title={t("nav.hiddenCount", { count: hiddenItems.length })}
style={btnStyle}
className={`${btnBase} ${managing ? activeClass : idle}`}
>
<RailIcon>
<EyeOff size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{t("nav.hiddenCount", { count: hiddenItems.length })}
</RailLabel>
</button>
{managing &&
hiddenItems.map((item) => {
const Icon = item.icon;
return (
<button
key={`hidden-${item.id}`}
type="button"
onClick={() => void toggleHiddenRailTab(item.id)}
title={t("nav.showInRail")}
style={btnStyle}
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
),
)}
<RailIcon>
<Icon size={16} />
</RailIcon>
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
{t(item.labelKey)}
</span>
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
</button>
);
})}
</>
)}
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
{showPinButton && (
<button
onClick={() => setRailPinned(!pinned)}
style={btnStyle}
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
className={`${btnBase} ${
pinned
? "text-accent-brand bg-accent-brand/10 hover:text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Pin size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
</span>
</button>
)}
{showPinButton && (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
)}
{footerItems.map((item) => {
const Icon = item.icon;
const title = t(item.labelKey);
return (
<>
<button
key={item.id}
onClick={(e) => {
if (item.kind === "tab") {
onOpenTab?.(item.id as TabType);
return;
}
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.id as RailView,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
}
type="button"
onClick={() => setRailPreference("pinAppRail", !pinned)}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.id
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Icon size={16} />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{title}
</span>
</button>
);
})}
{(
[
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
] as {
view: RailView;
icon: ReactElement;
title: string;
promotable?: boolean;
}[]
).map((item) => (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: true,
});
}}
data-rail-promotable={item.promotable ? "" : undefined}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
className={`${btnBase} ${pinned ? activeClass : idle}`}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{item.title}
</span>
</button>
))}
<div className="mx-2 my-1 border-t border-border" />
<RailIcon>
<Pin size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
{footerItems.map(renderItem)}
{footerItems.length > 0 && <Rule expanded={railExpanded} />}
<button
type="button"
onClick={() => onLogout({ manual: true })}
style={btnStyle}
title={t("common.logout")}
aria-label={t("common.logout")}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<RailIcon>
<LogOut size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{t("common.logout")}
</span>
</RailIcon>
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
</button>
</div>
<div className="shrink-0 border-t border-border">
<button
onClick={() => onRailClick("user-profile")}
title={t("nav.userProfile")}
aria-label={t("nav.userProfile")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
style={{ padding: "0 8px" }}
type="button"
onClick={onOpenSettings}
title={t("nav.openSettings")}
aria-label={t("nav.openSettings")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
style={{ padding: `0 ${rem(8)}` }}
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
@@ -577,7 +433,7 @@ export function AppRail({
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || "User"}
{username || t("nav.roleUser")}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
@@ -589,79 +445,101 @@ export function AppRail({
{menuPos && (
<div
data-rail-context-menu
role="menu"
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
>
{menuTarget && (
<>
{menuTarget.promotable && (
<button
<MenuItem
icon={<SquareArrowOutUpRight className="size-3" />}
onClick={() => {
onOpenTab?.(menuTarget.view as TabType);
onOpenTab?.(menuTarget.id as TabType);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SquareArrowOutUpRight className="size-3" />
</span>
{t("nav.openAsTab")}
</button>
</MenuItem>
)}
{menuTarget.rightDockable && (
<button
<MenuItem
icon={<PanelRight className="size-3" />}
onClick={() => {
onOpenInRightDock?.(menuTarget.view);
onOpenInRightDock?.(menuTarget.id);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<PanelRight className="size-3" />
</span>
{t("nav.openInRightDock")}
</button>
</MenuItem>
)}
{menuTarget.hideable && (
<MenuItem
icon={<EyeOff className="size-3" />}
onClick={() => {
void toggleHiddenRailTab(menuTarget.id);
setMenuPos(null);
}}
>
{t("nav.hideFromRail")}
</MenuItem>
)}
<div className="h-px bg-border my-1" />
</>
)}
<button
<MenuItem
icon={pinned ? <Check className="size-3" /> : null}
onClick={togglePinned}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
role="menuitemcheckbox"
aria-checked={pinned}
checked={pinned}
>
<span className="shrink-0 w-3 flex items-center justify-center">
{pinned && <Check className="size-3" />}
</span>
{t("newUi.sidebar.userProfile.pinAppRail")}
</button>
<button
</MenuItem>
<MenuItem
icon={expandOnHover ? <Check className="size-3" /> : null}
onClick={toggleExpandOnHover}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
role="menuitemcheckbox"
aria-checked={expandOnHover}
checked={expandOnHover}
>
<span className="shrink-0 w-3 flex items-center justify-center">
{expandOnHover && <Check className="size-3" />}
</span>
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
</button>
</MenuItem>
<div className="h-px bg-border my-1" />
<button
<MenuItem
icon={<SlidersHorizontal className="size-3" />}
onClick={() => {
onRailClick("user-profile");
onOpenSettings();
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SlidersHorizontal className="size-3" />
</span>
{t("nav.sidebarSettings")}
</button>
{t("nav.openSettings")}
</MenuItem>
</div>
)}
</div>
);
}
function MenuItem({
icon,
onClick,
checked,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
checked?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role={checked === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={checked}
onClick={onClick}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
{icon}
</span>
{children}
</button>
);
}
+9
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
@@ -298,6 +299,7 @@ export function ConnectionsPanel({
onReorderTabs?: (tabs: Tab[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [now, setNow] = useState(Date.now());
const [persistMinutes, setPersistMinutes] = useState(30);
@@ -597,6 +599,13 @@ export function ConnectionsPanel({
onReopenTab(record, liveSession?.sessionId ?? null);
}}
onClose={async () => {
const ok = await confirm({
title: t("connections.forgetConfirm", {
name: host?.name ?? record.label,
}),
confirmLabel: t("connections.forget"),
});
if (!ok) return;
await deleteOpenTab(record.id).catch(() => {});
onForgetBackground(record.id);
}}
+75 -43
View File
@@ -1,4 +1,6 @@
import type React from "react";
import { useRef, useState } from "react";
import { EditorPane, EditorSection } from "@/manage/EditorPane";
import { PluginComponent } from "@/plugin-host/component-registry";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -23,7 +25,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
export function CredentialEditorView({
credential,
activeTab,
banner,
onBack,
onSave,
adminTargetUserId,
@@ -31,7 +33,8 @@ export function CredentialEditorView({
saveAsNewHost,
}: {
credential: Credential | null;
activeTab: string;
/** Shown above the form. */
banner?: React.ReactNode;
onBack: () => void;
onSave: (
saved: Record<string, unknown>,
@@ -170,9 +173,73 @@ export function CredentialEditorView({
}
};
const nameMissing = !credForm.name.trim();
const bands = [
{
id: "credential",
label: t("manage.bandCredential"),
items: [
{
id: "general",
label: t("hosts.basicInformation"),
icon: <Info className="size-3.5" />,
error: nameMissing,
},
{
id: "auth",
label: t("hosts.authDetailsSection"),
icon: <Lock className="size-3.5" />,
},
],
},
];
return (
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<EditorPane
bands={bands}
banner={banner}
errorCount={nameMissing ? 1 : 0}
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
size="sm"
className="px-4"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew || nameMissing}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
onClick={handleSave}
disabled={saving || savingAsNew || nameMissing}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</>
}
>
<EditorSection id="general" showHeading={false}>
<SectionCard
title={t("hosts.basicInformation")}
icon={<Info className="size-3.5" />}
@@ -265,9 +332,9 @@ export function CredentialEditorView({
</div>
</div>
</SectionCard>
)}
</EditorSection>
{activeTab === "auth" && (
<EditorSection id="auth" showHeading={false}>
<SectionCard
title={t("hosts.authDetailsSection")}
icon={<Lock className="size-3.5" />}
@@ -544,42 +611,7 @@ export function CredentialEditorView({
</div>
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 mt-3">
<Button
variant="ghost"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
className="px-6"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
onClick={handleSave}
disabled={saving || savingAsNew}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</div>
</div>
</EditorSection>
</EditorPane>
);
}
+317
View File
@@ -0,0 +1,317 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Upload } from "lucide-react";
import { Button } from "@/components/button";
import { Select2 } from "@/components/select2";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
deleteCredential,
deployCredentialToHost,
duplicateCredential,
getCredentials,
getSSHHosts,
renameCredentialFolder,
updateCredential,
} from "@/main-axios";
import { requestManage } from "@/manage/manage-requests";
import type { Credential, Host } from "@/types/ui-types";
import type {
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialShareModal } from "./CredentialShareModal";
import { CredentialSidebarTree } from "./credential-tree";
import { credentialPassesFilters, sortCredentials } from "./credential-sort";
import { enabledHostProtocols } from "./host-protocols";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
const UNCATEGORIZED = "Uncategorized";
/**
* The credentials sidebar list. Editing happens in the Manage tab; this keeps
* the tree, its folder moves and the quick actions on each row.
*/
export function CredentialList({
search = "",
sort = "default",
arrangeLocked = true,
filter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
onTagsChange,
}: {
search?: string;
sort?: CredentialSortKey;
arrangeLocked?: boolean;
filter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
onTagsChange?: (tags: string[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
const [deployTarget, setDeployTarget] = useState<Credential | null>(null);
const [deployHostId, setDeployHostId] = useState("");
const [deploying, setDeploying] = useState(false);
const [editingFolderName, setEditingFolderName] = useState<string | null>(
null,
);
const [editingFolderValue, setEditingFolderValue] = useState("");
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentials]);
useEffect(() => {
const reloadHosts = () => {
getSSHHosts()
.then((raw) => setHosts(raw.map(sshHostToHost)))
.catch(() => {});
};
const reloadCredentials = () => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {})
.finally(() => setLoading(false));
};
reloadHosts();
reloadCredentials();
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, []);
const needle = search.toLowerCase();
const searched = credentials.filter(
(c) =>
c.name.toLowerCase().includes(needle) ||
c.username.toLowerCase().includes(needle),
);
const filtered = sortCredentials(
filter
? searched.filter((c) => credentialPassesFilters(c, filter))
: searched,
sort,
);
const folderNames = Array.from(
new Set(filtered.map((c) => c.folder || UNCATEGORIZED)),
).sort();
const folderTree = folderNames.map((name) => ({
name,
children: filtered.filter((c) => (c.folder || UNCATEGORIZED) === name),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filtered) {
usedByCounts.set(
cred.id,
hosts.filter((h) => h.credentialId === cred.id).length,
);
}
const reload = async () => {
const res = await getCredentials();
setCredentials(mapCredentials(res));
};
const renameFolder = async (folder: string, newName: string) => {
try {
await renameCredentialFolder(folder, newName);
await reload();
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("credentials.failedToRenameFolder"));
}
};
const moveToFolder = async (credentialId: string, targetFolder: string) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || UNCATEGORIZED) === targetFolder) return;
const folderValue = targetFolder === UNCATEGORIZED ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
const clone = async (cred: Credential) => {
try {
await duplicateCredential(Number(cred.id), {
name: t("credentials.clonedCredentialName", { name: cred.name }),
});
await reload();
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.clonedCredential", { name: cred.name }));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("credentials.failedToCloneCredential"));
}
};
const remove = async (cred: 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 }));
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
};
const deploy = async () => {
if (!deployTarget || !deployHostId) return;
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployTarget.id),
Number(deployHostId),
);
toast.success(t("credentials.keyDeployedSuccess"));
setDeployTarget(null);
} catch {
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
};
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!loading && filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10"
onClick={() =>
requestManage({ kind: "credential", credentialId: null })
}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={folderTree}
usedByCounts={usedByCounts}
query=""
loading={loading}
arrangeLocked={arrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={setEditingFolderName}
onEditingFolderValueChange={setEditingFolderValue}
onRenameFolder={renameFolder}
onMoveCredentialToFolder={moveToFolder}
onDeployCredential={(cred) => {
setDeployHostId("");
setDeployTarget(cred);
}}
onEditCredential={(cred) =>
requestManage({ kind: "credential", credentialId: cred.id })
}
onCloneCredential={clone}
onDeleteCredential={remove}
onShareCredential={setShareCredential}
/>
)}
<CredentialShareModal
credential={shareCredential}
onClose={() => setShareCredential(null)}
/>
<InlineView
open={!!deployTarget}
onOpenChange={(open) => {
if (!open) setDeployTarget(null);
}}
icon={<Upload className="size-4" />}
title={t("credentials.deployDialogTitle")}
footer={
<div className="ml-auto flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployTarget(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployHostId || deploying}
onClick={() => void deploy()}
>
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("credentials.deployDialogDesc", { name: deployTarget?.name })}
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.targetHostLabel")}
</label>
<Select2
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={deployHostId}
onChange={(e) => setDeployHostId(e.target.value)}
>
<option value="">{t("credentials.selectHostOption")}</option>
{hosts
.filter(
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
</div>
</InlineView>
</div>
);
}
+19 -15
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Check,
ListChecks,
Search,
@@ -13,6 +12,7 @@ import {
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { Input } from "@/components/input";
import {
DropdownMenu,
@@ -72,6 +72,7 @@ export function CredentialShareModal({
onClose: () => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [shareUsers, setShareUsers] = useState<
@@ -228,6 +229,13 @@ export function CredentialShareModal({
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
const who =
record.username ?? record.roleDisplayName ?? record.roleName ?? "";
const ok = await confirm({
title: t("sharing.revokeConfirm", { name: who }),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeCredentialAccess(credentialId, record.id);
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
@@ -237,20 +245,16 @@ export function CredentialShareModal({
}
}
if (!credential) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{t("credentials.share.title", { name: credential.name ?? "" })}
</span>
</button>
<InlineView
open={!!credential}
onOpenChange={(open) => {
if (!open) onClose();
}}
icon={<Share2 className="size-4" />}
title={t("credentials.share.title", { name: credential?.name ?? "" })}
bare
>
{loadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
@@ -570,6 +574,6 @@ export function CredentialShareModal({
})}
</div>
</div>
</div>
</InlineView>
);
}
+11 -22
View File
@@ -13,7 +13,8 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { HostManager } from "@/sidebar/HostManager";
import { CredentialList } from "@/sidebar/CredentialList";
import { requestManage } from "@/manage/manage-requests";
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
@@ -30,8 +31,7 @@ import {
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
export function CredentialsPanel({
onEditingChange,
active = true,
active: _active = true,
}: {
onEditingChange?: (editing: boolean) => void;
active?: boolean;
@@ -41,7 +41,6 @@ export function CredentialsPanel({
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
@@ -94,14 +93,9 @@ export function CredentialsPanel({
}));
}
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
@@ -297,9 +291,7 @@ export function CredentialsPanel({
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
)
requestManage({ kind: "credential", credentialId: null })
}
title={t("credentials.addCredential")}
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
@@ -312,21 +304,18 @@ export function CredentialsPanel({
</div>
</div>
</div>
)}
}
<div className="flex flex-col flex-1 min-h-0">
<HostManager
hideListHeader
externalSearch={managerEditing ? undefined : search}
externalSort={sortKey}
externalArrangeLocked={arrangeLocked}
externalFilter={filterState}
<CredentialList
search={search}
sort={sortKey}
arrangeLocked={arrangeLocked}
filter={filterState}
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
onTagsChange={setAllTags}
onEditingChange={handleEditingChange}
active={active}
/>
</div>
@@ -1,12 +1,6 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
CredentialDensity,
@@ -32,108 +26,104 @@ export function CustomizeCredentialsSidebarPanel({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("credentials.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("credentials.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
</SectionCard>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
</div>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
+169 -179
View File
@@ -1,12 +1,6 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
@@ -33,186 +27,182 @@ export function CustomizeSidebarPanel({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("hosts.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("hosts.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
+117 -122
View File
@@ -1,13 +1,7 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
@@ -124,125 +118,126 @@ export function FolderMetadataDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderNestingHint")}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</>
}
>
<p className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
{t("hosts.folderNestingHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</InlineView>
);
}
+77 -83
View File
@@ -1,15 +1,8 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
getCredentials,
getHostAuthOverride,
@@ -123,78 +116,18 @@ export function HostAuthOverrideModal({
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</DialogTitle>
<DialogDescription>
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</select>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-amber-500/30 bg-amber-500/5 p-2 text-[10px] text-amber-600 dark:text-amber-400">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
)}
<DialogFooter>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</>
}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
@@ -212,9 +145,70 @@ export function HostAuthOverrideModal({
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
</p>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</select>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-warning/30 bg-warning/5 p-2 text-[10px] text-warning">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
)}
</InlineView>
</span>
);
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -14,6 +14,7 @@ import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import type { HostFormErrors } from "@/manage/host-validation";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
import {
@@ -35,6 +36,7 @@ export function HostEditorGeneralTab({
hosts,
host,
simpleMode = false,
errors = {},
}: {
form: HostEditorForm;
setField: HostEditorSetField;
@@ -44,6 +46,7 @@ export function HostEditorGeneralTab({
host: Host | null;
/** Hides organizational/advanced fields; their values still save unchanged. */
simpleMode?: boolean;
errors?: HostFormErrors;
}) {
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
@@ -210,8 +213,14 @@ export function HostEditorGeneralTab({
<Input
placeholder={t("placeholders.hostAddress")}
value={form.ip}
aria-invalid={!!errors.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
{errors.ip && (
<span className="text-[10px] text-destructive">
{t(errors.ip)}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+124 -130
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -7,13 +8,6 @@ import { toast } from "sonner";
import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
@@ -220,126 +214,13 @@ export function HostExportDialog({
const canExport = !loading && count > 0;
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex flex-col w-[95vw] sm:max-w-6xl max-h-[85dvh] overflow-hidden">
<DialogHeader>
<DialogTitle>{t("hosts.export.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
<DialogFooter className="shrink-0">
<InlineView
open={open}
onOpenChange={(next) => !next && onClose()}
title={t("hosts.export.title")}
width="wide"
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("hosts.export.cancel")}
</Button>
@@ -352,8 +233,121 @@ export function HostExportDialog({
<Download className="size-3.5 mr-2" />
{t("hosts.export.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
</InlineView>
);
}
-928
View File
@@ -1,928 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
enabledHostProtocols,
hostProtocolFlags,
type HostProtocols,
} from "./host-protocols";
import React, {
useState,
useEffect,
useRef,
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import type { HostDefaultsLevel } from "@/types/host-defaults";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import {
HostDefaultsEditorView,
type HostDefaultsEditorTarget,
} from "./host-defaults/HostDefaultsEditorView";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal";
import { Button } from "@/components/button";
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { toast } from "sonner";
import {
getSSHHosts,
getCredentials,
getCredentialDetails,
deleteCredential,
duplicateCredential,
updateCredential,
deployCredentialToHost,
renameCredentialFolder,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import type { SSHHostWithStatus } from "@/main-axios";
import type {
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialEditorView } from "./CredentialEditorView";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import { CredentialSidebarTree } from "./credential-tree";
import { sortCredentials, credentialPassesFilters } from "./credential-sort";
import {
makeCredentialTabs,
makeHostTabs,
makeHostSshSubTabs,
isSshGroupTab,
useHostEditorSections,
TabStrip,
} from "./HostManagerTabs";
import { Select2 } from "@/components/select2";
export function HostManager({
pendingEditId,
pendingAction,
onEditingChange,
hideListHeader,
externalSearch,
externalSort,
externalArrangeLocked = true,
externalFilter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
onTagsChange,
active = true,
}: {
pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
onEditingChange?: (editing: boolean) => void;
hideListHeader?: boolean;
externalSearch?: string;
externalSort?: CredentialSortKey;
externalArrangeLocked?: boolean;
externalFilter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
onTagsChange?: (tags: string[]) => void;
active?: boolean;
} = {}) {
// Re-render when a plugin adds or removes a host editor tab.
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
// A level of host defaults open in the editor instead of a host.
const [editingDefaults, setEditingDefaults] =
useState<HostDefaultsEditorTarget | null>(null);
// Fields a plugin filled in for a new host, keyed so a second draft remounts
// the editor.
const [hostDraft, setHostDraft] = useState<{
key: number;
draft: HostDraft;
} | null>(null);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
const [editingCredential, setEditingCredential] = useState<
Credential | "new" | null
>(null);
const [activeHostTab, setActiveHostTab] = useState("general");
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
const [searchQuery, setSearchQuery] = useState("");
const effectiveSearch = externalSearch ?? searchQuery;
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [credentialsLoading, setCredentialsLoading] = useState(true);
const [deployDialog, setDeployDialog] = useState<{
cred: Credential;
hostId: string;
} | null>(null);
const [deploying, setDeploying] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const [hostEditorDirty, setHostEditorDirty] = useState(false);
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
// Expanding advanced is a per-session choice; fixing one host's SSH options
// shouldn't quietly move the whole app off the Simple preset.
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
const hostsRef = useRef<Host[]>([]);
useEffect(() => {
hostsRef.current = hosts;
}, [hosts]);
const [editingCredFolderName, setEditingCredFolderName] = useState<
string | null
>(null);
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
// Remembers the host being edited when its credential is opened for
// editing, so "back" can return to that host instead of the list.
const [credentialReturnHost, setCredentialReturnHost] = useState<
Host | "new" | null
>(null);
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
}, [credentials]);
const applyPendingEdit = (hostList: Host[]) => {
if (pendingEditId?.current) {
const id = pendingEditId.current;
pendingEditId.current = null;
const host = hostList.find((h) => h.id === id);
if (host) {
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols(hostProtocolFlags(host));
return true;
}
}
return false;
};
const reloadHosts = () => {
getSSHHosts()
.then((raw) => {
const converted = raw.map(sshHostToHost);
setHosts(converted);
applyPendingEdit(converted);
})
.catch(() => {});
};
const reloadCredentials = () => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {})
.finally(() => setCredentialsLoading(false));
};
useEffect(() => {
reloadHosts();
reloadCredentials();
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,
);
};
}, []);
useEffect(() => {
if (pendingAction?.current) {
const action = pendingAction.current;
pendingAction.current = null;
if (action === "add-host") {
setEditingHost("new");
setHostDraft(null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
} else if (action === "add-credential") {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
}
}
}, [pendingEditId, pendingAction]);
useEffect(() => {
if (!active) return;
const handleAddHost = (e: Event) => {
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
setEditingHost("new");
setHostDraft(draft ? { key: Date.now(), draft } : null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
};
const handleAddCredential = () => {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
};
const handleEditDefaults = (e: Event) => {
const detail = (
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
).detail;
if (!detail) return;
const open = (target: HostDefaultsEditorTarget) => {
setEditingHost(null);
setEditingCredential(null);
setEditingDefaults(target);
};
if (detail.level === "folder" && detail.folderName) {
const folderName = detail.folderName;
getFolderDefaultsId(folderName)
.then((folderId) => open({ level: "folder", folderId, folderName }))
.catch(() => toast.error(t("hostDefaults.loadFailed")));
} else if (detail.level !== "folder") {
open({ level: detail.level });
}
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
const host = hostsRef.current.find((h) => h.id === id);
if (host) {
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols(hostProtocolFlags(host));
}
};
window.addEventListener("host-manager:add-host", handleAddHost);
window.addEventListener("host-manager:add-credential", handleAddCredential);
window.addEventListener("host-manager:edit-host", handleEditHost);
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
return () => {
window.removeEventListener(
"host-manager:edit-defaults",
handleEditDefaults,
);
window.removeEventListener("host-manager:add-host", handleAddHost);
window.removeEventListener(
"host-manager:add-credential",
handleAddCredential,
);
window.removeEventListener("host-manager:edit-host", handleEditHost);
};
}, [active]);
const allHosts = hosts;
const searchedCredentials = credentials.filter(
(c) =>
c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
c.username.toLowerCase().includes(effectiveSearch.toLowerCase()),
);
const filteredCredentials = sortCredentials(
externalFilter
? searchedCredentials.filter((c) =>
credentialPassesFilters(c, externalFilter),
)
: searchedCredentials,
externalSort ?? "default",
);
const credentialFolderNames = Array.from(
new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")),
).sort();
const credentialFolderTree = credentialFolderNames.map((name) => ({
name,
children: filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === name,
),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filteredCredentials) {
usedByCounts.set(
cred.id,
allHosts.filter((h) => h.credentialId === cred.id).length,
);
}
const handleRenameCredentialFolder = async (
folder: string,
newName: string,
) => {
try {
await renameCredentialFolder(folder, newName);
const res = await getCredentials();
setCredentials(mapCredentials(res));
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("credentials.failedToRenameFolder"));
}
};
const handleMoveCredentialToFolder = async (
credentialId: string,
targetFolder: string,
) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || "Uncategorized") === targetFolder) return;
const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
const handleCloneCredential = async (cred: Credential) => {
try {
await duplicateCredential(Number(cred.id), {
name: t("credentials.clonedCredentialName", { name: cred.name }),
});
const res = await getCredentials();
setCredentials(mapCredentials(res));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.clonedCredential", { name: cred.name }));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("credentials.failedToCloneCredential"));
}
};
const handleDeleteCredential = async (cred: Credential) => {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
};
function handleConfirmDeleteCredential(cred: Credential) {
setConfirmDialog({
message: t("credentials.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await handleDeleteCredential(cred);
toast.success(
t("credentials.deletedCredential", { name: cred.name }),
);
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
},
});
}
async function handleEditCredential(cred: Credential) {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
setEditingCredential({
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
});
setActiveCredentialTab("general");
} catch {
setEditingCredential(cred);
setActiveCredentialTab("general");
}
}
// Opens a host's credential for editing from within the host editor,
// remembering the host so "back" returns to it instead of the list.
async function handleEditCredentialFromHost(credentialId: string) {
const cred = credentials.find((c) => String(c.id) === String(credentialId));
if (!cred) return;
setCredentialReturnHost(editingHost);
await handleEditCredential(cred);
// The editor view keys off editingHost, so it has to be cleared or the
// host editor keeps rendering over the credential we just opened.
setEditingHost(null);
}
// Editor view: full-width with top tab bar instead of side nav
const closeHostEditor = () => {
setHostEditorDirty(false);
setShowUnsavedHostDialog(false);
setEditingHost(null);
setEditingDefaults(null);
setActiveHostTab("general");
};
const requestCloseHostEditor = () => {
if (hostEditorDirty) {
setShowUnsavedHostDialog(true);
return;
}
closeHostEditor();
};
const renderEditorView = () => {
if (editingDefaults) {
return (
<HostDefaultsEditorView
target={editingDefaults}
onClose={requestCloseHostEditor}
onDirtyChange={setHostEditorDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
const isHost = !!editingHost;
// Simple mode keeps General and SSH -- between them they hold everything
// needed to reach a host (name, address, username, auth) -- and hides the
// other protocol tabs plus the seven SSH sub-tabs. Nothing is lost:
// createHostEditorForm still materializes every field with its default and
// buildHostEditorPayload still serializes all of them, so hiding a tab
// hides its inputs, not its values.
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
const protocols = editingProtocols as unknown as Record<string, boolean>;
// Plugin tabs decide their own visibility from the host's protocols.
const tabs = isHost
? makeHostTabs(t, protocols).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editingProtocols.enableSsh;
return !collapseAdvanced;
})
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
// Registered SSH-group tabs count too: checking a fixed list here is what
// left plugin tabs unreachable.
const hostTabVisible =
tabs.some((tab) => tab.id === activeHostTab) ||
(!collapseAdvanced &&
editingProtocols.enableSsh &&
sshSubTabs.some((tab) => tab.id === activeHostTab));
const effectiveHostTab = hostTabVisible
? activeHostTab
: collapseAdvanced && isSshGroupTab(activeHostTab)
? "ssh"
: "general";
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
const showSshSubTabs =
isHost &&
!collapseAdvanced &&
editingProtocols.enableSsh &&
isSshGroupTab(activeHostTab);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Back bar + tab strip */}
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
if (isHost) {
requestCloseHostEditor();
} else if (credentialReturnHost) {
setEditingHost(credentialReturnHost);
setCredentialReturnHost(null);
setEditingCredential(null);
setActiveCredentialTab("general");
} else {
setEditingCredential(null);
setActiveCredentialTab("general");
}
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{isHost
? t("hosts.backToHosts")
: credentialReturnHost
? t("hosts.backToHost")
: t("credentials.backToCredentials")}
</span>
{isHost && editingHost !== "new" && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={(editingHost as Host).name}
>
{(editingHost as Host).name}
</span>
)}
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (isHost && id === "ssh") {
if (!isSshGroupTab(activeHostTab)) {
setActiveHostTab("ssh");
}
} else {
setActiveTab(id);
}
}}
isActive={
isHost
? (id) =>
id === "ssh"
? isSshGroupTab(activeHostTab)
: activeHostTab === id
: undefined
}
/>
{showSshSubTabs && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeHostTab}
onTabChange={setActiveHostTab}
variant="secondary"
/>
)}
{isHost && simpleEditor && (
<button
onClick={() => {
if (showAdvancedEditor) setActiveHostTab("general");
setShowAdvancedEditor(!showAdvancedEditor);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
>
<ChevronDown
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
/>
{showAdvancedEditor
? t("hosts.hideAdvancedSettings")
: t("hosts.showAdvancedSettings")}
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<HostEditor
key={
editingHost === "new"
? `new-host-${hostDraft?.key ?? ""}`
: (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
draft={editingHost === "new" ? hostDraft?.draft : undefined}
activeTab={effectiveHostTab}
simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor}
onSave={(saved) => {
const updated = sshHostToHost(
saved as unknown as SSHHostWithStatus,
);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
closeHostEditor();
}}
protocols={editingProtocols}
onProtocolChange={(p) =>
setEditingProtocols((prev) => ({ ...prev, ...p }))
}
onDirtyChange={setHostEditorDirty}
onTabChange={setActiveHostTab}
hosts={hosts}
credentials={credentials}
onEditCredential={handleEditCredentialFromHost}
/>
) : (
<CredentialEditorView
key={
editingCredential === "new"
? "new-cred"
: (editingCredential as Credential).id
}
credential={
editingCredential === "new"
? null
: (editingCredential as Credential)
}
activeTab={activeCredentialTab}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
saveAsNewHost={
credentialReturnHost ? credentialReturnHost : undefined
}
onBack={() => {
if (credentialReturnHost) {
setEditingHost(credentialReturnHost);
setCredentialReturnHost(null);
setEditingCredential(null);
setActiveCredentialTab("general");
return;
}
setEditingCredential(null);
setActiveCredentialTab("general");
}}
onSave={(saved, options) => {
// The save endpoints are typed as an untyped record; these are
// the fields this view reads back off the response.
const result = saved as Partial<Credential> & {
id: number | string;
authType?: string;
};
setCredentials((prev) => {
const idx = prev.findIndex((c) => c.id === String(result.id));
const updated: Credential = {
id: String(result.id),
name: result.name ?? "",
username: result.username ?? "",
type: result.authType === "key" ? "key" : "password",
value: result.value,
password: result.password,
publicKey: result.publicKey,
passphrase: result.passphrase,
description: result.description,
folder: result.folder ?? "",
tags: result.tags ?? [],
};
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
if (options?.assignToHost && credentialReturnHost) {
const returnHost = credentialReturnHost;
setCredentialReturnHost(null);
if (returnHost !== "new") {
setHosts((prev) =>
prev.map((h) =>
h.id === returnHost.id
? { ...h, credentialId: String(result.id) }
: h,
),
);
}
setEditingHost(
returnHost === "new"
? "new"
: { ...returnHost, credentialId: String(result.id) },
);
setActiveHostTab("ssh");
setEditingCredential(null);
return;
}
setEditingCredential(null);
setActiveCredentialTab("general");
}}
/>
)}
</div>
</div>
);
};
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
useEffect(() => {
if (active) onEditingChange?.(isEditing);
}, [isEditing, active]);
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{isEditing ? (
renderEditorView()
) : (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Search bar — hidden when parent supplies its own */}
{!hideListHeader && (
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("credentials.searchCredentialsPlaceholder")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={credentialFolderTree}
usedByCounts={usedByCounts}
query=""
loading={credentialsLoading}
arrangeLocked={externalArrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
editingFolderName={editingCredFolderName}
editingFolderValue={editingCredFolderValue}
onEditingFolderNameChange={setEditingCredFolderName}
onEditingFolderValueChange={setEditingCredFolderValue}
onRenameFolder={handleRenameCredentialFolder}
onMoveCredentialToFolder={handleMoveCredentialToFolder}
onDeployCredential={(cred) =>
setDeployDialog({ cred, hostId: "" })
}
onEditCredential={handleEditCredential}
onCloneCredential={handleCloneCredential}
onDeleteCredential={handleConfirmDeleteCredential}
onShareCredential={setShareCredential}
/>
)}
</div>
)}
<CredentialShareModal
credential={shareCredential}
onClose={() => setShareCredential(null)}
/>
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
{showUnsavedHostDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-foreground">
{t("common.unsavedChanges")}
</p>
<p className="text-xs text-muted-foreground">
{t("hosts.unsavedChangesDescription")}
</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowUnsavedHostDialog(false)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.keepEditing")}
</button>
<button
onClick={closeHostEditor}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.discardChanges")}
</button>
</div>
</div>
</div>
)}
{/* Deploy credential dialog */}
{deployDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">
{t("credentials.deployDialogTitle")}
</span>
<button
onClick={() => setDeployDialog(null)}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="text-xs text-muted-foreground">
{t("credentials.deployDialogDesc", {
name: deployDialog.cred.name,
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.targetHostLabel")}
</label>
<Select2
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={deployDialog.hostId}
onChange={(e) =>
setDeployDialog({ ...deployDialog, hostId: e.target.value })
}
>
<option value="">{t("credentials.selectHostOption")}</option>
{allHosts
.filter(
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployDialog(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployDialog.hostId || deploying}
onClick={async () => {
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployDialog.cred.id),
Number(deployDialog.hostId),
);
toast.success(t("credentials.keyDeployedSuccess"));
setDeployDialog(null);
} catch {
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
}}
>
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+3 -127
View File
@@ -1,19 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import {
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
ChevronLeft,
ChevronRight,
KeyRound,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type ComponentType, type ReactNode } from "react";
import { KeyRound, Settings, Terminal } from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
@@ -196,115 +183,4 @@ export function makeCredentialTabs(
];
}
export function TabStrip({
tabs,
activeTab,
onTabChange,
isActive,
variant = "primary",
}: {
tabs: { id: string; label: string; icon: ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
isActive?: (id: string) => boolean;
variant?: "primary" | "secondary";
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer = new ResizeObserver(update);
observer.observe(el);
if (el.firstElementChild) observer.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const renderTab = (tab: (typeof tabs)[0]) => {
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 ${
variant === "secondary" ? "py-1.5 text-[11px]" : "py-2 text-xs"
} font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
active
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
</button>
);
};
const overflow = scroll.left || scroll.right;
return (
<div
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
</div>
);
}
export { TabStrip } from "@/components/tab-strip";
+40 -20
View File
@@ -5,8 +5,8 @@ import {
type FolderAccessRule,
} from "@/api/rbac-api";
import { useTranslation } from "react-i18next";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
ArrowLeft,
Check,
ListChecks,
Search,
@@ -70,6 +70,7 @@ export function HostShareModal({
folder?: string | null;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const isFolderShare = !host && !!folder;
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
@@ -278,23 +279,20 @@ export function HostShareModal({
}
}
if (!open) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
{/* Header */}
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
</span>
</button>
<InlineView
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
icon={<Share2 className="size-4" />}
title={
isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })
}
bare
>
{sharingLoadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
@@ -544,8 +542,19 @@ export function HostShareModal({
type="button"
className="text-[10px] text-muted-foreground hover:text-destructive"
title={t("hosts.sharing.folderRuleRemove")}
onClick={() => {
void revokeFolderAccess(rule.id).then(refreshFolderRules);
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
rule.username ??
rule.roleDisplayName ??
rule.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (ok)
void revokeFolderAccess(rule.id).then(refreshFolderRules);
}}
>
<X className="size-3.5" />
@@ -626,6 +635,17 @@ export function HostShareModal({
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
record.username ??
record.roleDisplayName ??
record.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeHostAccess(
Number(host!.id),
@@ -676,6 +696,6 @@ export function HostShareModal({
</div>
</div>
)}
</div>
</InlineView>
);
}
+4 -17
View File
@@ -26,7 +26,6 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
@@ -200,7 +199,6 @@ export function HostsPanel({
onEditHost,
hostTree,
loading,
onEditingChange,
active = true,
}: {
onOpenTab: (
@@ -215,6 +213,7 @@ export function HostsPanel({
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
loading?: boolean;
/** Unused since editing moved to the Manage tab. */
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
@@ -238,7 +237,6 @@ export function HostsPanel({
const hostProtocols = useHostProtocols();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@@ -345,11 +343,6 @@ export function HostsPanel({
};
}, []);
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
function toggleSelectionMode() {
setSelectionMode((v) => !v);
}
@@ -422,7 +415,7 @@ export function HostsPanel({
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-none">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
@@ -1023,7 +1016,7 @@ export function HostsPanel({
</div>
</div>
</div>
)}
}
<CustomizeSidebarPanel
open={customizePanelOpen}
onOpenChange={setCustomizePanelOpen}
@@ -1031,7 +1024,7 @@ export function HostsPanel({
update={updateSidebarPrefs}
/>
{active && !managerEditing && (
{active && (
<div className="flex flex-col flex-1 min-h-0">
<SidebarTree
children={
@@ -1069,12 +1062,6 @@ export function HostsPanel({
</div>
)}
<div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
>
<HostManager onEditingChange={handleEditingChange} active={active} />
</div>
<HostShareModal
open={shareModalHost !== null}
onClose={() => setShareModalHost(null)}
+16 -56
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useConfirm } from "@/components/surface/surface-scope";
import {
Check,
Gauge,
@@ -9,14 +10,6 @@ import {
Wrench,
} from "lucide-react";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { usePluginStore } from "@/plugin-host/plugin-store";
@@ -45,10 +38,7 @@ export function InterfacePresetSettings({
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const [pendingPreset, setPendingPreset] = useState<Exclude<
UiPreset,
"custom"
> | null>(null);
const confirm = useConfirm();
const [applying, setApplying] = useState(false);
// The chosen preset stays highlighted even once knobs diverge from it --
@@ -87,18 +77,25 @@ export function InterfacePresetSettings({
if (!ctx) return null;
async function confirmPreset() {
if (!pendingPreset) return;
async function choosePreset(preset: Exclude<UiPreset, "custom">) {
const ok = await confirm({
title: t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: t(`newUi.sidebar.userProfile.preset_${preset}`),
}),
description: t("newUi.sidebar.userProfile.presetConfirmDesc"),
confirmLabel: t("newUi.sidebar.userProfile.presetConfirmApply"),
destructive: false,
});
if (!ok) return;
setApplying(true);
try {
ctx.setPreset(pendingPreset);
ctx.setPreset(preset);
// Picking a preset is a deliberate reset, so any knob the user had
// pinned goes back to following the preset.
ctx.clearAllOverrides();
await applyPresetSideEffects(pendingPreset);
await applyPresetSideEffects(preset);
} finally {
setApplying(false);
setPendingPreset(null);
}
}
@@ -118,7 +115,8 @@ export function InterfacePresetSettings({
<button
key={preset}
type="button"
onClick={() => setPendingPreset(preset)}
disabled={applying}
onClick={() => void choosePreset(preset)}
aria-pressed={active}
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
active
@@ -203,44 +201,6 @@ export function InterfacePresetSettings({
</Button>
</div>
)}
<Dialog
open={!!pendingPreset}
onOpenChange={(open) => !open && setPendingPreset(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: pendingPreset
? t(`newUi.sidebar.userProfile.preset_${pendingPreset}`)
: "",
})}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.presetConfirmDesc")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingPreset(null)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={applying}
onClick={confirmPreset}
>
{t("newUi.sidebar.userProfile.presetConfirmApply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-494
View File
@@ -1,494 +0,0 @@
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "./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;
}
/**
* Appearance > 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 KeybindingsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
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(() => {
if (!open) return;
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, [open]);
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"));
}
}
function handleDelete(id: string) {
persist(bindings.filter((kb) => kb.id !== id));
}
function handleResetAll() {
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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.keybindings.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{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={() => 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">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.customHeading")}
</span>
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
</div>
{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={() => handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
</div>
<div className="flex items-center justify-between gap-2 mt-2">
<Button
variant="ghost"
className="text-muted-foreground"
onClick={handleResetAll}
disabled={bindings.length === 0}
>
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("newUi.sidebar.keybindings.close")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<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-yellow-500">
{conflictWarning}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
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>
))}
</select>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => setFormOpen(false)}>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
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>
</DialogContent>
</Dialog>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PanelRight, SquareArrowOutUpRight, X } from "lucide-react";
const STORAGE_KEY = "termix_multiPanelHintDismissed";
function multiPanelHintDismissed(): boolean {
return localStorage.getItem(STORAGE_KEY) === "true";
}
/**
* One-time nudge telling users a panel can leave the sidebar. Both actions are
* otherwise only reachable through modifier clicks and the rail context menu,
* which nobody finds on their own.
*/
export function MultiPanelHint({
canPromote,
canRightDock,
onOpenAsTab,
onOpenInRightDock,
}: {
canPromote: boolean;
canRightDock: boolean;
onOpenAsTab: () => void;
onOpenInRightDock: () => void;
}) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(multiPanelHintDismissed);
if (dismissed || (!canPromote && !canRightDock)) return null;
function dismiss() {
localStorage.setItem(STORAGE_KEY, "true");
setDismissed(true);
}
return (
<div className="relative shrink-0 border-b border-border bg-accent-brand/5 px-3 py-2.5">
<button
onClick={dismiss}
title={t("common.dismiss")}
aria-label={t("common.dismiss")}
className="absolute right-1.5 top-1.5 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
<p className="pr-5 text-xs font-semibold text-foreground">
{t("nav.multiPanelHintTitle")}
</p>
<p className="mt-0.5 pr-5 text-[11px] leading-snug text-muted-foreground">
{t("nav.multiPanelHintBody")}
</p>
<div className="mt-2 flex flex-col gap-1.5">
{canPromote && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<SquareArrowOutUpRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenAsTab();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openAsTab")}
</button>
{": "}
{t("nav.multiPanelHintTabHow")}
</span>
</div>
)}
{canRightDock && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<PanelRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenInRightDock();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openInRightDock")}
</button>
{": "}
{t("nav.multiPanelHintDockHow")}
</span>
</div>
)}
</div>
</div>
);
}
+182 -177
View File
@@ -1,3 +1,4 @@
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { useState, useRef, useEffect } from "react";
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
import { useTranslation } from "react-i18next";
@@ -26,14 +27,6 @@ import { shell } from "@/plugin-host/shell-bridge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { VersionBadge } from "@/components/version-badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/dialog";
import {
AlertCircle,
ChevronDown,
@@ -56,8 +49,8 @@ import {
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
import { InterfacePresetSettings } from "./InterfacePresetSettings";
import { useSettingsEmbedded } from "@/settings/settings-embed";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { KeybindingsDialog } from "./KeybindingsDialog";
import {
ACCENT_PRESET_COLORS,
applyAccentColor,
@@ -157,7 +150,19 @@ export function AccordionSection({
hidden?: boolean;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (hidden) return null;
if (embedded) {
if (!open) return null;
return (
<div
id={`${id}-content`}
className="flex flex-col border border-border bg-card px-3 pb-3"
>
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden">
@@ -253,88 +258,14 @@ export function NewApiKeyDialog({
};
return (
<Dialog
<InlineView
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || !createdToken) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-center gap-2.5">
<div className="size-8 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-3.5 text-accent-brand" />
</div>
<div>
<DialogTitle className="text-base font-bold leading-none">
{t("newUi.sidebar.userProfile.createApiKeyTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
</DialogDescription>
</div>
</div>
</DialogHeader>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t(
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
)}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
)}
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{createdToken ? (
<Button
variant="outline"
@@ -363,9 +294,68 @@ export function NewApiKeyDialog({
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
</p>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t("newUi.sidebar.userProfile.apiKeyNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
)}
</InlineView>
);
}
@@ -485,7 +475,13 @@ export function UserProfilePanel({
onLogout,
userPrefs,
onPrefsChange,
section,
onOpenSettingsPage,
}: {
/** Shows just this section, flat, as a Settings page. */
section?: string;
/** Moves the Settings tab to another page. */
onOpenSettingsPage?: (id: string) => void;
username?: string;
onLogout?: () => void;
userPrefs?: {
@@ -509,6 +505,7 @@ export function UserProfilePanel({
}) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const themeLabel: Record<ThemeId, string> = {
system: t("newUi.sidebar.userProfile.themeSystem"),
light: t("newUi.sidebar.userProfile.themeLight"),
@@ -603,7 +600,6 @@ export function UserProfilePanel({
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
// Sidebar display customization (density, tags, tray trigger, status
// colors) now lives in the dedicated Customize Sidebar panel opened from
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
@@ -1182,9 +1178,18 @@ export function UserProfilePanel({
}
const canChangePasword = !isExternal || isDualAuth;
const isOpen = (id: string) =>
section ? section === id : openSections.has(id as UserProfileSection);
const shows = (id: string) => !section || section === id;
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3"
}
>
<NewApiKeyDialog
open={newKeyOpen}
onOpenChange={setNewKeyOpen}
@@ -1193,30 +1198,32 @@ export function UserProfilePanel({
/>
{/* Donate banner */}
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
{shows("account") && (
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
)}
{/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isLinked) && (
{(!isElectron() || isLinked) && shows("data") && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")}
@@ -1261,7 +1268,7 @@ export function UserProfilePanel({
id="account"
label={t("newUi.sidebar.userProfile.sectionAccount")}
icon={<User className="size-3.5" />}
open={openSections.has("account")}
open={isOpen("account")}
onToggle={() => toggle("account")}
>
<div className="flex flex-col gap-0 pt-2">
@@ -1447,7 +1454,7 @@ export function UserProfilePanel({
id="interface"
label={t("newUi.sidebar.userProfile.sectionInterface")}
icon={<LayoutTemplate size={13} />}
open={openSections.has("interface")}
open={isOpen("interface")}
onToggle={() => toggle("interface")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1464,7 +1471,7 @@ export function UserProfilePanel({
id="appearance"
label={t("newUi.sidebar.userProfile.sectionAppearance")}
icon={<Palette className="size-3.5" />}
open={openSections.has("appearance")}
open={isOpen("appearance")}
onToggle={() => toggle("appearance")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1641,7 +1648,7 @@ export function UserProfilePanel({
<Button
variant="outline"
size="sm"
onClick={() => setKeybindingsDialogOpen(true)}
onClick={() => onOpenSettingsPage?.("keybindings")}
>
{t("newUi.sidebar.userProfile.manageShortcuts")}
</Button>
@@ -1812,7 +1819,7 @@ export function UserProfilePanel({
id="security"
label={t("newUi.sidebar.userProfile.sectionSecurity")}
icon={<Shield className="size-3.5" />}
open={openSections.has("security")}
open={isOpen("security")}
onToggle={() => toggle("security")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1833,7 +1840,7 @@ export function UserProfilePanel({
id="api-keys"
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={openSections.has("api-keys")}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
>
<div className="flex flex-col gap-2 pt-3">
@@ -1896,6 +1903,13 @@ export function UserProfilePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
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) =>
@@ -1938,7 +1952,7 @@ export function UserProfilePanel({
id="data"
label={t("newUi.sidebar.userProfile.sectionData")}
icon={<Database className="size-3.5" />}
open={openSections.has("data")}
open={isOpen("data")}
onToggle={() => toggle("data")}
>
<div className="flex flex-col gap-3 pt-3">
@@ -2013,78 +2027,69 @@ export function UserProfilePanel({
key={plugin.id}
plugin={plugin}
scope="user"
open={openSections.has(featureSectionId(plugin.id))}
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
{/* Delete account dialog */}
<Dialog
<InlineView
open={showDeleteConfirm}
onOpenChange={(open) => {
setShowDeleteConfirm(open);
if (!open) setDeletePassword("");
}}
title={t("newUi.sidebar.userProfile.deleteAccount")}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-destructive">
{t("newUi.sidebar.userProfile.deleteAccount")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
</label>
<Input
type="password"
placeholder={t(
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
)}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
</p>
<div className="flex flex-col gap-3 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
</span>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
setDeletePassword("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword.trim()}
>
<Trash2 className="size-3.5" />
{deleteLoading
? t("newUi.sidebar.userProfile.deleting")
: t("newUi.sidebar.userProfile.deleteAccount")}
</Button>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
</label>
<Input
type="password"
placeholder={t(
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
)}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
/>
</div>
</DialogContent>
</Dialog>
<KeybindingsDialog
open={keybindingsDialogOpen}
onOpenChange={setKeybindingsDialogOpen}
/>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
setDeletePassword("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword.trim()}
>
<Trash2 className="size-3.5" />
{deleteLoading
? t("newUi.sidebar.userProfile.deleting")
: t("newUi.sidebar.userProfile.deleteAccount")}
</Button>
</div>
</InlineView>
</div>
);
}
+49
View File
@@ -1,3 +1,8 @@
import { useEffect, useState } from "react";
import { getUserPreferences, saveUserPreferences } from "@/main-axios";
export const HIDDEN_RAIL_TABS_EVENT = "hiddenRailTabsChanged";
export function readHiddenRailTabs(): Set<string> {
try {
const raw = localStorage.getItem("hiddenRailTabs");
@@ -8,3 +13,47 @@ export function readHiddenRailTabs(): Set<string> {
return new Set();
}
}
/**
* The one writer for hidden rail items: the rail's restore list, the
* Navigation toggles in settings and presets all go through here, so they
* never disagree. Saved to the account when settings sync to the cloud.
*/
export async function writeHiddenRailTabs(
next: Iterable<string>,
): Promise<void> {
const serialized = JSON.stringify([...next]);
try {
localStorage.setItem("hiddenRailTabs", serialized);
} catch {
// A private window refuses the write; the change still applies this session.
}
window.dispatchEvent(new Event(HIDDEN_RAIL_TABS_EVENT));
try {
const prefs = await getUserPreferences();
if (prefs?.storageMode === "cloud") {
await saveUserPreferences({ hiddenRailTabs: serialized });
}
} catch {
// Offline; the local copy is already updated.
}
}
/** Hides one item if it is shown, shows it if hidden. */
export function toggleHiddenRailTab(id: string): Promise<void> {
const hidden = readHiddenRailTabs();
if (hidden.has(id)) hidden.delete(id);
else hidden.add(id);
return writeHiddenRailTabs(hidden);
}
/** The hidden rail items, kept current as anything changes them. */
export function useHiddenRailTabs(): Set<string> {
const [hidden, setHidden] = useState(readHiddenRailTabs);
useEffect(() => {
const handler = () => setHidden(readHiddenRailTabs());
window.addEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
return () => window.removeEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
}, []);
return hidden;
}
@@ -1,16 +1,10 @@
import { useMemo, useState } from "react";
/* eslint-disable react-refresh/only-export-components */
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import type { Host } from "@/types/ui-types";
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
import { HostEditor } from "../HostEditor";
import {
isSshGroupTab,
makeHostSshSubTabs,
makeHostTabs,
TabStrip,
useHostEditorSections,
} from "../HostManagerTabs";
import { useHostEditorSections } from "../HostManagerTabs";
import { listHostProtocols, type HostProtocols } from "../host-protocols";
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
@@ -18,8 +12,18 @@ export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
folderName?: string;
}
/** The title a level of defaults goes by. */
export function hostDefaultsTitle(
target: HostDefaultsEditorTarget,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (target.level === "admin") return t("hostDefaults.titleAdmin");
if (target.level === "user") return t("hostDefaults.titleUser");
return t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
}
/**
* The host editor as a form for one level of host defaults: the same tabs
* The host editor as a form for one level of host defaults: the same sections
* and fields as a new host, minus what only one host can have (its name,
* address, secrets, folder).
*/
@@ -38,25 +42,14 @@ export function HostDefaultsEditorView({
}) {
const { t } = useTranslation();
useHostEditorSections();
const [activeTab, setActiveTab] = useState("general");
// Every protocol counts as on, so every tab that has defaults shows.
// Every protocol counts as on, so every section that has defaults shows.
const protocols = useMemo<HostProtocols>(() => {
const all: HostProtocols = { enableSsh: true };
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
return all;
}, []);
const flags = protocols as unknown as Record<string, boolean>;
const tabs = makeHostTabs(t, flags, true);
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
const inSshGroup = isSshGroupTab(activeTab);
const title =
target.level === "admin"
? t("hostDefaults.titleAdmin")
: target.level === "user"
? t("hostDefaults.titleUser")
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
const description =
target.level === "admin"
? t("hostDefaults.descriptionAdmin")
@@ -65,61 +58,22 @@ export function HostDefaultsEditorView({
: t("hostDefaults.descriptionFolder");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t("hosts.backToHosts")}</span>
<span
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
title={title}
>
{title}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (id === "ssh") {
if (!inSshGroup) setActiveTab("ssh");
} else {
setActiveTab(id);
}
}}
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
/>
{inSshGroup && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
variant="secondary"
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
banner={
<p className="border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{description}
</p>
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
activeTab={activeTab}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
onTabChange={setActiveTab}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
</div>
</div>
}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
);
}
+32 -9
View File
@@ -3,8 +3,8 @@ import {
KeyRound,
Plug,
Server,
LibraryBig,
Settings,
User,
Zap,
type LucideIcon,
} from "lucide-react";
@@ -21,7 +21,7 @@ import { getTabType } from "@/shell/tab-registry";
*
* This used to be duplicated in four places -- AppRail's button array, the
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
* MobileBottomBar's own primary/more lists -- which drifted: half the sidebar
* the mobile bar's own primary/more lists -- which drifted: half the sidebar
* titles were hardcoded English, the alerts entry had no visibility toggle,
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
* here, so adding a destination is a single edit.
@@ -38,6 +38,8 @@ export interface RailItemDef {
alwaysVisible?: boolean;
/** Renders a separator after this item in the rail. */
separatorAfter?: boolean;
/** The rail band it sits in. Defaults to "tools". */
group?: RailGroup;
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
mobilePrimary?: boolean;
/**
@@ -72,13 +74,24 @@ export interface RailItemDef {
useBadge?: () => number | null | undefined;
}
/** Things you keep, things you do, and the instance itself. */
export type RailGroup = "objects" | "tools" | "system";
export const RAIL_GROUP_ORDER: RailGroup[] = ["objects", "tools", "system"];
export const RAIL_ITEMS: RailItemDef[] = [
{ id: "hosts", icon: Server, labelKey: "nav.hosts", mobilePrimary: true },
{
id: "hosts",
icon: Server,
labelKey: "nav.hosts",
mobilePrimary: true,
group: "objects",
},
{
id: "credentials",
icon: KeyRound,
labelKey: "nav.credentials",
separatorAfter: true,
group: "objects",
},
{
id: "connections",
@@ -86,6 +99,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.connections",
separatorAfter: true,
rightDockable: true,
group: "tools",
},
{
id: "quick-connect",
@@ -93,6 +107,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.quickConnect",
separatorAfter: true,
mobilePrimary: true,
group: "tools",
},
{
id: "sync",
@@ -100,10 +115,21 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.sync",
electronOnly: true,
placement: "footer",
group: "system",
useBadge: useSyncAttentionCount,
},
];
/** Main rail items split into their bands, in band order, empty bands left out. */
export function groupRailItems(
items: RailItemDef[],
): { group: RailGroup; items: RailItemDef[] }[] {
return RAIL_GROUP_ORDER.map((group) => ({
group,
items: items.filter((item) => (item.group ?? "tools") === group),
})).filter((band) => band.items.length > 0);
}
/**
* Rail items registered by plugins at runtime. Reactive, because a plugin can
* be enabled or disabled while the app is open and the rail, the mobile bar
@@ -203,13 +229,10 @@ export function useRailItems(): RailItemDef[] {
);
}
/**
* Destinations that live outside the rail's hideable list but still need a
* title and a mobile entry.
*/
/** Places outside the rail that still need a title, like the Settings tab. */
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
{ id: "host-manager", icon: LibraryBig, labelKey: "nav.manage" },
];
/** Ids that may be opened in the right dock. */
+2 -2
View File
@@ -1267,7 +1267,7 @@ export function HostItem({
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-warning" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
@@ -1281,7 +1281,7 @@ export function HostItem({
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-warning" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
+10 -30
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
import {
useCallback,
@@ -179,10 +180,15 @@ export function SidebarTree({
useEffect(() => {
if (!selectionMode) setSelectedHostIds(new Set());
}, [selectionMode, setSelectedHostIds]);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => Promise<void> | void;
} | null>(null);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => Promise<void> | void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } =
useSidebarDragState();
const [folderDialog, setFolderDialog] = useState<{
@@ -1437,32 +1443,6 @@ export function SidebarTree({
</div>
)}
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
<FolderMetadataDialog
open={folderDialog !== null}
mode={folderDialog?.mode ?? "create"}
+65 -76
View File
@@ -1,6 +1,10 @@
import { useState } from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PanePrompt } from "@/components/surface/surface-scope";
import {
PROMPT_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "@/components/surface/prompt-styles";
import { Shield, Copy, ExternalLink, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -50,91 +54,76 @@ export function BrowserSignInDialog({
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200 overflow-y-auto">
<div
className="absolute inset-0 bg-canvas"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 my-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("sshAuth.browserSignInRequired", { provider: label })}
</h3>
</div>
</div>
<div className="p-4 flex flex-col gap-4">
{code && code !== "N/A" && (
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("sshAuth.browserSignInCode")}
</p>
<div className="border border-border bg-muted/10 p-4 text-center">
<div className="text-2xl font-mono font-bold tracking-wider text-accent-brand">
{code}
</div>
</div>
</div>
)}
<PanePrompt
open
layer="connection"
backgroundColor={backgroundColor}
icon={<Shield className="size-4" />}
title={t("sshAuth.browserSignInRequired", { provider: label })}
className="max-w-md"
>
<div className="flex flex-col gap-3">
{code && code !== "N/A" && (
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("sshAuth.browserSignInUrl")}
{t("sshAuth.browserSignInCode")}
</p>
<div className="flex gap-2">
<Input
type="text"
value={url}
readOnly
className="rounded-none bg-muted/50 border-border text-xs font-mono flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyUrl}
className="rounded-none border-border shrink-0"
title={t("common.copy")}
>
{copied ? (
<Check className="size-4 text-accent-brand" />
) : (
<Copy className="size-4" />
)}
</Button>
<div className="border border-border bg-muted/10 p-4 text-center">
<div className="text-2xl font-mono font-bold tracking-wider text-accent-brand">
{code}
</div>
</div>
</div>
)}
<div className="flex flex-wrap justify-end gap-2 pt-1">
<Button
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("sshAuth.browserSignInUrl")}
</p>
<div className="flex gap-2">
<Input
type="text"
value={url}
readOnly
className="rounded-none bg-muted/50 border-border text-xs font-mono flex-1"
/>
<button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest sm:mr-auto"
onClick={handleCopyUrl}
className={`${PROMPT_BUTTON} h-9 w-9 shrink-0 px-0`}
title={t("common.copy")}
aria-label={t("common.copy")}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="outline"
onClick={onContinue}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("sshAuth.browserSignInContinue")}
</Button>
<Button
type="button"
variant="outline"
onClick={onOpenUrl}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5"
>
<ExternalLink className="size-3.5" />
{t("sshAuth.browserSignInOpen")}
</Button>
{copied ? (
<Check className="size-4 text-accent-brand" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={onCancel}
className={`${PROMPT_BUTTON} sm:mr-auto`}
>
{t("common.cancel")}
</button>
<button type="button" onClick={onContinue} className={PROMPT_BUTTON}>
{t("sshAuth.browserSignInContinue")}
</button>
<button
type="button"
onClick={onOpenUrl}
className={PROMPT_PRIMARY_BUTTON}
>
<ExternalLink className="size-3.5" />
{t("sshAuth.browserSignInOpen")}
</button>
</div>
</div>
</div>
</PanePrompt>
);
}
+135 -156
View File
@@ -1,5 +1,10 @@
import React, { useState } from "react";
import { Button } from "@/components/button.tsx";
import { useState } from "react";
import { PanePrompt } from "@/components/surface/surface-scope";
import {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "@/components/surface/prompt-styles";
import { Shield, AlertTriangle, Copy, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -20,6 +25,47 @@ interface HostKeyVerificationDialogProps {
backgroundColor?: string;
}
const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") || fp;
function FingerprintRow({
label,
value,
copied,
onCopy,
}: {
label: string;
value: string;
copied: boolean;
onCopy: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
{formatFingerprint(value)}
</div>
<button
type="button"
onClick={onCopy}
title={t("common.copy")}
aria-label={t("common.copy")}
className={`${PROMPT_BUTTON} size-8 shrink-0 px-0`}
>
{copied ? (
<Check className="size-4 text-accent-brand" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
);
}
export function HostKeyVerificationDialog({
isOpen,
scenario,
@@ -50,168 +96,101 @@ export function HostKeyVerificationDialog({
}
};
const formatFingerprint = (fp: string) =>
fp.match(/.{1,2}/g)?.join(":") || fp;
const changed = scenario === "changed";
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-lg mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
{scenario === "new" ? (
<Shield className="size-4 text-accent-brand" />
) : (
<AlertTriangle className="size-4 text-destructive" />
)}
<h3 className="text-xs font-bold uppercase tracking-widest">
{scenario === "new"
? t("hostKey.verifyNewHost")
: t("hostKey.keyChangedWarning")}
</h3>
</div>
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
{hostname || ip}:{port}
</p>
</div>
<div className="p-4 flex flex-col gap-4">
{scenario === "new" ? (
<>
<div className="flex items-start gap-3 p-3 border border-border bg-muted/10">
<Shield className="size-4 text-accent-brand shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold uppercase tracking-widest">
{t("hostKey.firstConnectionTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t("hostKey.firstConnectionDescription")}
</p>
</div>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostKey.fingerprint")} ({algorithm.toUpperCase()})
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
{formatFingerprint(fingerprint)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyFingerprint(fingerprint)}
className="rounded-none shrink-0"
>
{copiedFingerprint ? (
<Check className="size-4 text-accent-brand" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hostKey.verifyInstructions")}
</p>
</>
) : (
<>
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
{t("hostKey.securityWarning")}
</p>
<p className="text-xs text-destructive/80 mt-1">
{t("hostKey.keyChangedDescription")}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostKey.previousKey")}
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
{formatFingerprint(oldFingerprint || "")}
</div>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyFingerprint(oldFingerprint || "", true)
}
className="rounded-none shrink-0"
>
{copiedOldFingerprint ? (
<Check className="size-4 text-accent-brand" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostKey.newFingerprint")}
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
{formatFingerprint(fingerprint)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyFingerprint(fingerprint)}
className="rounded-none shrink-0"
>
{copiedFingerprint ? (
<Check className="size-4 text-accent-brand" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
</div>
</>
)}
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={onReject}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
<PanePrompt
open
layer="connection"
backgroundColor={backgroundColor}
tone={changed ? "destructive" : "default"}
icon={
changed ? (
<AlertTriangle className="size-4" />
) : (
<Shield className="size-4" />
)
}
title={
changed ? t("hostKey.keyChangedWarning") : t("hostKey.verifyNewHost")
}
description={
<span className="font-mono">
{hostname || ip}:{port}
</span>
}
className="max-w-lg"
actions={
<>
<button type="button" onClick={onReject} className={PROMPT_BUTTON}>
{t("common.cancel")}
</Button>
<Button
</button>
<button
type="button"
onClick={onAccept}
variant="outline"
className={
scenario === "changed"
? "border-destructive/40 text-destructive hover:bg-destructive/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
changed ? PROMPT_DESTRUCTIVE_BUTTON : PROMPT_PRIMARY_BUTTON
}
>
{scenario === "new"
? t("hostKey.acceptAndContinue")
: t("hostKey.acceptNewKey")}
</Button>
</div>
{changed
? t("hostKey.acceptNewKey")
: t("hostKey.acceptAndContinue")}
</button>
</>
}
>
<div className="flex flex-col gap-3">
{changed ? (
<>
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
{t("hostKey.securityWarning")}
</p>
<p className="text-xs text-destructive/80 mt-1">
{t("hostKey.keyChangedDescription")}
</p>
</div>
</div>
<FingerprintRow
label={t("hostKey.previousKey")}
value={oldFingerprint || ""}
copied={copiedOldFingerprint}
onCopy={() => copyFingerprint(oldFingerprint || "", true)}
/>
<FingerprintRow
label={t("hostKey.newFingerprint")}
value={fingerprint}
copied={copiedFingerprint}
onCopy={() => copyFingerprint(fingerprint)}
/>
</>
) : (
<>
<div className="flex items-start gap-3 p-3 border border-border bg-muted/10">
<Shield className="size-4 text-accent-brand shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold uppercase tracking-widest">
{t("hostKey.firstConnectionTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t("hostKey.firstConnectionDescription")}
</p>
</div>
</div>
<FingerprintRow
label={`${t("hostKey.fingerprint")} (${algorithm.toUpperCase()})`}
value={fingerprint}
copied={copiedFingerprint}
onCopy={() => copyFingerprint(fingerprint)}
/>
<p className="text-[10px] text-muted-foreground">
{t("hostKey.verifyInstructions")}
</p>
</>
)}
</div>
</div>
</PanePrompt>
);
}
+34 -49
View File
@@ -1,6 +1,10 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import { PanePrompt } from "@/components/surface/surface-scope";
import {
PROMPT_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "@/components/surface/prompt-styles";
import { KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -38,55 +42,36 @@ export function PassphraseDialog({
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<KeyRound className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("auth.passphraseRequired")}
</h3>
</div>
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
{hostDisplay}
<PanePrompt
open
layer="connection"
backgroundColor={backgroundColor}
icon={<KeyRound className="size-4" />}
title={t("auth.passphraseRequired")}
description={<span className="font-mono">{hostDisplay}</span>}
>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<PasswordInput
id="passphrase"
name="passphrase"
autoFocus
placeholder={t("placeholders.keyPassword")}
className="rounded-none bg-muted/50 border-border text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{t("auth.passphraseRequiredDescription")}
</p>
</div>
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<PasswordInput
id="passphrase"
name="passphrase"
autoFocus
placeholder={t("placeholders.keyPassword")}
className="rounded-none bg-muted/50 border-border text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{t("auth.passphraseRequiredDescription")}
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.connect")}
</Button>
</div>
</form>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className={PROMPT_BUTTON}>
{t("common.cancel")}
</button>
<button type="submit" className={PROMPT_PRIMARY_BUTTON}>
{t("common.connect")}
</button>
</div>
</form>
</PanePrompt>
);
}
+162 -172
View File
@@ -2,6 +2,11 @@ import React, { useState } from "react";
import { Button } from "@/components/button.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import { Label } from "@/components/label.tsx";
import { PanePrompt } from "@/components/surface/surface-scope";
import {
PROMPT_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "@/components/surface/prompt-styles";
import {
Tabs,
TabsContent,
@@ -120,191 +125,176 @@ export function SSHAuthDialog({
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-xl mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("auth.sshAuthenticationRequired")}
</h3>
<PanePrompt
open
layer="connection"
backgroundColor={backgroundColor}
icon={<Shield className="size-4" />}
title={t("auth.sshAuthenticationRequired")}
description={<span className="font-mono">{hostDisplay}</span>}
className="max-w-xl"
>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-3">
<div
className={`flex items-start gap-3 p-3 border ${
reason === "auth_failed"
? "border-destructive/20 bg-destructive/10"
: "border-border bg-muted/10"
}`}
>
<AlertCircle
className={`size-4 shrink-0 mt-0.5 ${reason === "auth_failed" ? "text-destructive" : "text-accent-brand"}`}
/>
<div>
<p
className={`text-[10px] font-bold uppercase tracking-widest ${reason === "auth_failed" ? "text-destructive" : ""}`}
>
{getReasonMessage()}
</p>
<p
className={`text-xs mt-1 ${reason === "auth_failed" ? "text-destructive/80" : "text-muted-foreground"}`}
>
{getReasonDescription()}
</p>
</div>
</div>
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
{hostDisplay}
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="p-4 flex flex-col gap-4">
<div
className={`flex items-start gap-3 p-3 border ${
reason === "auth_failed"
? "border-destructive/20 bg-destructive/10"
: "border-border bg-muted/10"
}`}
>
<AlertCircle
className={`size-4 shrink-0 mt-0.5 ${reason === "auth_failed" ? "text-destructive" : "text-accent-brand"}`}
<Tabs
value={authTab}
onValueChange={(v) => setAuthTab(v as "password" | "key")}
>
<TabsList className="w-full rounded-none">
<TabsTrigger
value="password"
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger
value="key"
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("credentials.sshKey")}
</TabsTrigger>
</TabsList>
<TabsContent value="password" className="mt-3 flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.password")}
</Label>
<PasswordInput
placeholder={t("placeholders.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
className="rounded-none bg-muted/50 border-border text-xs"
/>
<div>
<p
className={`text-[10px] font-bold uppercase tracking-widest ${reason === "auth_failed" ? "text-destructive" : ""}`}
>
{getReasonMessage()}
</p>
<p
className={`text-xs mt-1 ${reason === "auth_failed" ? "text-destructive/80" : "text-muted-foreground"}`}
>
{getReasonDescription()}
<p className="text-[10px] text-muted-foreground">
{t("auth.sshPasswordDescription")}
</p>
</TabsContent>
<TabsContent value="key" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.sshPrivateKey")}
</Label>
<div className="relative">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.ppk,.txt"
onChange={handleKeyFileUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start rounded-none text-[10px] font-bold uppercase tracking-widest border-border"
>
<Upload className="size-3.5 mr-2" />
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
<CodeMirror
value={sshKey}
onChange={(value) => setSshKey(value)}
placeholder={t("placeholders.pastePrivateKey")}
theme={oneDark}
className="border border-border text-xs"
minHeight="160px"
maxHeight="260px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
}),
]}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.keyPassword")} ({t("common.optional")})
</Label>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
value={keyPassword}
onChange={(e) => setKeyPassword(e.target.value)}
className="rounded-none bg-muted/50 border-border text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{t("auth.sshKeyPasswordDescription")}
</p>
</div>
</div>
<Tabs
value={authTab}
onValueChange={(v) => setAuthTab(v as "password" | "key")}
>
<TabsList className="w-full rounded-none">
<TabsTrigger
value="password"
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger
value="key"
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("credentials.sshKey")}
</TabsTrigger>
</TabsList>
<TabsContent
value="password"
className="mt-3 flex flex-col gap-2"
>
<div className="flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.password")}
{t("credentials.password")} ({t("common.optional")})
</Label>
<PasswordInput
placeholder={t("placeholders.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
className="rounded-none bg-muted/50 border-border text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{t("auth.sshPasswordDescription")}
</p>
</TabsContent>
</div>
</TabsContent>
</Tabs>
</div>
<TabsContent value="key" className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.sshPrivateKey")}
</Label>
<div className="relative">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.ppk,.txt"
onChange={handleKeyFileUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start rounded-none text-[10px] font-bold uppercase tracking-widest border-border"
>
<Upload className="size-3.5 mr-2" />
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
<CodeMirror
value={sshKey}
onChange={(value) => setSshKey(value)}
placeholder={t("placeholders.pastePrivateKey")}
theme={oneDark}
className="border border-border text-xs"
minHeight="160px"
maxHeight="260px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
}),
]}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.keyPassword")} ({t("common.optional")})
</Label>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
value={keyPassword}
onChange={(e) => setKeyPassword(e.target.value)}
className="rounded-none bg-muted/50 border-border text-xs"
/>
<p className="text-[10px] text-muted-foreground">
{t("auth.sshKeyPasswordDescription")}
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.password")} ({t("common.optional")})
</Label>
<PasswordInput
placeholder={t("placeholders.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-none bg-muted/50 border-border text-xs"
/>
</div>
</TabsContent>
</Tabs>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="outline"
disabled={!canSubmit() || loading}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.connecting") : t("common.connect")}
</Button>
</div>
</form>
</div>
</div>
<div className="flex justify-end gap-2 pt-3">
<button
type="button"
onClick={onCancel}
disabled={loading}
className={PROMPT_BUTTON}
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={!canSubmit() || loading}
className={PROMPT_PRIMARY_BUTTON}
>
{loading ? t("common.connecting") : t("common.connect")}
</button>
</div>
</form>
</PanePrompt>
);
}
+73 -92
View File
@@ -1,6 +1,10 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PanePrompt } from "@/components/surface/surface-scope";
import {
PROMPT_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "@/components/surface/prompt-styles";
import { Shield, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MFAPromptMode } from "@termix/plugin-sdk/frontend";
@@ -17,6 +21,9 @@ interface TOTPDialogProps {
backgroundColor?: string;
}
const CODE_INPUT =
"rounded-none bg-muted/50 border-border text-center text-sm tracking-widest";
export function TOTPDialog({
isOpen,
prompt,
@@ -68,99 +75,73 @@ export function TOTPDialog({
const label = prompt || (isTotp ? t("sshAuth.totpCodeLabel") : undefined);
const cancel = (
<button type="button" onClick={onCancel} className={PROMPT_BUTTON}>
{t("common.cancel")}
</button>
);
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{title}
</h3>
</div>
{label && (
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
{label}
<PanePrompt
open
layer="connection"
backgroundColor={backgroundColor}
icon={<Shield className="size-4" />}
title={title}
description={label}
>
{showWaiting ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 py-1">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{t("sshAuth.mfaWaitingApproval")}
</p>
)}
</div>
{showWaiting ? (
<div className="p-4 flex flex-col gap-4">
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{t("sshAuth.mfaWaitingApproval")}
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-4">
{isPush ? null : isMenu ? (
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
placeholder={t("sshAuth.mfaMenuPlaceholder")}
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
/>
) : allowsPushKeyword ? (
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
placeholder={t("sshAuth.mfaCodeOrPushPlaceholder")}
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
/>
) : (
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
// Some Secure Auth codes can be longer than 6 digits, so allow up to 8 digits here.
maxLength={8}
pattern="[0-9]*"
inputMode="numeric"
placeholder="000000"
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{isPush ? t("sshAuth.mfaSendRequest") : t("sshAuth.totpVerify")}
</Button>
</div>
</form>
)}
</div>
</div>
<div className="flex justify-end gap-2">{cancel}</div>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{isPush ? null : isMenu ? (
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
placeholder={t("sshAuth.mfaMenuPlaceholder")}
className={CODE_INPUT}
/>
) : allowsPushKeyword ? (
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
placeholder={t("sshAuth.mfaCodeOrPushPlaceholder")}
className={CODE_INPUT}
/>
) : (
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
// Some Secure Auth codes can be longer than 6 digits, so allow up to 8 digits here.
maxLength={8}
pattern="[0-9]*"
inputMode="numeric"
placeholder="000000"
className={CODE_INPUT}
/>
)}
<div className="flex justify-end gap-2">
{cancel}
<button type="submit" className={PROMPT_PRIMARY_BUTTON}>
{isPush ? t("sshAuth.mfaSendRequest") : t("sshAuth.totpVerify")}
</button>
</div>
</form>
)}
</PanePrompt>
);
}
+4 -6
View File
@@ -114,9 +114,9 @@ describe("Auth local desktop flow", () => {
renderAuth();
await waitFor(() => {
expect(screen.getByText("Local desktop signed out")).toBeTruthy();
expect(screen.getByText("auth.localDesktopSignedOut")).toBeTruthy();
});
expect(screen.getByText("Continue with local desktop")).toBeTruthy();
expect(screen.getByText("auth.continueLocalDesktop")).toBeTruthy();
expect(screen.queryByText("common.register")).toBeNull();
expect(screen.queryByLabelText("common.username")).toBeNull();
expect(screen.queryByText("auth.forgotPassword")).toBeNull();
@@ -134,11 +134,9 @@ describe("Auth local desktop flow", () => {
renderAuth();
await waitFor(() => {
expect(
screen.getByText("Local desktop session unavailable"),
).toBeTruthy();
expect(screen.getByText("auth.localDesktopUnavailable")).toBeTruthy();
});
expect(screen.getByText("Retry local desktop session")).toBeTruthy();
expect(screen.getByText("auth.retryLocalDesktop")).toBeTruthy();
expect(screen.queryByText("common.register")).toBeNull();
expect(screen.queryByLabelText("common.username")).toBeNull();
expect(mainAxios.requestDesktopAutoSession).toHaveBeenCalled();
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more