mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: port the demo redesign with Manage and Settings tabs
This commit is contained in:
1 parent
f8151cb885
commit
35c954b79a
128 files changed
+13262
-8143
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+21
-54
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
|
||||
|
||||
+474
-1234
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+873
-809
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user