mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15: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
|
# Helm templates contain Go template syntax, not plain YAML
|
||||||
charts/*/templates/
|
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",
|
"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",
|
"prepare": "husky || true",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"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 .",
|
"lint:fix": "eslint --fix .",
|
||||||
"type-check": "npm run build:sdk && tsc -b --force",
|
"type-check": "npm run build:sdk && tsc -b --force",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -210,6 +210,21 @@ export interface ShellApi {
|
|||||||
openRailView: (id: string) => void;
|
openRailView: (id: string) => void;
|
||||||
/** Closes a rail view wherever it is shown. */
|
/** Closes a rail view wherever it is shown. */
|
||||||
closeRailView: (id: string) => void;
|
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<
|
export interface TabsApi extends Pick<
|
||||||
@@ -260,6 +275,11 @@ export interface RailItemContribution {
|
|||||||
/** Desktop app only. */
|
/** Desktop app only. */
|
||||||
electronOnly?: boolean;
|
electronOnly?: boolean;
|
||||||
separatorAfter?: 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 without being unregistered, e.g. while a feature is switched off. */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -293,8 +313,11 @@ export interface PanelProps {
|
|||||||
/** Whether the panel is the one currently shown. */
|
/** Whether the panel is the one currently shown. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
shell: ShellApi;
|
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. */
|
/** Type of the focused tab, if any. */
|
||||||
activeTabType?: string;
|
activeTabType?: string;
|
||||||
/** Where the panel is rendered. */
|
/** Where the panel is rendered. */
|
||||||
@@ -323,6 +346,8 @@ export interface TabHandle {
|
|||||||
/** Repaint only. The shell calls it every time the tab becomes active. */
|
/** Repaint only. The shell calls it every time the tab becomes active. */
|
||||||
refresh?: () => void;
|
refresh?: () => void;
|
||||||
notifyResize?: () => void;
|
notifyResize?: () => void;
|
||||||
|
/** Asked before the tab closes; resolve false to keep it open. */
|
||||||
|
confirmClose?: () => boolean | Promise<boolean>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +654,11 @@ export interface DashboardCardContribution {
|
|||||||
defaultHeight?: number;
|
defaultHeight?: number;
|
||||||
/** Which column a preset places this card in by default. Defaults to "main". */
|
/** Which column a preset places this card in by default. Defaults to "main". */
|
||||||
defaultPanel?: "main" | "side";
|
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>;
|
component: ComponentType<DashboardCardProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,6 +939,12 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
options?: { origin?: unknown },
|
options?: { origin?: unknown },
|
||||||
) => Promise<PluginWsTarget | null>;
|
) => Promise<PluginWsTarget | null>;
|
||||||
tabs: TabsApi;
|
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. */
|
/** The desktop app, when the frontend runs in it. */
|
||||||
desktop: DesktopApi;
|
desktop: DesktopApi;
|
||||||
/**
|
/**
|
||||||
@@ -1333,6 +1369,31 @@ export function usePluginUiPreferences<
|
|||||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
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 {
|
export function useTabs(): TabsApi {
|
||||||
return requireHost().useTabs();
|
return requireHost().useTabs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
|||||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => 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
|
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
|
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||||
BarSeriesItem (type): BarSeriesItem
|
BarSeriesItem (type): BarSeriesItem
|
||||||
@@ -27,17 +28,25 @@ CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttribu
|
|||||||
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
|
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
|
||||||
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => 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
|
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => 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
|
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
|
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
|
||||||
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
|
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
|
||||||
ConnectionOrigin (type): ConnectionOrigin
|
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
|
ConnectionStage (type): ConnectionStage
|
||||||
ConnectionStatus (type): ConnectionStatus
|
ConnectionStatus (type): ConnectionStatus
|
||||||
|
ConnectionUnavailable (type): ConnectionUnavailable
|
||||||
CustomKeybinding (type): CustomKeybinding
|
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
|
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => 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
|
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||||
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => 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
|
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||||
FOLDER_COLORS (value): string[]
|
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
|
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
|
FullScreenAppPhase (type): FullScreenAppPhase
|
||||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||||
GridCardCatalogEntry (type): GridCardCatalogEntry
|
GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||||
GridColSpan (type): GridColSpan
|
GridColSpan (type): GridColSpan
|
||||||
|
GridColumns (type): GridColumns
|
||||||
GridLayout (type): GridLayout
|
GridLayout (type): GridLayout
|
||||||
GridSlot (type): GridSlot
|
GridSlot (type): GridSlot
|
||||||
|
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
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
|
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||||
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||||
@@ -93,8 +109,19 @@ MFAPromptMode (type): MFAPromptMode
|
|||||||
ManagerCardError (type): ManagerCardError
|
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
|
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
|
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
|
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
|
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
|
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
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
|
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||||
RadialGaugeProps (type): RadialGaugeProps
|
RadialGaugeProps (type): RadialGaugeProps
|
||||||
RecentActivityItem (type): RecentActivityItem
|
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
|
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & 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
|
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
|
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
|
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
|
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
|
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SelectLabel (value): ({ className, ...props }: SelectLabelProps & 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
|
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
|
||||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => 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
|
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => 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
|
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
|
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||||
SparklineProps (type): SparklineProps
|
SparklineProps (type): SparklineProps
|
||||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
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
|
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
|
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>>
|
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||||
TextareaProps (type): TextareaProps
|
TextareaProps (type): TextareaProps
|
||||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||||
@@ -146,7 +185,9 @@ TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipC
|
|||||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
UptimeInfo (type): UptimeInfo
|
UptimeInfo (type): UptimeInfo
|
||||||
|
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||||
VersionInfo (type): VersionInfo
|
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
|
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
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>
|
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
|
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
|
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
|
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
|
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||||
useAppTheme (value): () => ThemeProviderState
|
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
|
useConnectionLog (value): () => ConnectionLogContextType
|
||||||
useIsDefaultsEditor (value): () => boolean
|
useIsDefaultsEditor (value): () => boolean
|
||||||
useIsMobile (value): () => boolean
|
useIsMobile (value): () => boolean
|
||||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||||
|
useSurfaceKind (value): () => SurfaceKind
|
||||||
useTabs (value): () => TabContextType
|
useTabs (value): () => TabContextType
|
||||||
useTabsSafe (value): () => TabContextType
|
useTabsSafe (value): () => TabContextType
|
||||||
@@ -34,6 +34,7 @@ const ALLOWED = [
|
|||||||
"AlertDialogTitle",
|
"AlertDialogTitle",
|
||||||
"AlertDialogTrigger",
|
"AlertDialogTrigger",
|
||||||
"AlertTitle",
|
"AlertTitle",
|
||||||
|
"BackButton",
|
||||||
"Badge",
|
"Badge",
|
||||||
"BarSeries",
|
"BarSeries",
|
||||||
"BarSeriesItem",
|
"BarSeriesItem",
|
||||||
@@ -48,17 +49,25 @@ const ALLOWED = [
|
|||||||
"CardFooter",
|
"CardFooter",
|
||||||
"CardGridCanvas",
|
"CardGridCanvas",
|
||||||
"CardHeader",
|
"CardHeader",
|
||||||
|
"CardMasonry",
|
||||||
"CardTitle",
|
"CardTitle",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
"ColumnCountStepper",
|
"ColumnCountStepper",
|
||||||
"ComponentSlot",
|
"ComponentSlot",
|
||||||
|
"ConfirmOptions",
|
||||||
|
"ConnectionGate",
|
||||||
|
"ConnectionGateController",
|
||||||
|
"ConnectionGateProps",
|
||||||
"ConnectionLogPanel",
|
"ConnectionLogPanel",
|
||||||
"ConnectionLogProvider",
|
"ConnectionLogProvider",
|
||||||
"ConnectionOrigin",
|
"ConnectionOrigin",
|
||||||
"ConnectionScreen",
|
"ConnectionScreen",
|
||||||
"ConnectionStage",
|
"ConnectionStage",
|
||||||
"ConnectionStatus",
|
"ConnectionStatus",
|
||||||
|
"ConnectionUnavailable",
|
||||||
"CustomKeybinding",
|
"CustomKeybinding",
|
||||||
|
"DataColumn",
|
||||||
|
"DataView",
|
||||||
"Dialog",
|
"Dialog",
|
||||||
"DialogClose",
|
"DialogClose",
|
||||||
"DialogContent",
|
"DialogContent",
|
||||||
@@ -84,20 +93,27 @@ const ALLOWED = [
|
|||||||
"DropdownMenuSubContent",
|
"DropdownMenuSubContent",
|
||||||
"DropdownMenuSubTrigger",
|
"DropdownMenuSubTrigger",
|
||||||
"DropdownMenuTrigger",
|
"DropdownMenuTrigger",
|
||||||
|
"EditingWidth",
|
||||||
"EmptyState",
|
"EmptyState",
|
||||||
"FOLDER_COLORS",
|
"FOLDER_COLORS",
|
||||||
|
"Facts",
|
||||||
"FakeSwitch",
|
"FakeSwitch",
|
||||||
|
"Field",
|
||||||
|
"FieldPair",
|
||||||
"FullScreenAppPhase",
|
"FullScreenAppPhase",
|
||||||
"FullScreenAppWrapper",
|
"FullScreenAppWrapper",
|
||||||
"GridCardCatalogEntry",
|
"GridCardCatalogEntry",
|
||||||
"GridColSpan",
|
"GridColSpan",
|
||||||
|
"GridColumns",
|
||||||
"GridLayout",
|
"GridLayout",
|
||||||
"GridSlot",
|
"GridSlot",
|
||||||
|
"GroupHeading",
|
||||||
"HostDefaultBadge",
|
"HostDefaultBadge",
|
||||||
"HostDefaultField",
|
"HostDefaultField",
|
||||||
"HostFeatureFields",
|
"HostFeatureFields",
|
||||||
"HostFeatureFieldsProps",
|
"HostFeatureFieldsProps",
|
||||||
"HostKeyVerificationDialog",
|
"HostKeyVerificationDialog",
|
||||||
|
"InlineView",
|
||||||
"Input",
|
"Input",
|
||||||
"Kbd",
|
"Kbd",
|
||||||
"KbdKey",
|
"KbdKey",
|
||||||
@@ -114,8 +130,19 @@ const ALLOWED = [
|
|||||||
"ManagerCardError",
|
"ManagerCardError",
|
||||||
"ManagerCardShell",
|
"ManagerCardShell",
|
||||||
"ManagerSearch",
|
"ManagerSearch",
|
||||||
|
"Meter",
|
||||||
"MetricCard",
|
"MetricCard",
|
||||||
"MiniStat",
|
"MiniStat",
|
||||||
|
"NumberField",
|
||||||
|
"PANEL",
|
||||||
|
"PanePrompt",
|
||||||
|
"PROMPT_BUTTON",
|
||||||
|
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||||
|
"PROMPT_PRIMARY_BUTTON",
|
||||||
|
"PanelDensity",
|
||||||
|
"PanelSearch",
|
||||||
|
"PanelShell",
|
||||||
|
"PanelViewMode",
|
||||||
"PassphraseDialog",
|
"PassphraseDialog",
|
||||||
"PasswordInput",
|
"PasswordInput",
|
||||||
"PluginComponent",
|
"PluginComponent",
|
||||||
@@ -127,14 +154,18 @@ const ALLOWED = [
|
|||||||
"RadialGauge",
|
"RadialGauge",
|
||||||
"RadialGaugeProps",
|
"RadialGaugeProps",
|
||||||
"RecentActivityItem",
|
"RecentActivityItem",
|
||||||
|
"Repeater",
|
||||||
"RobustClipboardProvider",
|
"RobustClipboardProvider",
|
||||||
"SSHAuthDialog",
|
"SSHAuthDialog",
|
||||||
"ScrollArea",
|
"ScrollArea",
|
||||||
"ScrollBar",
|
"ScrollBar",
|
||||||
|
"SecretField",
|
||||||
"SectionCard",
|
"SectionCard",
|
||||||
|
"Segmented",
|
||||||
"Select",
|
"Select",
|
||||||
"Select2",
|
"Select2",
|
||||||
"SelectContent",
|
"SelectContent",
|
||||||
|
"SelectField",
|
||||||
"SelectGroup",
|
"SelectGroup",
|
||||||
"SelectItem",
|
"SelectItem",
|
||||||
"SelectLabel",
|
"SelectLabel",
|
||||||
@@ -158,8 +189,16 @@ const ALLOWED = [
|
|||||||
"Sparkline",
|
"Sparkline",
|
||||||
"SparklineProps",
|
"SparklineProps",
|
||||||
"StatRow",
|
"StatRow",
|
||||||
|
"SurfaceKind",
|
||||||
|
"SurfaceScope",
|
||||||
"Switch",
|
"Switch",
|
||||||
|
"SwitchRow",
|
||||||
"TOTPDialog",
|
"TOTPDialog",
|
||||||
|
"TabStrip",
|
||||||
|
"TabStripItem",
|
||||||
|
"TagInput",
|
||||||
|
"TextAreaField",
|
||||||
|
"TextField",
|
||||||
"Textarea",
|
"Textarea",
|
||||||
"TextareaProps",
|
"TextareaProps",
|
||||||
"Tooltip",
|
"Tooltip",
|
||||||
@@ -167,7 +206,9 @@ const ALLOWED = [
|
|||||||
"TooltipProvider",
|
"TooltipProvider",
|
||||||
"TooltipTrigger",
|
"TooltipTrigger",
|
||||||
"UptimeInfo",
|
"UptimeInfo",
|
||||||
|
"UsagePair",
|
||||||
"VersionInfo",
|
"VersionInfo",
|
||||||
|
"ViewToggle",
|
||||||
"WebSocketConnectionTarget",
|
"WebSocketConnectionTarget",
|
||||||
"WidgetTitle",
|
"WidgetTitle",
|
||||||
"buildOriginWsUrl",
|
"buildOriginWsUrl",
|
||||||
@@ -198,13 +239,17 @@ const ALLOWED = [
|
|||||||
"runAdaptiveBackgroundTask",
|
"runAdaptiveBackgroundTask",
|
||||||
"runAdaptivePolling",
|
"runAdaptivePolling",
|
||||||
"runVisibleInterval",
|
"runVisibleInterval",
|
||||||
|
"usageBarColor",
|
||||||
|
"usageColor",
|
||||||
"useAdaptivePolling",
|
"useAdaptivePolling",
|
||||||
"useAppTheme",
|
"useAppTheme",
|
||||||
"useConfirmation",
|
"useConfirm",
|
||||||
|
"useConnectionGate",
|
||||||
"useConnectionLog",
|
"useConnectionLog",
|
||||||
"useIsDefaultsEditor",
|
"useIsDefaultsEditor",
|
||||||
"useIsMobile",
|
"useIsMobile",
|
||||||
"useOptionalConnectionLog",
|
"useOptionalConnectionLog",
|
||||||
|
"useSurfaceKind",
|
||||||
"useTabs",
|
"useTabs",
|
||||||
"useTabsSafe",
|
"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 =
|
type KnownTabType =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "host-manager"
|
| "host-manager"
|
||||||
|
| "settings"
|
||||||
| "user-profile"
|
| "user-profile"
|
||||||
| "admin-settings"
|
| "admin-settings"
|
||||||
| "split-screen";
|
| "split-screen";
|
||||||
@@ -189,6 +190,8 @@ export type Tab = {
|
|||||||
refresh?: () => void;
|
refresh?: () => void;
|
||||||
getApplicationCursorKeysMode?: () => boolean;
|
getApplicationCursorKeysMode?: () => boolean;
|
||||||
focus?: () => void;
|
focus?: () => void;
|
||||||
|
/** Asked before the tab closes; resolve false to keep it open. */
|
||||||
|
confirmClose?: () => boolean | Promise<boolean>;
|
||||||
} | null>;
|
} | 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 { startPreLoginPlugins } from "@/plugin-host/loader";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
import { Checkbox } from "@/components/checkbox";
|
||||||
import { useBranding } from "@/contexts/BrandingContext";
|
import { useBranding } from "@/contexts/BrandingContext";
|
||||||
|
import { BrandPanel, CompactBrand } from "./auth-bits";
|
||||||
import {
|
import {
|
||||||
changeAppLanguage,
|
changeAppLanguage,
|
||||||
normalizeLanguageCode,
|
normalizeLanguageCode,
|
||||||
@@ -1228,39 +1229,26 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
|
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
|
||||||
<div className="flex flex-1 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">
|
<BrandPanel
|
||||||
<div
|
logo={branding.logo}
|
||||||
className="absolute inset-0"
|
appName={branding.appName}
|
||||||
style={{
|
tagline={branding.tagline || t("auth.tagline")}
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
|
<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-5">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-xl font-bold">
|
<h1 className="text-xl font-bold">
|
||||||
{desktopManualLogoutActive
|
{desktopManualLogoutActive
|
||||||
? "Local desktop signed out"
|
? t("auth.localDesktopSignedOut")
|
||||||
: "Local desktop session unavailable"}
|
: t("auth.localDesktopUnavailable")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
{desktopManualLogoutActive
|
{desktopManualLogoutActive
|
||||||
? "You signed out of the local desktop session. Continue locally to use this device's embedded Termix server again."
|
? t("auth.localDesktopSignedOutDesc")
|
||||||
: "Termix could not create a local desktop session. Retry the embedded local session instead of registering a new account."}
|
: t("auth.localDesktopUnavailableDesc")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -1270,8 +1258,8 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
onClick={continueLocalDesktopSession}
|
onClick={continueLocalDesktopSession}
|
||||||
>
|
>
|
||||||
{desktopManualLogoutActive
|
{desktopManualLogoutActive
|
||||||
? "Continue with local desktop"
|
? t("auth.continueLocalDesktop")
|
||||||
: "Retry local desktop session"}
|
: t("auth.retryLocalDesktop")}
|
||||||
</Button>
|
</Button>
|
||||||
<Separator />
|
<Separator />
|
||||||
<LanguageRow
|
<LanguageRow
|
||||||
@@ -1308,37 +1296,16 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
|
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left decorative panel */}
|
<BrandPanel
|
||||||
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
|
logo={branding.logo}
|
||||||
<div
|
appName={branding.appName}
|
||||||
className="absolute inset-0"
|
tagline={branding.tagline || t("auth.tagline")}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* Right panel */}
|
||||||
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
|
<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 */}
|
{/* TOTP view */}
|
||||||
{view === "second-factor" && (
|
{view === "second-factor" && (
|
||||||
<div className="flex flex-col gap-5">
|
<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 { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { copyToClipboard } from "@/lib/clipboard.ts";
|
import { copyToClipboard } from "@/lib/clipboard.ts";
|
||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { cn } from "@/lib/utils.ts";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import {
|
import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Copy,
|
|
||||||
Info,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertTriangle,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ConnectionLogPanelProps {
|
interface ConnectionLogPanelProps {
|
||||||
@@ -23,8 +15,23 @@ interface ConnectionLogPanelProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLLAPSED_HEIGHT = "h-[140px]";
|
const COLLAPSED_HEIGHT = "h-[136px]";
|
||||||
const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]";
|
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({
|
export function ConnectionLogPanel({
|
||||||
isConnecting,
|
isConnecting,
|
||||||
@@ -39,6 +46,7 @@ export function ConnectionLogPanel({
|
|||||||
connectionLog ?? {};
|
connectionLog ?? {};
|
||||||
const lastLogRef = useRef<HTMLDivElement>(null);
|
const lastLogRef = useRef<HTMLDivElement>(null);
|
||||||
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
|
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasConnectionError && setIsExpanded) {
|
if (hasConnectionError && setIsExpanded) {
|
||||||
@@ -56,7 +64,7 @@ export function ConnectionLogPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastLogRef.current) {
|
if (lastLogRef.current) {
|
||||||
lastLogRef.current.scrollIntoView({ block: "end" });
|
lastLogRef.current.scrollIntoView?.({ block: "end" });
|
||||||
}
|
}
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
@@ -88,113 +96,101 @@ export function ConnectionLogPanel({
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const ok = await copyToClipboard(logsText);
|
const ok = await copyToClipboard(logsText);
|
||||||
if (ok) toast.success(t("sshAuth.connectionLogCopied"));
|
if (ok) {
|
||||||
else toast.error(t("sshAuth.connectionLogCopyFailed"));
|
setCopied(true);
|
||||||
};
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
toast.success(t("sshAuth.connectionLogCopied"));
|
||||||
const getIcon = (type: string) => {
|
} else {
|
||||||
switch (type) {
|
toast.error(t("sshAuth.connectionLogCopyFailed"));
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
position === "bottom"
|
||||||
? "border-t border-border"
|
? "border-t border-border"
|
||||||
: "border-b border-border",
|
: "border-b border-border",
|
||||||
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
|
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
|
||||||
"transition-[height] duration-150",
|
"transition-[height] duration-200",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 shrink-0 border-b border-border/60">
|
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-2 pr-1.5">
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
|
||||||
onClick={handleToggle}
|
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 ? (
|
{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">
|
<span className="text-[10px] font-semibold uppercase tracking-widest">
|
||||||
{t("sshAuth.connectionLogTitle")} ({logs.length})
|
{t("sshAuth.connectionLogTitle")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{logs.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
{logs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={copyLogsToClipboard}
|
onClick={copyLogsToClipboard}
|
||||||
title={t("sshAuth.connectionLogCopy")}
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<div className="px-3 py-2">
|
{logs.length === 0 ? (
|
||||||
{logs.length === 0 ? (
|
<p className="px-3 py-4 text-xs text-muted-foreground">
|
||||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
{isConnecting
|
||||||
{isConnecting
|
? t("sshAuth.connectionLogWaiting")
|
||||||
? t("sshAuth.connectionLogWaiting")
|
: t("sshAuth.connectionLogEmpty")}
|
||||||
: t("sshAuth.connectionLogEmpty")}
|
</p>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="px-2 py-1.5 font-mono text-[11px] leading-[1.7]">
|
||||||
<div className="space-y-1 font-mono text-xs">
|
{logs.map((log, index) => (
|
||||||
{logs.map((log, index) => (
|
<div
|
||||||
<div
|
key={log.id}
|
||||||
key={log.id}
|
ref={index === logs.length - 1 ? lastLogRef : null}
|
||||||
ref={index === logs.length - 1 ? lastLogRef : null}
|
className="flex items-baseline gap-2 px-1 hover:bg-foreground/[0.03]"
|
||||||
className="flex items-start gap-2"
|
>
|
||||||
|
<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.message}
|
||||||
{log.timestamp.toLocaleTimeString()}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
{getIcon(log.type)}
|
))}
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
)}
|
||||||
"flex-1 min-w-0 break-all whitespace-pre-wrap",
|
|
||||||
getTextColor(log.type),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{log.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,29 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils.ts";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import { Button } from "@/components/button.tsx";
|
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 { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
||||||
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
|
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;
|
status: ConnectionStatus;
|
||||||
|
/** Headline while connecting. */
|
||||||
message?: string;
|
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;
|
backgroundColor?: string;
|
||||||
attempt?: number;
|
attempt?: number;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
@@ -22,9 +38,14 @@ interface ConnectionScreenProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one screen every connection shows until it is up. */
|
||||||
export function ConnectionScreen({
|
export function ConnectionScreen({
|
||||||
status,
|
status,
|
||||||
message,
|
message,
|
||||||
|
detail,
|
||||||
|
errorMessage,
|
||||||
|
errorDetail,
|
||||||
|
unavailable,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
attempt = 0,
|
attempt = 0,
|
||||||
maxAttempts = 0,
|
maxAttempts = 0,
|
||||||
@@ -39,83 +60,165 @@ export function ConnectionScreen({
|
|||||||
}: ConnectionScreenProps) {
|
}: ConnectionScreenProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (status === "connected" && !emptyState) {
|
if (status === "connected" && !emptyState && !unavailable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSpinner = status === "connecting";
|
const connecting = status === "connecting" && !unavailable;
|
||||||
const showRetryButton = status === "disconnected" && !!onManualRetry;
|
const failed = status === "error" || status === "disconnected";
|
||||||
const showLog = status !== "connected";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
data-status={status}
|
data-status={unavailable ? "unavailable" : status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
|
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
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 ? (
|
||||||
emptyState
|
emptyState
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
<div className="flex max-w-md flex-col items-center gap-3.5 text-center">
|
||||||
{showSpinner && (
|
<ConnectionMark
|
||||||
<Loader2
|
state={
|
||||||
className="size-8 animate-spin text-accent-brand"
|
unavailable
|
||||||
aria-hidden="true"
|
? "unavailable"
|
||||||
/>
|
: connecting || retrying
|
||||||
)}
|
? "busy"
|
||||||
{message && (
|
: "failed"
|
||||||
<p className="text-sm text-foreground-secondary font-medium">
|
}
|
||||||
{message}
|
/>
|
||||||
</p>
|
|
||||||
)}
|
<div className="space-y-1.5">
|
||||||
{attempt > 0 && status !== "disconnected" && (
|
{headline && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm font-semibold tracking-tight text-foreground">
|
||||||
{nextRetryInMs && nextRetryInMs > 0
|
{headline}
|
||||||
? 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")}
|
|
||||||
</p>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="sm"
|
||||||
onClick={onManualRetry}
|
onClick={onManualRetry}
|
||||||
className="gap-2 font-semibold"
|
className="gap-2 font-semibold"
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
{retryLabel || t("connection.reconnect")}
|
{retrying
|
||||||
|
? t("connection.retryNow")
|
||||||
|
: retryLabel || t("connection.reconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
{extraActions}
|
)}
|
||||||
</div>
|
{unavailable?.action}
|
||||||
|
{(failed || unavailable) && extraActions}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showLog && !emptyState && (
|
{showLog && (
|
||||||
<ConnectionLogPanel
|
<ConnectionLogPanel
|
||||||
isConnecting={status === "connecting"}
|
isConnecting={connecting}
|
||||||
isConnected={false}
|
isConnected={false}
|
||||||
hasConnectionError={status === "error" || status === "disconnected"}
|
hasConnectionError={failed || !!unavailable}
|
||||||
position={logPosition}
|
position={logPosition}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{error ? (
|
||||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
<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>
|
<span className="text-xs text-muted-foreground">{error.message}</span>
|
||||||
{error.code === "SUDO_REQUIRED" && (
|
{error.code === "SUDO_REQUIRED" && (
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<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,
|
action,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
rowId,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Lets settings search jump to this card. */
|
||||||
|
rowId?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-setting-row={rowId}
|
||||||
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
|
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">
|
<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-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}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{action && <div className="ml-auto">{action}</div>}
|
{action && <div className="ml-auto">{action}</div>}
|
||||||
@@ -36,6 +40,7 @@ export function SettingRow({
|
|||||||
badge,
|
badge,
|
||||||
description,
|
description,
|
||||||
defaultKey,
|
defaultKey,
|
||||||
|
rowId,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -46,18 +51,23 @@ export function SettingRow({
|
|||||||
* field key, or "core.<key>". Shows where the value comes from.
|
* field key, or "core.<key>". Shows where the value comes from.
|
||||||
*/
|
*/
|
||||||
defaultKey?: string;
|
defaultKey?: string;
|
||||||
|
/** Lets settings search jump to this row. */
|
||||||
|
rowId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DefaultsOnly settingKey={defaultKey ?? ""}>
|
<DefaultsOnly settingKey={defaultKey ?? ""}>
|
||||||
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
||||||
padding and divider would double up. */}
|
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 flex-col gap-0.5 min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="text-sm font-medium leading-snug">{label}</span>
|
<span className="text-sm font-medium leading-snug">{label}</span>
|
||||||
{badge && (
|
{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}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -90,13 +100,16 @@ export function FakeSwitch({
|
|||||||
const on = checked !== undefined ? checked : internalOn;
|
const on = checked !== undefined ? checked : internalOn;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = !on;
|
const next = !on;
|
||||||
if (checked === undefined) setInternalOn(next);
|
if (checked === undefined) setInternalOn(next);
|
||||||
onChange?.(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
|
<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"}`}
|
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"
|
status === "beta"
|
||||||
? "bg-blue-500/20 text-blue-400"
|
? "bg-blue-500/20 text-blue-400"
|
||||||
: status === "requires_update"
|
: status === "requires_update"
|
||||||
? "bg-yellow-500/20 text-yellow-400"
|
? "bg-warning/20 text-warning"
|
||||||
: "bg-accent-brand/20 text-accent-brand"
|
: "bg-accent-brand/20 text-accent-brand"
|
||||||
}${className ? ` ${className}` : ""}`;
|
}${className ? ` ${className}` : ""}`;
|
||||||
|
|
||||||
|
|||||||
+474
-1234
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,8 @@ export interface RegisteredDashboardCard {
|
|||||||
defaultHeight?: number;
|
defaultHeight?: number;
|
||||||
/** Which column a preset places this card in by default. Defaults to "main". */
|
/** Which column a preset places this card in by default. Defaults to "main". */
|
||||||
defaultPanel?: "main" | "side";
|
defaultPanel?: "main" | "side";
|
||||||
|
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */
|
||||||
|
frame?: "framed" | "bare";
|
||||||
component: ComponentType<DashboardCardRenderProps>;
|
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";
|
const online = status === true || status === "online";
|
||||||
if (loading || status === "unknown") {
|
if (loading || status === "unknown") {
|
||||||
if (scheme === "status") {
|
if (scheme === "status") {
|
||||||
if (variant === "dot") return "bg-yellow-400 animate-pulse";
|
if (variant === "dot") return "bg-warning animate-pulse";
|
||||||
if (variant === "stripe") return "bg-yellow-400/40 animate-pulse";
|
if (variant === "stripe") return "bg-warning/40 animate-pulse";
|
||||||
return "border-yellow-400/40 text-yellow-400 bg-yellow-400/10 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 === "dot") return "bg-muted-foreground/40 animate-pulse";
|
||||||
if (variant === "stripe") return "bg-muted-foreground/20 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-accent-foreground: oklch(0.2115 0.0042 128.68);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 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 {
|
.dark {
|
||||||
@@ -124,6 +132,8 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 12%);
|
--sidebar-border: oklch(1 0 0 / 12%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
--warning: oklch(0.852 0.156 91.94);
|
||||||
|
--terminal-bg: #0c0d0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dracula ────────────────────────────────────────────────── */
|
/* ── Dracula ────────────────────────────────────────────────── */
|
||||||
@@ -162,6 +172,8 @@
|
|||||||
--sidebar-accent-foreground: #f8f8f2;
|
--sidebar-accent-foreground: #f8f8f2;
|
||||||
--sidebar-border: rgba(168, 179, 217, 0.18);
|
--sidebar-border: rgba(168, 179, 217, 0.18);
|
||||||
--sidebar-ring: #8be9fd;
|
--sidebar-ring: #8be9fd;
|
||||||
|
--warning: #f1fa8c;
|
||||||
|
--terminal-bg: #282a36;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Catppuccin Mocha ───────────────────────────────────────── */
|
/* ── Catppuccin Mocha ───────────────────────────────────────── */
|
||||||
@@ -200,6 +212,8 @@
|
|||||||
--sidebar-accent-foreground: #cdd6f4;
|
--sidebar-accent-foreground: #cdd6f4;
|
||||||
--sidebar-border: rgba(186, 194, 222, 0.18);
|
--sidebar-border: rgba(186, 194, 222, 0.18);
|
||||||
--sidebar-ring: #89b4fa;
|
--sidebar-ring: #89b4fa;
|
||||||
|
--warning: #f9e2af;
|
||||||
|
--terminal-bg: #1e1e2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Nord ───────────────────────────────────────────────────── */
|
/* ── Nord ───────────────────────────────────────────────────── */
|
||||||
@@ -238,6 +252,8 @@
|
|||||||
--sidebar-accent-foreground: #eceff4;
|
--sidebar-accent-foreground: #eceff4;
|
||||||
--sidebar-border: rgba(180, 192, 212, 0.18);
|
--sidebar-border: rgba(180, 192, 212, 0.18);
|
||||||
--sidebar-ring: #88c0d0;
|
--sidebar-ring: #88c0d0;
|
||||||
|
--warning: #ebcb8b;
|
||||||
|
--terminal-bg: #2e3440;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Solarized Dark ─────────────────────────────────────────── */
|
/* ── Solarized Dark ─────────────────────────────────────────── */
|
||||||
@@ -276,6 +292,8 @@
|
|||||||
--sidebar-accent-foreground: #d5dddd;
|
--sidebar-accent-foreground: #d5dddd;
|
||||||
--sidebar-border: rgba(159, 179, 179, 0.2);
|
--sidebar-border: rgba(159, 179, 179, 0.2);
|
||||||
--sidebar-ring: #268bd2;
|
--sidebar-ring: #268bd2;
|
||||||
|
--warning: #b58900;
|
||||||
|
--terminal-bg: #002b36;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tokyo Night ────────────────────────────────────────────── */
|
/* ── Tokyo Night ────────────────────────────────────────────── */
|
||||||
@@ -315,6 +333,8 @@
|
|||||||
--sidebar-accent-foreground: #c0caf5;
|
--sidebar-accent-foreground: #c0caf5;
|
||||||
--sidebar-border: rgba(139, 147, 189, 0.2);
|
--sidebar-border: rgba(139, 147, 189, 0.2);
|
||||||
--sidebar-ring: #7aa2f7;
|
--sidebar-ring: #7aa2f7;
|
||||||
|
--warning: #e0af68;
|
||||||
|
--terminal-bg: #1a1b26;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── One Dark ───────────────────────────────────────────────── */
|
/* ── One Dark ───────────────────────────────────────────────── */
|
||||||
@@ -353,6 +373,8 @@
|
|||||||
--sidebar-accent-foreground: #abb2bf;
|
--sidebar-accent-foreground: #abb2bf;
|
||||||
--sidebar-border: rgba(154, 163, 178, 0.18);
|
--sidebar-border: rgba(154, 163, 178, 0.18);
|
||||||
--sidebar-ring: #61afef;
|
--sidebar-ring: #61afef;
|
||||||
|
--warning: #e5c07b;
|
||||||
|
--terminal-bg: #282c34;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Gruvbox Dark ───────────────────────────────────────────── */
|
/* ── Gruvbox Dark ───────────────────────────────────────────── */
|
||||||
@@ -392,6 +414,8 @@
|
|||||||
--sidebar-accent-foreground: #ebdbb2;
|
--sidebar-accent-foreground: #ebdbb2;
|
||||||
--sidebar-border: rgba(196, 181, 157, 0.18);
|
--sidebar-border: rgba(196, 181, 157, 0.18);
|
||||||
--sidebar-ring: #fabd2f;
|
--sidebar-ring: #fabd2f;
|
||||||
|
--warning: #fabd2f;
|
||||||
|
--terminal-bg: #282828;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -784,3 +808,10 @@ html.fs-xl {
|
|||||||
transition-duration: 1ms !important;
|
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}})",
|
"retryingIn": "Retrying in {{seconds}}s ({{attempt}}/{{max}})",
|
||||||
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
|
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
|
||||||
"disconnected": "Unable to connect",
|
"disconnected": "Unable to connect",
|
||||||
"reconnect": "Reconnect"
|
"reconnect": "Reconnect",
|
||||||
|
"failed": "Could not connect",
|
||||||
|
"failedRetrying": "Connection failed, trying again",
|
||||||
|
"retryNow": "Retry now"
|
||||||
},
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
@@ -314,7 +317,21 @@
|
|||||||
"openSidebar": "Open sidebar",
|
"openSidebar": "Open sidebar",
|
||||||
"confirmCloseHost": "Close {{host}}?",
|
"confirmCloseHost": "Close {{host}}?",
|
||||||
"enterFullscreen": "Enter fullscreen (Ctrl+Shift+F)",
|
"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": "Hosts",
|
"hosts": "Hosts",
|
||||||
@@ -975,7 +992,9 @@
|
|||||||
"expiresIn": "Expires in {{duration}}",
|
"expiresIn": "Expires in {{duration}}",
|
||||||
"search": "Search connections...",
|
"search": "Search connections...",
|
||||||
"noSearchResults": "No connections match your search",
|
"noSearchResults": "No connections match your search",
|
||||||
"rename": "Rename session"
|
"rename": "Rename session",
|
||||||
|
"forgetConfirm": "End the background session for {{name}}?",
|
||||||
|
"forget": "End session"
|
||||||
},
|
},
|
||||||
"sshAuth": {
|
"sshAuth": {
|
||||||
"totpRequired": "Two-Factor Authentication Required",
|
"totpRequired": "Two-Factor Authentication Required",
|
||||||
@@ -1064,7 +1083,13 @@
|
|||||||
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
|
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
|
||||||
"serverAuthentication": "Server sign-in",
|
"serverAuthentication": "Server sign-in",
|
||||||
"minChars": "min. {{min}} characters",
|
"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": {
|
"hostKey": {
|
||||||
"verifyNewHost": "Verify SSH Host Key",
|
"verifyNewHost": "Verify SSH Host Key",
|
||||||
@@ -1205,7 +1230,19 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"viewServerDetails": "View server details",
|
"viewServerDetails": "View server details",
|
||||||
"dragToResizeColumns": "Drag to resize columns",
|
"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": {
|
"admin": {
|
||||||
"sectionGeneral": "General",
|
"sectionGeneral": "General",
|
||||||
@@ -1515,7 +1552,13 @@
|
|||||||
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting",
|
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting",
|
||||||
"hostTags": "Predefined host tags",
|
"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.",
|
"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": {
|
"newUi": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
@@ -1582,7 +1625,9 @@
|
|||||||
"actionOpenCommandPalette": "Open quick search",
|
"actionOpenCommandPalette": "Open quick search",
|
||||||
"actionReconnectSession": "Reconnect current session",
|
"actionReconnectSession": "Reconnect current session",
|
||||||
"unavailableAction": "{{type}} (not available)",
|
"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": {
|
"userProfile": {
|
||||||
"donateTitle": "Keep Termix alive",
|
"donateTitle": "Keep Termix alive",
|
||||||
@@ -1917,7 +1962,33 @@
|
|||||||
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
|
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
|
||||||
"pluginSettingsSaveFailed": "Failed to save settings",
|
"pluginSettingsSaveFailed": "Failed to save settings",
|
||||||
"pluginSettingsLoadFailed": "Failed to load 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": {
|
"managerCard": {
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
@@ -2091,5 +2162,54 @@
|
|||||||
"resetCore": "Connection and SSH to defaults",
|
"resetCore": "Connection and SSH to defaults",
|
||||||
"resetDone_one": "{{count}} host now follows its defaults.",
|
"resetDone_one": "{{count}} host now follows its defaults.",
|
||||||
"resetDone_other": "{{count}} hosts now follow their 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,
|
mobilePrimary: item.mobilePrimary,
|
||||||
electronOnly: item.electronOnly,
|
electronOnly: item.electronOnly,
|
||||||
separatorAfter: item.separatorAfter ?? true,
|
separatorAfter: item.separatorAfter ?? true,
|
||||||
|
group: item.group,
|
||||||
hidden: item.hidden,
|
hidden: item.hidden,
|
||||||
after: item.after,
|
after: item.after,
|
||||||
order: item.order,
|
order: item.order,
|
||||||
@@ -392,6 +393,7 @@ export function createPluginApp(
|
|||||||
titleKey: key(card.titleKey),
|
titleKey: key(card.titleKey),
|
||||||
defaultHeight: card.defaultHeight,
|
defaultHeight: card.defaultHeight,
|
||||||
defaultPanel: card.defaultPanel,
|
defaultPanel: card.defaultPanel,
|
||||||
|
frame: card.frame,
|
||||||
component: scoped(
|
component: scoped(
|
||||||
card.component as unknown as ComponentType<DashboardCardRenderProps>,
|
card.component as unknown as ComponentType<DashboardCardRenderProps>,
|
||||||
),
|
),
|
||||||
@@ -546,6 +548,8 @@ export function createPluginApp(
|
|||||||
onReady: (listener) => track(tabsApi.onReady(listener)),
|
onReady: (listener) => track(tabsApi.onReady(listener)),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirm: (options) => shell.confirm!(options),
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
available: isElectron(),
|
available: isElectron(),
|
||||||
remoteServerUrl,
|
remoteServerUrl,
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ export {
|
|||||||
ManagerSearch,
|
ManagerSearch,
|
||||||
type ManagerCardError,
|
type ManagerCardError,
|
||||||
} from "@/components/manager-card";
|
} from "@/components/manager-card";
|
||||||
export { useConfirmation } from "@/hooks/use-confirmation";
|
|
||||||
export { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
export { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||||
// Homepage widget pieces, for plugins that register a widget.
|
// Homepage widget pieces, for plugins that register a widget.
|
||||||
export { WidgetTitle } from "@/lib/widget-title";
|
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.
|
// A host that is never saved, for connecting to an address straight away.
|
||||||
export { createQuickConnectHost } from "@/sidebar/quick-connect-host";
|
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),
|
openHostEditor: (...args) => callbacks?.openHostEditor?.(...args),
|
||||||
saveQuickConnect: (...args) =>
|
saveQuickConnect: (...args) =>
|
||||||
callbacks?.saveQuickConnect?.(...args) ?? Promise.resolve(),
|
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 = {
|
export const tabsApi = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
import i18n from "@/i18n/i18n";
|
import i18n from "@/i18n/i18n";
|
||||||
import type { PluginSummary } from "@/api/plugins-api";
|
import type { PluginSummary } from "@/api/plugins-api";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||||
import { setPermissionsForTesting } from "@/hooks/use-permissions";
|
import { setPermissionsForTesting } from "@/hooks/use-permissions";
|
||||||
import {
|
import {
|
||||||
getTabType,
|
getTabType,
|
||||||
@@ -98,7 +99,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
|||||||
function wrap(element: ReactElement): HTMLElement {
|
function wrap(element: ReactElement): HTMLElement {
|
||||||
return render(
|
return render(
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="termix-test-theme">
|
<ThemeProvider defaultTheme="dark" storageKey="termix-test-theme">
|
||||||
{element}
|
<SurfaceScope className="h-full">{element}</SurfaceScope>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
).container;
|
).container;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function FeatureSettingsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureSettingsForm({
|
export function FeatureSettingsForm({
|
||||||
plugin,
|
plugin,
|
||||||
scope,
|
scope,
|
||||||
fields,
|
fields,
|
||||||
@@ -208,7 +208,7 @@ function FeatureSettingsForm({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 pt-2">
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
{!running && (
|
{!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", {
|
{t("settings.featureUnavailable", {
|
||||||
name: t(pluginKey(plugin.id, "plugin.name"), {
|
name: t(pluginKey(plugin.id, "plugin.name"), {
|
||||||
defaultValue: plugin.name,
|
defaultValue: plugin.name,
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function HostPluginSection({
|
|||||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||||
>
|
>
|
||||||
{!running && (
|
{!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 })}
|
{t("settings.featureUnavailable", { name: plugin.name })}
|
||||||
</div>
|
</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)}
|
{label(field.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
{error && (
|
{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")}
|
{t("common.error")}
|
||||||
</span>
|
</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 { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { InlineView, useSurfaceKind } from "@/components/surface/surface-scope";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertCircle, Loader2, X } from "lucide-react";
|
import { AlertCircle, Loader2, X } from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
@@ -222,6 +223,241 @@ export function LinkServerDialog({
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const wide = step === "signin";
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -235,220 +471,11 @@ export function LinkServerDialog({
|
|||||||
onInteractOutside={(event) => wide && event.preventDefault()}
|
onInteractOutside={(event) => wide && event.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader className={wide ? "sr-only" : undefined}>
|
<DialogHeader className={wide ? "sr-only" : undefined}>
|
||||||
<DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
{relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")}
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</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>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{step === "server" && (
|
{content}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -17,14 +18,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Switch } from "@/components/switch";
|
import { Switch } from "@/components/switch";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||||
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
|
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
|
||||||
import { notifySyncChanged } from "@/lib/linked-server";
|
import { notifySyncChanged } from "@/lib/linked-server";
|
||||||
@@ -565,45 +558,12 @@ export function SyncPanel() {
|
|||||||
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
|
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={unlinkOpen} onOpenChange={setUnlinkOpen}>
|
<InlineView
|
||||||
<DialogContent className="bg-card border border-border rounded-none sm:max-w-md">
|
open={unlinkOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setUnlinkOpen}
|
||||||
<DialogTitle>{t("sync.unlinkTitle")}</DialogTitle>
|
title={t("sync.unlinkTitle")}
|
||||||
<DialogDescription>{t("sync.unlinkDescription")}</DialogDescription>
|
footer={
|
||||||
</DialogHeader>
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
<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>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-none"
|
className="rounded-none"
|
||||||
@@ -626,9 +586,46 @@ export function SyncPanel() {
|
|||||||
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
|
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
|
||||||
{t("sync.unlink")}
|
{t("sync.unlink")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
}
|
||||||
</Dialog>
|
>
|
||||||
|
<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}
|
{linkDialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+339
-442
@@ -8,26 +8,26 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandSeparator,
|
CommandEmpty,
|
||||||
} from "@/components/command";
|
} from "@/components/command";
|
||||||
import {
|
import {
|
||||||
Server,
|
|
||||||
Settings,
|
|
||||||
Globe,
|
|
||||||
Plus,
|
|
||||||
MessagesSquare,
|
|
||||||
LifeBuoy,
|
|
||||||
Search,
|
|
||||||
User,
|
|
||||||
KeyRound,
|
|
||||||
Clock,
|
Clock,
|
||||||
Folder,
|
Folder,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
LifeBuoy,
|
||||||
|
MessagesSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
||||||
import type { Host, TabType, Tab } from "@/types/ui-types";
|
import type { Host, TabType, Tab } from "@/types/ui-types";
|
||||||
import { canEditHost } from "@/sidebar/host-permissions";
|
import { canEditHost } from "@/sidebar/host-permissions";
|
||||||
import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
|
import { useRailItems } from "@/sidebar/rail-items";
|
||||||
import {
|
import {
|
||||||
defaultConnectAction,
|
defaultConnectAction,
|
||||||
hostActionsFor,
|
hostActionsFor,
|
||||||
@@ -47,6 +47,10 @@ import { shell } from "@/plugin-host/shell-bridge";
|
|||||||
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
|
/** Opens settings, optionally at one section. */
|
||||||
|
onOpenSettings?: (section?: string) => void;
|
||||||
|
/** Opens a host in the editor. */
|
||||||
|
onEditHost?: (host: Host) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
@@ -65,6 +69,8 @@ export function CommandPalette({
|
|||||||
activeTabId = "",
|
activeTabId = "",
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onOpenPanel,
|
onOpenPanel,
|
||||||
|
onOpenSettings,
|
||||||
|
onEditHost,
|
||||||
}: CommandPaletteProps) {
|
}: CommandPaletteProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const railItems = useRailItems();
|
const railItems = useRailItems();
|
||||||
@@ -120,22 +126,26 @@ export function CommandPalette({
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
}, [isOpen, setIsOpen]);
|
}, [isOpen, setIsOpen]);
|
||||||
|
|
||||||
const filteredHosts = hosts.filter((h) => {
|
const query = search.trim().toLowerCase();
|
||||||
const query = search.toLowerCase();
|
const searching = query.length > 0;
|
||||||
return (
|
// Without a query the list is a short starting point, not every host.
|
||||||
h.name.toLowerCase().includes(query) ||
|
const filteredHosts = searching
|
||||||
h.ip.toLowerCase().includes(query) ||
|
? hosts.filter(
|
||||||
h.username.toLowerCase().includes(query) ||
|
(h) =>
|
||||||
h.tags?.some((tag) => tag.toLowerCase().includes(query))
|
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 groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
|
||||||
const folderMap = new Map<string, Host[]>();
|
const folderMap = new Map<string, Host[]>();
|
||||||
const ungrouped: Host[] = [];
|
const ungrouped: Host[] = [];
|
||||||
for (const h of filteredHosts) {
|
for (const h of filteredHosts) {
|
||||||
if (h.folder) {
|
if (h.folder && searching) {
|
||||||
if (!folderMap.has(h.folder)) folderMap.set(h.folder, []);
|
if (!folderMap.has(h.folder)) folderMap.set(h.folder, []);
|
||||||
folderMap.get(h.folder)!.push(h);
|
folderMap.get(h.folder)!.push(h);
|
||||||
} else {
|
} else {
|
||||||
@@ -151,12 +161,9 @@ export function CommandPalette({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const firstHost = filteredHosts[0];
|
const firstHost = filteredHosts[0];
|
||||||
if (search.trim() && firstHost) {
|
setSelectedValue(searching && firstHost ? `host-${firstHost.id}` : "");
|
||||||
setSelectedValue(`host-${firstHost.id}`);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
return;
|
}, [isOpen, search]);
|
||||||
}
|
|
||||||
setSelectedValue("quick-action-add-host");
|
|
||||||
}, [filteredHosts, isOpen, search]);
|
|
||||||
|
|
||||||
const activeTargetTab =
|
const activeTargetTab =
|
||||||
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
|
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
|
||||||
@@ -165,21 +172,83 @@ export function CommandPalette({
|
|||||||
action();
|
action();
|
||||||
setIsOpen(false);
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/40 pt-[15vh] backdrop-blur-sm"
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="motion-context-enter mx-4 w-full max-w-2xl overflow-hidden border border-border bg-card shadow-2xl"
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Command
|
<Command
|
||||||
@@ -189,247 +258,28 @@ export function CommandPalette({
|
|||||||
value={selectedValue}
|
value={selectedValue}
|
||||||
onValueChange={setSelectedValue}
|
onValueChange={setSelectedValue}
|
||||||
>
|
>
|
||||||
<div className="flex items-center border-b border-border px-4 py-1">
|
<div className="flex items-center border-b border-border px-3">
|
||||||
<Search className="size-4 text-muted-foreground mr-3" />
|
<Search className="mr-2 size-4 shrink-0 text-muted-foreground" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
placeholder={t("commandPalette.searchPlaceholder")}
|
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="ml-2">ESC</Kbd>
|
||||||
<Kbd className="bg-muted/50 border-none h-6 px-2 text-[11px] rounded-none">
|
|
||||||
ESC
|
|
||||||
</Kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandList className="max-h-[60vh] thin-scrollbar flex flex-col">
|
<CommandList className="thin-scrollbar max-h-[60vh]">
|
||||||
<CommandGroup
|
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
|
||||||
heading={t("commandPalette.quickActions")}
|
{t("commandPalette.noHostsFound", { search })}
|
||||||
className="px-2"
|
</CommandEmpty>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<CommandItem
|
{!searching && recentActivity.length > 0 && (
|
||||||
value="quick-action-admin-settings"
|
<CommandGroup heading={t("commandPalette.recentActivity")}>
|
||||||
onSelect={() => handleAction(() => onOpenTab("admin-settings"))}
|
{recentActivity.slice(0, 4).map((item) => {
|
||||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
const Icon = activityTarget(item.type)?.icon ?? Server;
|
||||||
>
|
return (
|
||||||
<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) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
value={`recent-activity-${item.id}`}
|
value={`recent-activity-${item.id}`}
|
||||||
@@ -439,53 +289,36 @@ export function CommandPalette({
|
|||||||
if (target) onOpenTab(target.tab, item.hostName);
|
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">
|
<Icon className={iconClass} />
|
||||||
{(() => {
|
<span className="truncate">{item.hostName}</span>
|
||||||
const Icon = activityTarget(item.type)?.icon;
|
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
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">
|
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
<span className="text-[10px]">
|
{new Date(item.timestamp).toLocaleDateString()}
|
||||||
{new Date(item.timestamp).toLocaleDateString()}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
);
|
||||||
</CommandGroup>
|
})}
|
||||||
</>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn(showHostResultsFirst && "order-first")}>
|
{filteredHosts.length > 0 && (
|
||||||
{!showHostResultsFirst && <CommandSeparator className="my-2" />}
|
<CommandGroup heading={t("commandPalette.serversAndHosts")}>
|
||||||
|
{groupedHosts.map(({ folder, hosts: groupHosts }) => (
|
||||||
<CommandGroup
|
<div key={folder ?? "__root__"}>
|
||||||
heading={t("commandPalette.serversAndHosts")}
|
{folder && (
|
||||||
className="px-2"
|
<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" />
|
||||||
{filteredHosts.length > 0 ? (
|
{folder}
|
||||||
groupedHosts.map(({ folder, hosts: groupHosts }) => (
|
</div>
|
||||||
<div key={folder ?? "__root__"}>
|
)}
|
||||||
{folder && (
|
{groupHosts.map((host) => {
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wide">
|
const online =
|
||||||
<Folder className="size-3" />
|
getLiveHostStatus(Number(host.id)) === "online";
|
||||||
{folder}
|
return (
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{groupHosts.map((host, i) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={i}
|
key={host.id}
|
||||||
value={`host-${host.id}`}
|
value={`host-${host.id}`}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleAction(() => {
|
handleAction(() => {
|
||||||
@@ -496,38 +329,32 @@ export function CommandPalette({
|
|||||||
if (action) runHostAction(action, host, shell);
|
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
|
||||||
<Server
|
className={cn(
|
||||||
className={cn(
|
"size-3.5 shrink-0",
|
||||||
"size-4",
|
online
|
||||||
getLiveHostStatus(Number(host.id)) === "online"
|
? "text-accent-brand"
|
||||||
? "text-accent-brand"
|
: "text-muted-foreground",
|
||||||
: "text-muted-foreground",
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
<span className="truncate">{host.name}</span>
|
||||||
</div>
|
<span className="truncate font-mono text-[11px] text-muted-foreground">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
{host.username}@{host.ip}
|
||||||
<div className="flex items-center gap-2">
|
</span>
|
||||||
<span className="text-sm font-semibold truncate">
|
{host.isShared && (
|
||||||
{host.name}
|
<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">
|
||||||
</span>
|
{t("hosts.sharing.sharedBadge")}
|
||||||
{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}
|
|
||||||
</span>
|
</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) => {
|
{hostActionsFor(hostActions, host).map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
const label =
|
const label =
|
||||||
@@ -535,16 +362,18 @@ export function CommandPalette({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={action.id}
|
key={action.id}
|
||||||
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
runHostAction(action, host, shell),
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -557,126 +386,194 @@ export function CommandPalette({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
title={t(entry.titleKey)}
|
title={t(entry.titleKey)}
|
||||||
|
aria-label={t(entry.titleKey)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleAction(() => entry.run(shell, host));
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{canEditHost(host) && (
|
{canEditHost(host) && (
|
||||||
<>
|
<button
|
||||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
type="button"
|
||||||
<button
|
title={t("hosts.editHost")}
|
||||||
title={t("hosts.editHost")}
|
aria-label={t("hosts.editHost")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsOpen(false);
|
handleAction(() => onEditHost?.(host));
|
||||||
onOpenTab("host-manager");
|
}}
|
||||||
setTimeout(() => {
|
className={hostButton}
|
||||||
window.dispatchEvent(
|
>
|
||||||
new CustomEvent(
|
<Pencil className="size-3" />
|
||||||
"host-manager:edit-host",
|
</button>
|
||||||
{
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
{t("commandPalette.noHostsFound", { search })}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</CommandGroup>
|
</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">
|
{onOpenPanel && destinations.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<CommandGroup heading={t("commandPalette.navigation")}>
|
||||||
<CommandItem
|
{destinations.map((item) => {
|
||||||
value="link-github"
|
const Icon = item.icon;
|
||||||
onSelect={() =>
|
return (
|
||||||
window.open(
|
<CommandItem
|
||||||
"https://github.com/Termix-SSH/Termix",
|
key={`nav-${item.id}`}
|
||||||
"_blank",
|
value={`nav-${item.id}`}
|
||||||
)
|
onSelect={() =>
|
||||||
}
|
handleAction(() =>
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
item.kind === "tab"
|
||||||
>
|
? onOpenTab(item.id as TabType)
|
||||||
<Globe className="size-4 text-muted-foreground" />
|
: onOpenPanel(item.id),
|
||||||
<span className="text-sm font-medium">GitHub</span>
|
)
|
||||||
</CommandItem>
|
}
|
||||||
<CommandItem
|
className={rowClass}
|
||||||
value="link-discord"
|
>
|
||||||
onSelect={() =>
|
<Icon className={iconClass} />
|
||||||
window.open(
|
<span className="truncate">{t(item.labelKey)}</span>
|
||||||
"https://discord.com/invite/jVQGdvHDrf",
|
</CommandItem>
|
||||||
"_blank",
|
);
|
||||||
)
|
})}
|
||||||
}
|
</CommandGroup>
|
||||||
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" />
|
{quickActions.length > 0 && (
|
||||||
<span className="text-sm font-medium">Discord</span>
|
<CommandGroup heading={t("commandPalette.quickActions")}>
|
||||||
</CommandItem>
|
{quickActions.map((action) => {
|
||||||
<CommandItem
|
const Icon = action.icon;
|
||||||
value="link-support"
|
return (
|
||||||
onSelect={() =>
|
<CommandItem
|
||||||
window.open(
|
key={action.id}
|
||||||
"https://github.com/Termix-SSH/Support/issues/new",
|
value={action.id}
|
||||||
"_blank",
|
onSelect={() => handleAction(action.run)}
|
||||||
)
|
className={rowClass}
|
||||||
}
|
>
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
<Icon className={iconClass} />
|
||||||
>
|
{action.label}
|
||||||
<LifeBuoy className="size-4 text-muted-foreground" />
|
</CommandItem>
|
||||||
<span className="text-sm font-medium">
|
);
|
||||||
{t("dashboard.support")}
|
})}
|
||||||
</span>
|
</CommandGroup>
|
||||||
</CommandItem>
|
)}
|
||||||
</div>
|
|
||||||
</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>
|
</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 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
|
||||||
<div className="flex items-center gap-3">
|
<span className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<Kbd className="h-4">↑↓</Kbd>
|
||||||
<Kbd className="h-5 px-1 bg-background rounded-none">↑↓</Kbd>
|
{t("commandPalette.navigate")}
|
||||||
<span>{t("commandPalette.navigate")}</span>
|
</span>
|
||||||
</div>
|
<span className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<Kbd className="h-4">↵</Kbd>
|
||||||
<Kbd className="h-5 px-1 bg-background rounded-none">ENTER</Kbd>
|
{t("commandPalette.select")}
|
||||||
<span>{t("commandPalette.select")}</span>
|
</span>
|
||||||
</div>
|
<span className="ml-auto flex items-center gap-1">
|
||||||
</div>
|
<Kbd className="h-4">Ctrl</Kbd>
|
||||||
<div className="flex items-center gap-1">
|
<span className="opacity-50">+</span>
|
||||||
<span>{t("commandPalette.toggleWith")}</span>
|
<Kbd className="h-4">K</Kbd>
|
||||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Ctrl</Kbd>
|
<span className="px-1 opacity-50">
|
||||||
<span>+</span>
|
{t("commandPalette.orShortcut")}
|
||||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">K</Kbd>
|
</span>
|
||||||
<span>{t("commandPalette.orShortcut")}</span>
|
<Kbd className="h-4">⇧⇧</Kbd>
|
||||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
|
</span>
|
||||||
<span>+</span>
|
|
||||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Command>
|
</Command>
|
||||||
</div>
|
</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([
|
const CORE_UNSAVED = new Set([
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"host-manager",
|
"host-manager",
|
||||||
|
"settings",
|
||||||
"user-profile",
|
"user-profile",
|
||||||
"admin-settings",
|
"admin-settings",
|
||||||
"split-screen",
|
"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 { ComponentType, Ref } from "react";
|
||||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||||
import type { Host, Tab } from "@/types/ui-types";
|
import type { Host, Tab } from "@/types/ui-types";
|
||||||
@@ -34,6 +35,8 @@ export interface TabShellCallbacks {
|
|||||||
openHostEditor?: (draft?: HostDraft) => void;
|
openHostEditor?: (draft?: HostDraft) => void;
|
||||||
/** Saves a quick-connect tab's host. Core terminal only. */
|
/** Saves a quick-connect tab's host. Core terminal only. */
|
||||||
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
|
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 {
|
export interface TabRenderProps {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutPanelLeft,
|
LayoutPanelLeft,
|
||||||
|
LibraryBig,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { lazy, memo, Suspense } from "react";
|
import { lazy, memo, Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,12 +17,19 @@ import {
|
|||||||
type TabTypeDef,
|
type TabTypeDef,
|
||||||
} from "./tab-registry";
|
} from "./tab-registry";
|
||||||
import { getPanel, type PanelDef } from "./panel-registry";
|
import { getPanel, type PanelDef } from "./panel-registry";
|
||||||
|
import type { SettingsTabProps } from "@/settings/SettingsTab";
|
||||||
import {
|
import {
|
||||||
markAdaptiveResourceUsed,
|
markAdaptiveResourceUsed,
|
||||||
runAdaptiveBackgroundTask,
|
runAdaptiveBackgroundTask,
|
||||||
} from "@/lib/adaptive-resource-budget";
|
} 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(() =>
|
const DashboardTab = lazy(() =>
|
||||||
import("@/dashboard/DashboardTab").then((m) => ({
|
import("@/dashboard/DashboardTab").then((m) => ({
|
||||||
default: m.DashboardTab,
|
default: m.DashboardTab,
|
||||||
@@ -91,9 +98,9 @@ export function tabIcon(type: TabType) {
|
|||||||
case "dashboard":
|
case "dashboard":
|
||||||
return <LayoutDashboard className="size-3.5" />;
|
return <LayoutDashboard className="size-3.5" />;
|
||||||
case "host-manager":
|
case "host-manager":
|
||||||
return <Server className="size-3.5" />;
|
return <LibraryBig className="size-3.5" />;
|
||||||
|
case "settings":
|
||||||
case "user-profile":
|
case "user-profile":
|
||||||
return <User className="size-3.5" />;
|
|
||||||
case "admin-settings":
|
case "admin-settings":
|
||||||
return <Settings className="size-3.5" />;
|
return <Settings className="size-3.5" />;
|
||||||
case "split-screen":
|
case "split-screen":
|
||||||
@@ -202,6 +209,8 @@ function noop() {}
|
|||||||
|
|
||||||
export interface TabRenderContext {
|
export interface TabRenderContext {
|
||||||
shell: TabShellCallbacks;
|
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. */
|
/** The terminal a panel shown as a tab sends commands to. */
|
||||||
panelTargetTab?: Tab;
|
panelTargetTab?: Tab;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
@@ -225,6 +234,22 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
case "host-manager":
|
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 "user-profile":
|
||||||
case "admin-settings":
|
case "admin-settings":
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { copyToClipboard } from "@/lib/clipboard";
|
import { copyToClipboard } from "@/lib/clipboard";
|
||||||
@@ -52,6 +53,7 @@ export function AdminApiKeysSection({
|
|||||||
newKeyLoading,
|
newKeyLoading,
|
||||||
}: AdminApiKeysSectionProps) {
|
}: AdminApiKeysSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
@@ -244,6 +246,11 @@ export function AdminApiKeysSection({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.revokeKeyConfirm", { name: key.name }),
|
||||||
|
confirmLabel: t("admin.revokeKey"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteApiKey(key.id);
|
await deleteApiKey(key.id);
|
||||||
setApiKeys((prev) => prev.filter((k) => k.id !== 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 { useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -112,6 +113,7 @@ export function AdminUsersSection({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
}: UsersSectionProps) {
|
}: UsersSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const hasPrev = page > 0;
|
const hasPrev = page > 0;
|
||||||
const hasNext = page + 1 < pageCount;
|
const hasNext = page + 1 < pageCount;
|
||||||
@@ -249,6 +251,13 @@ export function AdminUsersSection({
|
|||||||
className="size-6 text-muted-foreground hover:text-destructive"
|
className="size-6 text-muted-foreground hover:text-destructive"
|
||||||
disabled={user.isAdmin}
|
disabled={user.isAdmin}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.deleteUserConfirm", {
|
||||||
|
username: user.username,
|
||||||
|
}),
|
||||||
|
description: t("manage.cannotBeUndone"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteUser(user.username);
|
await deleteUser(user.username);
|
||||||
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||||
@@ -321,6 +330,7 @@ export function AdminSessionsSection({
|
|||||||
loadSessions,
|
loadSessions,
|
||||||
}: SessionsSectionProps) {
|
}: SessionsSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
@@ -375,6 +385,13 @@ export function AdminSessionsSection({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
|
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.revokeAllSessionsConfirm", {
|
||||||
|
username: session.username ?? "",
|
||||||
|
}),
|
||||||
|
confirmLabel: t("admin.revokeAll"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await revokeAllUserSessions(session.userId);
|
await revokeAllUserSessions(session.userId);
|
||||||
setSessions((prev) =>
|
setSessions((prev) =>
|
||||||
@@ -393,6 +410,11 @@ export function AdminSessionsSection({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-muted-foreground hover:text-destructive"
|
className="size-6 text-muted-foreground hover:text-destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.revokeSessionConfirm"),
|
||||||
|
confirmLabel: t("admin.revokeSession"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await revokeSession(session.id);
|
await revokeSession(session.id);
|
||||||
setSessions((prev) =>
|
setSessions((prev) =>
|
||||||
@@ -447,6 +469,7 @@ export function AdminRolesSection({
|
|||||||
createRoleLoading,
|
createRoleLoading,
|
||||||
}: RolesSectionProps) {
|
}: RolesSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
|
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
|
||||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||||
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
|
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
|
||||||
@@ -652,6 +675,12 @@ export function AdminRolesSection({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-muted-foreground hover:text-destructive"
|
className="size-6 text-muted-foreground hover:text-destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.deleteRoleConfirm", {
|
||||||
|
name: role.displayName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
await deleteRole(role.id);
|
await deleteRole(role.id);
|
||||||
setRoles((prev) =>
|
setRoles((prev) =>
|
||||||
prev.filter((r) => r.id !== role.id),
|
prev.filter((r) => r.id !== role.id),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useBranding } from "@/contexts/BrandingContext";
|
import { useBranding } from "@/contexts/BrandingContext";
|
||||||
@@ -102,11 +103,15 @@ const USERS_PAGE_SIZE = 25;
|
|||||||
export function AdminSettingsPanel({
|
export function AdminSettingsPanel({
|
||||||
onEditingChange,
|
onEditingChange,
|
||||||
onOpenHostTab,
|
onOpenHostTab,
|
||||||
|
section,
|
||||||
}: {
|
}: {
|
||||||
onEditingChange?: (editing: boolean) => void;
|
onEditingChange?: (editing: boolean) => void;
|
||||||
onOpenHostTab?: (host: Host) => void;
|
onOpenHostTab?: (host: Host) => void;
|
||||||
|
/** Shows just this section, flat, as a Settings page. */
|
||||||
|
section?: AdminSection;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [openSections, setOpenSections] = useState<
|
const [openSections, setOpenSections] = useState<
|
||||||
Set<AdminSection | FeatureSectionId>
|
Set<AdminSection | FeatureSectionId>
|
||||||
>(() => new Set(["general"]));
|
>(() => new Set(["general"]));
|
||||||
@@ -567,6 +572,13 @@ export function AdminSettingsPanel({
|
|||||||
|
|
||||||
async function handleDeleteEditUser() {
|
async function handleDeleteEditUser() {
|
||||||
if (!editUserTarget) return;
|
if (!editUserTarget) return;
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.deleteUserConfirm", {
|
||||||
|
username: editUserTarget.username,
|
||||||
|
}),
|
||||||
|
description: t("manage.cannotBeUndone"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
setEditUserLoading(true);
|
setEditUserLoading(true);
|
||||||
try {
|
try {
|
||||||
await deleteUser(editUserTarget.username);
|
await deleteUser(editUserTarget.username);
|
||||||
@@ -734,6 +746,9 @@ export function AdminSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOpen = (id: AdminSection | FeatureSectionId) =>
|
||||||
|
section ? section === id : openSections.has(id);
|
||||||
|
|
||||||
if (manageUser) {
|
if (manageUser) {
|
||||||
return (
|
return (
|
||||||
<AdminUserManagePanel
|
<AdminUserManagePanel
|
||||||
@@ -758,9 +773,15 @@ export function AdminSettingsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<AdminGeneralSettingsSection
|
||||||
open={openSections.has("general")}
|
open={isOpen("general")}
|
||||||
onToggle={() => toggle("general")}
|
onToggle={() => toggle("general")}
|
||||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||||
onSaveNotificationPrivateEndpoints={
|
onSaveNotificationPrivateEndpoints={
|
||||||
@@ -790,7 +811,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminUsersSection
|
<AdminUsersSection
|
||||||
open={openSections.has("users")}
|
open={isOpen("users")}
|
||||||
onToggle={() => toggle("users")}
|
onToggle={() => toggle("users")}
|
||||||
users={users}
|
users={users}
|
||||||
setUsers={setUsers}
|
setUsers={setUsers}
|
||||||
@@ -812,7 +833,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminSessionsSection
|
<AdminSessionsSection
|
||||||
open={openSections.has("sessions")}
|
open={isOpen("sessions")}
|
||||||
onToggle={() => toggle("sessions")}
|
onToggle={() => toggle("sessions")}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
setSessions={setSessions}
|
setSessions={setSessions}
|
||||||
@@ -820,7 +841,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminRolesSection
|
<AdminRolesSection
|
||||||
open={openSections.has("roles")}
|
open={isOpen("roles")}
|
||||||
onToggle={() => toggle("roles")}
|
onToggle={() => toggle("roles")}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
setRoles={setRoles}
|
setRoles={setRoles}
|
||||||
@@ -837,12 +858,12 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminHostDefaultsSection
|
<AdminHostDefaultsSection
|
||||||
open={openSections.has("host-defaults")}
|
open={isOpen("host-defaults")}
|
||||||
onToggle={() => toggle("host-defaults")}
|
onToggle={() => toggle("host-defaults")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminBrandingSection
|
<AdminBrandingSection
|
||||||
open={openSections.has("branding")}
|
open={isOpen("branding")}
|
||||||
onToggle={() => toggle("branding")}
|
onToggle={() => toggle("branding")}
|
||||||
settings={brandingSettings}
|
settings={brandingSettings}
|
||||||
setSettings={setBrandingSettings}
|
setSettings={setBrandingSettings}
|
||||||
@@ -852,7 +873,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminDatabaseSection
|
<AdminDatabaseSection
|
||||||
open={openSections.has("database")}
|
open={isOpen("database")}
|
||||||
onToggle={() => toggle("database")}
|
onToggle={() => toggle("database")}
|
||||||
importFile={importFile}
|
importFile={importFile}
|
||||||
setImportFile={setImportFile}
|
setImportFile={setImportFile}
|
||||||
@@ -863,7 +884,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminSSLSection
|
<AdminSSLSection
|
||||||
open={openSections.has("ssl")}
|
open={isOpen("ssl")}
|
||||||
onToggle={() => toggle("ssl")}
|
onToggle={() => toggle("ssl")}
|
||||||
status={tlsStatus}
|
status={tlsStatus}
|
||||||
manualCertDraft={manualCertDraft}
|
manualCertDraft={manualCertDraft}
|
||||||
@@ -875,7 +896,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminApiKeysSection
|
<AdminApiKeysSection
|
||||||
open={openSections.has("api-keys")}
|
open={isOpen("api-keys")}
|
||||||
onToggle={() => toggle("api-keys")}
|
onToggle={() => toggle("api-keys")}
|
||||||
apiKeys={apiKeys}
|
apiKeys={apiKeys}
|
||||||
setApiKeys={setApiKeys}
|
setApiKeys={setApiKeys}
|
||||||
@@ -896,7 +917,7 @@ export function AdminSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminAuditLogSection
|
<AdminAuditLogSection
|
||||||
open={openSections.has("audit-log")}
|
open={isOpen("audit-log")}
|
||||||
onToggle={() => toggle("audit-log")}
|
onToggle={() => toggle("audit-log")}
|
||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
@@ -906,7 +927,7 @@ export function AdminSettingsPanel({
|
|||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
scope="admin"
|
scope="admin"
|
||||||
open={openSections.has(featureSectionId(plugin.id))}
|
open={isOpen(featureSectionId(plugin.id))}
|
||||||
onToggle={() => toggle(featureSectionId(plugin.id))}
|
onToggle={() => toggle(featureSectionId(plugin.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ function certState(
|
|||||||
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
|
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
|
||||||
none: "text-muted-foreground",
|
none: "text-muted-foreground",
|
||||||
valid: "text-green-500",
|
valid: "text-green-500",
|
||||||
expiring: "text-yellow-500",
|
expiring: "text-warning",
|
||||||
expired: "text-destructive",
|
expired: "text-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ export function AdminSSLSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cert && !cert.selfSigned && !status?.renewal && (
|
{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 })}
|
{t("admin.sslNotRenewed", { date: expiry })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useSettingsEmbedded } from "@/settings/settings-embed";
|
||||||
|
|
||||||
export function AdminToggle({
|
export function AdminToggle({
|
||||||
on,
|
on,
|
||||||
@@ -36,6 +37,15 @@ export function AccordionSection({
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
children: React.ReactNode;
|
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 (
|
return (
|
||||||
<div className="border border-border bg-card overflow-hidden shrink-0">
|
<div className="border border-border bg-card overflow-hidden shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+368
-395
@@ -1,3 +1,4 @@
|
|||||||
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -9,13 +10,6 @@ import {
|
|||||||
import type { Role, UserRole } from "@/main-axios";
|
import type { Role, UserRole } from "@/main-axios";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
|
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AdminToggle } from "./AdminSettingsShared";
|
import { AdminToggle } from "./AdminSettingsShared";
|
||||||
@@ -63,83 +57,80 @@ export function AdminCreateUserDialog({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={t("admin.createUserTitle")}
|
||||||
{t("admin.createUserTitle")}
|
>
|
||||||
</DialogTitle>
|
<p className="text-xs text-muted-foreground">
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
{t("admin.createUserDesc")}
|
||||||
{t("admin.createUserDesc")}
|
</p>
|
||||||
</DialogDescription>
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
</DialogHeader>
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
<div className="flex flex-col gap-1.5">
|
{t("admin.createUserUsername")}{" "}
|
||||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
<span className="text-accent-brand">*</span>
|
||||||
{t("admin.createUserUsername")}{" "}
|
</label>
|
||||||
<span className="text-accent-brand">*</span>
|
<Input
|
||||||
</label>
|
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
|
<Input
|
||||||
placeholder={t("admin.createUserEnterUsername")}
|
type={showNewPassword ? "text" : "password"}
|
||||||
value={newUsername}
|
placeholder={t("placeholders.enterPassword")}
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
|
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>
|
||||||
<div className="flex flex-col gap-1.5">
|
<span className="text-xs text-muted-foreground">
|
||||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
{t("admin.createUserPasswordHint")}
|
||||||
{t("admin.createUserPassword")}{" "}
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
</div>
|
||||||
<Button
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
onOpenChange(false);
|
onClick={() => {
|
||||||
setNewUsername("");
|
onOpenChange(false);
|
||||||
setNewPassword("");
|
setNewUsername("");
|
||||||
}}
|
setNewPassword("");
|
||||||
>
|
}}
|
||||||
{t("common.cancel")}
|
>
|
||||||
</Button>
|
{t("common.cancel")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
variant="outline"
|
||||||
onClick={handleCreateUser}
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
disabled={createUserLoading}
|
onClick={handleCreateUser}
|
||||||
>
|
disabled={createUserLoading}
|
||||||
{createUserLoading
|
>
|
||||||
? t("admin.creating")
|
{createUserLoading
|
||||||
: t("admin.createUserSubmit")}
|
? t("admin.creating")
|
||||||
</Button>
|
: t("admin.createUserSubmit")}
|
||||||
</div>
|
</Button>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</InlineView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,222 +164,210 @@ export function AdminEditUserDialog({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
|
||||||
{t("admin.editUserTitle", { username: editUserTarget?.username })}
|
>
|
||||||
</DialogTitle>
|
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
{editUserTarget && (
|
||||||
{t("admin.editUserDesc")}
|
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
|
||||||
</DialogDescription>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
|
||||||
</DialogHeader>
|
<div className="flex flex-col gap-0.5">
|
||||||
{editUserTarget && (
|
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
|
{t("admin.editUserUsername")}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
|
</span>
|
||||||
<div className="flex flex-col gap-0.5">
|
<span className="text-sm font-semibold">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
{editUserTarget.username}
|
||||||
{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")}
|
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="flex flex-col gap-0.5">
|
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
<span className="text-sm font-medium">
|
{t("admin.editUserAuthType")}
|
||||||
{t("admin.revokeAllUserSessions")}
|
</span>
|
||||||
</span>
|
<span className="text-sm font-semibold">
|
||||||
<span className="text-xs text-muted-foreground">
|
{editUserTarget.isExternal && editUserTarget.passwordHash
|
||||||
{t("admin.revokeAllUserSessionsDesc")}
|
? t("admin.authTypeDual")
|
||||||
</span>
|
: editUserTarget.isExternal
|
||||||
</div>
|
? t("admin.authTypeExternal")
|
||||||
<Button
|
: t("admin.authTypeLocal")}
|
||||||
variant="outline"
|
</span>
|
||||||
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>
|
||||||
<div className="flex flex-col gap-2 py-3">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
{t("admin.editUserAdminStatus")}
|
||||||
<span className="text-xs text-destructive">
|
</span>
|
||||||
{t("admin.deleteUserWarning")}
|
<span className="text-sm font-semibold">
|
||||||
</span>
|
{editUserTarget.isAdmin
|
||||||
</div>
|
? t("admin.adminStatusAdministrator")
|
||||||
<Button
|
: t("admin.adminStatusRegularUser")}
|
||||||
variant="outline"
|
</span>
|
||||||
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
</div>
|
||||||
disabled={editUserTarget.isAdmin || editUserLoading}
|
<div className="flex flex-col gap-0.5">
|
||||||
onClick={handleDeleteEditUser}
|
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
>
|
{t("admin.editUserUserId")}
|
||||||
<Trash2 className="size-3.5" />
|
</span>
|
||||||
{editUserLoading
|
<span className="text-xs font-mono text-muted-foreground truncate">
|
||||||
? t("admin.deleting")
|
{editUserTarget.id}
|
||||||
: t("admin.deleteUser", {
|
</span>
|
||||||
username: editUserTarget.username,
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center justify-between py-3">
|
||||||
</DialogContent>
|
<div className="flex flex-col gap-0.5">
|
||||||
</Dialog>
|
<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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={t("admin.unlinkAccountTitle")}
|
||||||
{t("admin.unlinkAccountTitle")}
|
>
|
||||||
</DialogTitle>
|
<p className="text-xs text-muted-foreground">
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
{t("admin.unlinkAccountDesc", {
|
||||||
{t("admin.unlinkAccountDesc", {
|
username: unlinkAccountTarget?.username,
|
||||||
username: unlinkAccountTarget?.username,
|
})}
|
||||||
})}
|
</p>
|
||||||
</DialogDescription>
|
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||||
</DialogHeader>
|
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
<span className="text-xs text-destructive">
|
||||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
{t("admin.unlinkAccountWarning")}
|
||||||
<span className="text-xs text-destructive">
|
</span>
|
||||||
{t("admin.unlinkAccountWarning")}
|
</div>
|
||||||
</span>
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
</div>
|
<Button
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
variant="ghost"
|
||||||
<Button
|
onClick={() => onOpenChange(false)}
|
||||||
variant="ghost"
|
disabled={submitting}
|
||||||
onClick={() => onOpenChange(false)}
|
>
|
||||||
disabled={submitting}
|
{t("common.cancel")}
|
||||||
>
|
</Button>
|
||||||
{t("common.cancel")}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
variant="outline"
|
disabled={submitting || !unlinkAccountTarget}
|
||||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
onClick={handleSubmit}
|
||||||
disabled={submitting || !unlinkAccountTarget}
|
>
|
||||||
onClick={handleSubmit}
|
{submitting
|
||||||
>
|
? t("admin.unlinkAccountInProgress")
|
||||||
{submitting
|
: t("admin.unlinkAccount")}
|
||||||
? t("admin.unlinkAccountInProgress")
|
</Button>
|
||||||
: t("admin.unlinkAccount")}
|
</div>
|
||||||
</Button>
|
</InlineView>
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,74 +505,71 @@ export function AdminLinkAccountDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={t("admin.linkAccountTitle")}
|
||||||
{t("admin.linkAccountTitle")}
|
>
|
||||||
</DialogTitle>
|
<p className="text-xs text-muted-foreground">
|
||||||
<DialogDescription 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
|
{isExternalInitiator
|
||||||
? t("admin.linkAccountDesc", {
|
? t("admin.linkAccountTargetUsername")
|
||||||
username: linkAccountTarget?.username,
|
: t("admin.linkAccountExternalUsername")}{" "}
|
||||||
})
|
<span className="text-accent-brand">*</span>
|
||||||
: t("admin.linkAccountDescLocal", {
|
</label>
|
||||||
username: linkAccountTarget?.username,
|
<Input
|
||||||
})}
|
value={otherUsername}
|
||||||
</DialogDescription>
|
onChange={(e) => setOtherUsername(e.target.value)}
|
||||||
</DialogHeader>
|
placeholder={
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
isExternalInitiator
|
||||||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
? t("admin.linkAccountTargetPlaceholder")
|
||||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
: t("admin.linkAccountExternalPlaceholder")
|
||||||
<div className="flex flex-col gap-1 text-xs text-destructive">
|
}
|
||||||
<span>{t("admin.linkAccountWarningTitle")}</span>
|
autoFocus
|
||||||
<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)}
|
|
||||||
disabled={submitting}
|
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>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
<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 { useEffect, useState } from "react";
|
||||||
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
||||||
import { getUserList } from "@/main-axios";
|
import { getUserList } from "@/main-axios";
|
||||||
@@ -50,7 +51,8 @@ import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
|
|||||||
import { HostEditor } from "./HostEditor";
|
import { HostEditor } from "./HostEditor";
|
||||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||||
import type { AdminSession, AdminUser } from "./AdminManagementSections";
|
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";
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||||
|
|
||||||
type ApiErrorLike = {
|
type ApiErrorLike = {
|
||||||
@@ -114,14 +116,18 @@ export function AdminUserManagePanel({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [user.id]);
|
}, [user.id]);
|
||||||
const [editor, setEditor] = useState<EditorState>(null);
|
const [editor, setEditor] = useState<EditorState>(null);
|
||||||
const [editorTab, setEditorTab] = useState("general");
|
|
||||||
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
|
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
|
||||||
hostProtocolFlags(null),
|
hostProtocolFlags(null),
|
||||||
);
|
);
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const confirm = useConfirm();
|
||||||
message: string;
|
const setConfirmDialog = (
|
||||||
onConfirm: () => void;
|
request: { message: string; onConfirm: () => void } | null,
|
||||||
} | null>(null);
|
) => {
|
||||||
|
if (!request) return;
|
||||||
|
void confirm({ title: request.message }).then((ok) => {
|
||||||
|
if (ok) void request.onConfirm();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const [hosts, setHosts] = useState<Host[]>([]);
|
const [hosts, setHosts] = useState<Host[]>([]);
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
@@ -323,57 +329,34 @@ export function AdminUserManagePanel({
|
|||||||
</span>
|
</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) {
|
if (editor) {
|
||||||
const isHost = editor.kind === "host";
|
const close = () => setEditor(null);
|
||||||
const tabs = isHost
|
|
||||||
? makeHostTabs(
|
|
||||||
t,
|
|
||||||
editorProtocols as unknown as Record<string, boolean>,
|
|
||||||
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
|
|
||||||
: makeCredentialTabs(t);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="flex flex-col shrink-0 border-b border-border">
|
<PanelShell
|
||||||
<button
|
leading={<BackButton onClick={close} />}
|
||||||
onClick={() => {
|
title={t("admin.manageEditorBack", { username: user.username })}
|
||||||
setEditor(null);
|
status={
|
||||||
setEditorTab("general");
|
editor.kind === "host"
|
||||||
}}
|
? (editor.host?.name ?? t("manage.newHost"))
|
||||||
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"
|
: (editor.credential?.name ?? t("manage.newCredential"))
|
||||||
>
|
}
|
||||||
<ArrowLeft className="size-3.5 shrink-0" />
|
scroll={false}
|
||||||
<span>
|
>
|
||||||
{t("admin.manageEditorBack", { username: user.username })}
|
{editor.kind === "host" ? (
|
||||||
</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 ? (
|
|
||||||
<HostEditor
|
<HostEditor
|
||||||
key={editor.host ? editor.host.id : "new-host"}
|
key={editor.host ? editor.host.id : "new-host"}
|
||||||
host={editor.host}
|
host={editor.host}
|
||||||
activeTab={editorTab}
|
onBack={close}
|
||||||
onBack={() => {
|
|
||||||
setEditor(null);
|
|
||||||
setEditorTab("general");
|
|
||||||
}}
|
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
setEditor(null);
|
close();
|
||||||
setEditorTab("general");
|
|
||||||
reloadHosts();
|
reloadHosts();
|
||||||
}}
|
}}
|
||||||
protocols={editorProtocols}
|
protocols={editorProtocols}
|
||||||
onProtocolChange={(p) =>
|
onProtocolChange={(p) =>
|
||||||
setEditorProtocols((prev) => ({ ...prev, ...p }))
|
setEditorProtocols((prev) => ({ ...prev, ...p }))
|
||||||
}
|
}
|
||||||
onTabChange={setEditorTab}
|
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
credentials={credentials}
|
credentials={credentials}
|
||||||
adminTargetUserId={user.id}
|
adminTargetUserId={user.id}
|
||||||
@@ -382,7 +365,6 @@ export function AdminUserManagePanel({
|
|||||||
<CredentialEditorView
|
<CredentialEditorView
|
||||||
key={editor.credential ? editor.credential.id : "new-cred"}
|
key={editor.credential ? editor.credential.id : "new-cred"}
|
||||||
credential={editor.credential}
|
credential={editor.credential}
|
||||||
activeTab={editorTab}
|
|
||||||
existingFolders={Array.from(
|
existingFolders={Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
credentials
|
credentials
|
||||||
@@ -390,19 +372,15 @@ export function AdminUserManagePanel({
|
|||||||
.filter((f): f is string => !!f),
|
.filter((f): f is string => !!f),
|
||||||
),
|
),
|
||||||
).sort()}
|
).sort()}
|
||||||
onBack={() => {
|
onBack={close}
|
||||||
setEditor(null);
|
|
||||||
setEditorTab("general");
|
|
||||||
}}
|
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
setEditor(null);
|
close();
|
||||||
setEditorTab("general");
|
|
||||||
reloadCredentials();
|
reloadCredentials();
|
||||||
}}
|
}}
|
||||||
adminTargetUserId={user.id}
|
adminTargetUserId={user.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PanelShell>
|
||||||
</div>
|
</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"
|
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditorProtocols(hostProtocolFlags(null));
|
setEditorProtocols(hostProtocolFlags(null));
|
||||||
setEditorTab("general");
|
|
||||||
setEditor({ kind: "host", host: null });
|
setEditor({ kind: "host", host: null });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -724,7 +701,6 @@ export function AdminUserManagePanel({
|
|||||||
className="size-6 text-muted-foreground hover:text-foreground"
|
className="size-6 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditorProtocols(hostProtocolFlags(host));
|
setEditorProtocols(hostProtocolFlags(host));
|
||||||
setEditorTab("general");
|
|
||||||
setEditor({ kind: "host", host });
|
setEditor({ kind: "host", host });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -790,7 +766,6 @@ export function AdminUserManagePanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditorTab("general");
|
|
||||||
setEditor({ kind: "credential", credential: null });
|
setEditor({ kind: "credential", credential: null });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -827,7 +802,6 @@ export function AdminUserManagePanel({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-muted-foreground hover:text-foreground"
|
className="size-6 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditorTab("general");
|
|
||||||
setEditor({ kind: "credential", credential: cred });
|
setEditor({ kind: "credential", credential: cred });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1013,32 +987,6 @@ export function AdminUserManagePanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
LogOut,
|
LogOut,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Pin,
|
Pin,
|
||||||
Settings,
|
Search,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
SquareArrowOutUpRight,
|
SquareArrowOutUpRight,
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TabType, ToolsTab } from "@/types/ui-types";
|
import type { TabType, ToolsTab } from "@/types/ui-types";
|
||||||
import { Skeleton } from "@/components/skeleton";
|
import { Skeleton } from "@/components/skeleton";
|
||||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
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 { RailBadge } from "./RailBadge";
|
||||||
import { rem } from "@/lib/rem";
|
import { rem } from "@/lib/rem";
|
||||||
|
|
||||||
/** Core rail views; plugins add their own ids at runtime. */
|
/** Core rail views; plugins add their own ids at runtime. */
|
||||||
export type CoreRailView =
|
export type CoreRailView =
|
||||||
| "hosts"
|
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
|
||||||
| "credentials"
|
|
||||||
| "quick-connect"
|
|
||||||
| ToolsTab
|
|
||||||
| "connections"
|
|
||||||
| "user-profile"
|
|
||||||
| "admin-settings";
|
|
||||||
|
|
||||||
export type RailView = CoreRailView | (string & {});
|
export type RailView = CoreRailView | (string & {});
|
||||||
|
|
||||||
type RailItem =
|
const btnBase =
|
||||||
| {
|
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
|
||||||
kind?: undefined;
|
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
||||||
view: RailView;
|
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
|
||||||
icon: React.ReactNode;
|
const activeClass = "text-accent-brand bg-accent-brand/10";
|
||||||
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" };
|
|
||||||
|
|
||||||
function buildRailButtons(
|
function RailLabel({
|
||||||
items: RailItemDef[],
|
expanded,
|
||||||
t: (key: string) => string,
|
children,
|
||||||
hidden: Set<string>,
|
}: {
|
||||||
): RailItem[] {
|
expanded: boolean;
|
||||||
const all: RailItem[] = [];
|
children: React.ReactNode;
|
||||||
for (const item of items) {
|
}) {
|
||||||
const Icon = item.icon;
|
return (
|
||||||
if (item.kind === "tab") {
|
<span
|
||||||
all.push({
|
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||||
kind: "tab",
|
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||||
tabType: item.id as TabType,
|
}`}
|
||||||
icon: <Icon size={16} />,
|
>
|
||||||
title: t(item.labelKey),
|
{children}
|
||||||
useBadge: item.useBadge,
|
</span>
|
||||||
});
|
);
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnBase =
|
function RailIcon({
|
||||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
children,
|
||||||
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
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({
|
export function AppRail({
|
||||||
railView,
|
railView,
|
||||||
@@ -111,6 +127,8 @@ export function AppRail({
|
|||||||
onRailClick,
|
onRailClick,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onOpenInRightDock,
|
onOpenInRightDock,
|
||||||
|
onOpenSettings,
|
||||||
|
onOpenPalette,
|
||||||
onLogout,
|
onLogout,
|
||||||
}: {
|
}: {
|
||||||
railView: RailView;
|
railView: RailView;
|
||||||
@@ -122,6 +140,8 @@ export function AppRail({
|
|||||||
onRailClick: (view: RailView) => void;
|
onRailClick: (view: RailView) => void;
|
||||||
onOpenTab?: (type: TabType) => void;
|
onOpenTab?: (type: TabType) => void;
|
||||||
onOpenInRightDock?: (view: RailView) => void;
|
onOpenInRightDock?: (view: RailView) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenPalette?: () => void;
|
||||||
onLogout: (options?: { manual?: boolean }) => void;
|
onLogout: (options?: { manual?: boolean }) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -134,22 +154,9 @@ export function AppRail({
|
|||||||
readRailPreference("showPinAppRailButton"),
|
readRailPreference("showPinAppRailButton"),
|
||||||
);
|
);
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
// Which promotable item was right-clicked, so the menu can offer to open it
|
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
|
||||||
// as a tab. Null when the right-click landed on empty rail space.
|
const [managing, setManaging] = useState(false);
|
||||||
const [menuTarget, setMenuTarget] = useState<{
|
const hiddenTabs = useHiddenRailTabs();
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
|
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
|
||||||
@@ -194,33 +201,20 @@ export function AppRail({
|
|||||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||||
const railItems = useRailItems();
|
const railItems = useRailItems();
|
||||||
|
|
||||||
useEffect(() => {
|
const railExpanded = pinned || (expandOnHover && hovered) || managing;
|
||||||
const handler = () => {
|
const mainItems = railItems.filter((item) => item.placement !== "footer");
|
||||||
try {
|
const bands = useMemo(
|
||||||
const stored = localStorage.getItem("hiddenRailTabs");
|
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
|
||||||
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
|
[mainItems, hiddenTabs],
|
||||||
} 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 hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
|
||||||
const footerItems = railItems.filter(
|
const footerItems = railItems.filter(
|
||||||
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
|
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
|
||||||
);
|
);
|
||||||
const setRailPinned = (nextPinned: boolean) => {
|
|
||||||
setPinned(nextPinned);
|
useEffect(() => {
|
||||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
if (hiddenItems.length === 0) setManaging(false);
|
||||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
}, [hiddenItems.length]);
|
||||||
};
|
|
||||||
|
|
||||||
const togglePinned = () => {
|
const togglePinned = () => {
|
||||||
setRailPreference("pinAppRail", !pinned);
|
setRailPreference("pinAppRail", !pinned);
|
||||||
@@ -232,29 +226,92 @@ export function AppRail({
|
|||||||
setMenuPos(null);
|
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 (
|
return (
|
||||||
<div
|
<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"
|
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)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Keep the menu on screen when right-clicking near the viewport edges
|
// Keep the menu on screen when right-clicking near the viewport edges
|
||||||
const MENU_W = 190;
|
const MENU_W = 200;
|
||||||
const MENU_H = 96;
|
const MENU_H = 180;
|
||||||
setMenuPos({
|
setMenuPos({
|
||||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 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);
|
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">
|
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||||
{!pluginsSettled
|
{!pluginsSettled
|
||||||
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
|
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
key={`rail-skeleton-${i}`}
|
key={`rail-skeleton-${i}`}
|
||||||
style={btnStyle}
|
style={btnStyle}
|
||||||
@@ -263,307 +320,106 @@ export function AppRail({
|
|||||||
<Skeleton className="size-4 shrink-0" />
|
<Skeleton className="size-4 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: railButtons.map((item, i) =>
|
: bands.map((band, i) => (
|
||||||
item.kind === "separator" ? (
|
<div key={band.group} className="flex flex-col gap-1">
|
||||||
<div
|
{i > 0 && (
|
||||||
key={`sep-${i}`}
|
<GroupDivider group={band.group} expanded={railExpanded} />
|
||||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
)}
|
||||||
style={{
|
{band.items.map(renderItem)}
|
||||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
</div>
|
||||||
}}
|
))}
|
||||||
/>
|
|
||||||
) : "tabType" in item ? (
|
{pluginsSettled && hiddenItems.length > 0 && (
|
||||||
<button
|
<>
|
||||||
key={item.tabType}
|
<Rule expanded={railExpanded} />
|
||||||
onClick={() => onOpenTab?.(item.tabType)}
|
<button
|
||||||
style={btnStyle}
|
type="button"
|
||||||
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
onClick={() => setManaging((v) => !v)}
|
||||||
>
|
aria-expanded={managing}
|
||||||
<span
|
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||||
className="relative shrink-0 flex items-center justify-center"
|
style={btnStyle}
|
||||||
style={{ width: rem(16), height: rem(16) }}
|
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}
|
<RailIcon>
|
||||||
{item.useBadge && (
|
<Icon size={16} />
|
||||||
<RailBadge
|
</RailIcon>
|
||||||
useBadge={item.useBadge}
|
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
||||||
className="-top-1.5 -right-2"
|
{t(item.labelKey)}
|
||||||
/>
|
</span>
|
||||||
)}
|
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
||||||
</span>
|
</button>
|
||||||
<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>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||||
{showPinButton && (
|
{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
|
<button
|
||||||
key={item.id}
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
||||||
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
|
|
||||||
}
|
|
||||||
style={btnStyle}
|
style={btnStyle}
|
||||||
className={`${btnBase} ${
|
title={
|
||||||
sidebarOpen && railView === item.id
|
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||||
? "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;
|
|
||||||
}
|
}
|
||||||
onRailClick(item.view);
|
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
||||||
}}
|
|
||||||
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) }}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
<RailIcon>
|
||||||
</span>
|
<Pin size={16} />
|
||||||
<span
|
</RailIcon>
|
||||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
<RailLabel expanded={railExpanded}>
|
||||||
>
|
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
||||||
{item.title}
|
</RailLabel>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
<Rule expanded={railExpanded} />
|
||||||
))}
|
</>
|
||||||
<div className="mx-2 my-1 border-t border-border" />
|
)}
|
||||||
|
{footerItems.map(renderItem)}
|
||||||
|
{footerItems.length > 0 && <Rule expanded={railExpanded} />}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onLogout({ manual: true })}
|
onClick={() => onLogout({ manual: true })}
|
||||||
style={btnStyle}
|
style={btnStyle}
|
||||||
title={t("common.logout")}
|
title={t("common.logout")}
|
||||||
aria-label={t("common.logout")}
|
aria-label={t("common.logout")}
|
||||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||||
>
|
>
|
||||||
<span
|
<RailIcon>
|
||||||
className="shrink-0 flex items-center justify-center"
|
|
||||||
style={{ width: rem(16), height: rem(16) }}
|
|
||||||
>
|
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
</span>
|
</RailIcon>
|
||||||
<span
|
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-border">
|
<div className="shrink-0 border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => onRailClick("user-profile")}
|
type="button"
|
||||||
title={t("nav.userProfile")}
|
onClick={onOpenSettings}
|
||||||
aria-label={t("nav.userProfile")}
|
title={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"
|
aria-label={t("nav.openSettings")}
|
||||||
style={{ padding: "0 8px" }}
|
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
|
<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"
|
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">
|
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||||
{username || "User"}
|
{username || t("nav.roleUser")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||||
@@ -589,79 +445,101 @@ export function AppRail({
|
|||||||
{menuPos && (
|
{menuPos && (
|
||||||
<div
|
<div
|
||||||
data-rail-context-menu
|
data-rail-context-menu
|
||||||
|
role="menu"
|
||||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
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 && (
|
||||||
<>
|
<>
|
||||||
{menuTarget.promotable && (
|
{menuTarget.promotable && (
|
||||||
<button
|
<MenuItem
|
||||||
|
icon={<SquareArrowOutUpRight className="size-3" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenTab?.(menuTarget.view as TabType);
|
onOpenTab?.(menuTarget.id as TabType);
|
||||||
setMenuPos(null);
|
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")}
|
{t("nav.openAsTab")}
|
||||||
</button>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{menuTarget.rightDockable && (
|
{menuTarget.rightDockable && (
|
||||||
<button
|
<MenuItem
|
||||||
|
icon={<PanelRight className="size-3" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenInRightDock?.(menuTarget.view);
|
onOpenInRightDock?.(menuTarget.id);
|
||||||
setMenuPos(null);
|
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")}
|
{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" />
|
<div className="h-px bg-border my-1" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<MenuItem
|
||||||
|
icon={pinned ? <Check className="size-3" /> : null}
|
||||||
onClick={togglePinned}
|
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"
|
checked={pinned}
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={pinned}
|
|
||||||
>
|
>
|
||||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
||||||
{pinned && <Check className="size-3" />}
|
|
||||||
</span>
|
|
||||||
{t("newUi.sidebar.userProfile.pinAppRail")}
|
{t("newUi.sidebar.userProfile.pinAppRail")}
|
||||||
</button>
|
</MenuItem>
|
||||||
<button
|
<MenuItem
|
||||||
|
icon={expandOnHover ? <Check className="size-3" /> : null}
|
||||||
onClick={toggleExpandOnHover}
|
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"
|
checked={expandOnHover}
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={expandOnHover}
|
|
||||||
>
|
>
|
||||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
||||||
{expandOnHover && <Check className="size-3" />}
|
|
||||||
</span>
|
|
||||||
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||||
</button>
|
</MenuItem>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<MenuItem
|
||||||
|
icon={<SlidersHorizontal className="size-3" />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRailClick("user-profile");
|
onOpenSettings();
|
||||||
setMenuPos(null);
|
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">
|
{t("nav.openSettings")}
|
||||||
<SlidersHorizontal className="size-3" />
|
</MenuItem>
|
||||||
</span>
|
|
||||||
{t("nav.sidebarSettings")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { getTabType, isPersistentTabType } from "@/shell/tab-registry";
|
||||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
@@ -298,6 +299,7 @@ export function ConnectionsPanel({
|
|||||||
onReorderTabs?: (tabs: Tab[]) => void;
|
onReorderTabs?: (tabs: Tab[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
|
|
||||||
const [persistMinutes, setPersistMinutes] = useState(30);
|
const [persistMinutes, setPersistMinutes] = useState(30);
|
||||||
@@ -597,6 +599,13 @@ export function ConnectionsPanel({
|
|||||||
onReopenTab(record, liveSession?.sessionId ?? null);
|
onReopenTab(record, liveSession?.sessionId ?? null);
|
||||||
}}
|
}}
|
||||||
onClose={async () => {
|
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(() => {});
|
await deleteOpenTab(record.id).catch(() => {});
|
||||||
onForgetBackground(record.id);
|
onForgetBackground(record.id);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type React from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { EditorPane, EditorSection } from "@/manage/EditorPane";
|
||||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { copyToClipboard } from "@/lib/clipboard";
|
import { copyToClipboard } from "@/lib/clipboard";
|
||||||
@@ -23,7 +25,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
|
|||||||
|
|
||||||
export function CredentialEditorView({
|
export function CredentialEditorView({
|
||||||
credential,
|
credential,
|
||||||
activeTab,
|
banner,
|
||||||
onBack,
|
onBack,
|
||||||
onSave,
|
onSave,
|
||||||
adminTargetUserId,
|
adminTargetUserId,
|
||||||
@@ -31,7 +33,8 @@ export function CredentialEditorView({
|
|||||||
saveAsNewHost,
|
saveAsNewHost,
|
||||||
}: {
|
}: {
|
||||||
credential: Credential | null;
|
credential: Credential | null;
|
||||||
activeTab: string;
|
/** Shown above the form. */
|
||||||
|
banner?: React.ReactNode;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSave: (
|
onSave: (
|
||||||
saved: Record<string, unknown>,
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<EditorPane
|
||||||
{activeTab === "general" && (
|
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
|
<SectionCard
|
||||||
title={t("hosts.basicInformation")}
|
title={t("hosts.basicInformation")}
|
||||||
icon={<Info className="size-3.5" />}
|
icon={<Info className="size-3.5" />}
|
||||||
@@ -265,9 +332,9 @@ export function CredentialEditorView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
</EditorSection>
|
||||||
|
|
||||||
{activeTab === "auth" && (
|
<EditorSection id="auth" showHeading={false}>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={t("hosts.authDetailsSection")}
|
title={t("hosts.authDetailsSection")}
|
||||||
icon={<Lock className="size-3.5" />}
|
icon={<Lock className="size-3.5" />}
|
||||||
@@ -544,42 +611,7 @@ export function CredentialEditorView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
</EditorSection>
|
||||||
|
</EditorPane>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Check,
|
Check,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Search,
|
Search,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
|
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -72,6 +72,7 @@ export function CredentialShareModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [shareUsers, setShareUsers] = useState<
|
const [shareUsers, setShareUsers] = useState<
|
||||||
@@ -228,6 +229,13 @@ export function CredentialShareModal({
|
|||||||
|
|
||||||
async function handleRevoke(record: AccessRecord) {
|
async function handleRevoke(record: AccessRecord) {
|
||||||
if (!credentialId) return;
|
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 {
|
try {
|
||||||
await revokeCredentialAccess(credentialId, record.id);
|
await revokeCredentialAccess(credentialId, record.id);
|
||||||
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
|
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
|
||||||
@@ -237,20 +245,16 @@ export function CredentialShareModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!credential) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
<InlineView
|
||||||
<button
|
open={!!credential}
|
||||||
onClick={onClose}
|
onOpenChange={(open) => {
|
||||||
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"
|
if (!open) onClose();
|
||||||
>
|
}}
|
||||||
<ArrowLeft className="size-3.5 shrink-0" />
|
icon={<Share2 className="size-4" />}
|
||||||
<span className="truncate">
|
title={t("credentials.share.title", { name: credential?.name ?? "" })}
|
||||||
{t("credentials.share.title", { name: credential.name ?? "" })}
|
bare
|
||||||
</span>
|
>
|
||||||
</button>
|
|
||||||
|
|
||||||
{loadError && (
|
{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">
|
<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" />
|
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||||
@@ -570,6 +574,6 @@ export function CredentialShareModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</InlineView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
|
||||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||||
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
|
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
|
||||||
@@ -30,8 +31,7 @@ import {
|
|||||||
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
||||||
|
|
||||||
export function CredentialsPanel({
|
export function CredentialsPanel({
|
||||||
onEditingChange,
|
active: _active = true,
|
||||||
active = true,
|
|
||||||
}: {
|
}: {
|
||||||
onEditingChange?: (editing: boolean) => void;
|
onEditingChange?: (editing: boolean) => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -41,7 +41,6 @@ export function CredentialsPanel({
|
|||||||
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
||||||
useCredentialSidebarPreferences();
|
useCredentialSidebarPreferences();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [managerEditing, setManagerEditing] = useState(false);
|
|
||||||
const [allTags, setAllTags] = useState<string[]>([]);
|
const [allTags, setAllTags] = useState<string[]>([]);
|
||||||
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
||||||
|
|
||||||
@@ -94,14 +93,9 @@ export function CredentialsPanel({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditingChange(editing: boolean) {
|
|
||||||
setManagerEditing(editing);
|
|
||||||
onEditingChange?.(editing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<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 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">
|
<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" />
|
<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">
|
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.dispatchEvent(
|
requestManage({ kind: "credential", credentialId: null })
|
||||||
new CustomEvent("host-manager:add-credential"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
title={t("credentials.addCredential")}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<HostManager
|
<CredentialList
|
||||||
hideListHeader
|
search={search}
|
||||||
externalSearch={managerEditing ? undefined : search}
|
sort={sortKey}
|
||||||
externalSort={sortKey}
|
arrangeLocked={arrangeLocked}
|
||||||
externalArrangeLocked={arrangeLocked}
|
filter={filterState}
|
||||||
externalFilter={filterState}
|
|
||||||
density={sidebarPrefs.display.density}
|
density={sidebarPrefs.display.density}
|
||||||
trayTrigger={sidebarPrefs.display.trayTrigger}
|
trayTrigger={sidebarPrefs.display.trayTrigger}
|
||||||
showTags={sidebarPrefs.display.showTags}
|
showTags={sidebarPrefs.display.showTags}
|
||||||
onTagsChange={setAllTags}
|
onTagsChange={setAllTags}
|
||||||
onEditingChange={handleEditingChange}
|
|
||||||
active={active}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Rows3, SquareStack } from "lucide-react";
|
import { Rows3, SquareStack } from "lucide-react";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||||
import type {
|
import type {
|
||||||
CredentialDensity,
|
CredentialDensity,
|
||||||
@@ -32,108 +26,104 @@ export function CustomizeCredentialsSidebarPanel({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={t("credentials.customizeSidebar")}
|
||||||
{t("credentials.customizeSidebar")}
|
>
|
||||||
</DialogTitle>
|
<p className="text-xs text-muted-foreground">
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
{t("credentials.customizeSidebarDescription")}
|
||||||
{t("credentials.customizeSidebarDescription")}
|
</p>
|
||||||
</DialogDescription>
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
</DialogHeader>
|
<SectionCard
|
||||||
|
title={t("credentials.customizeDensityTitle")}
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
icon={<Rows3 className="size-3.5" />}
|
||||||
<SectionCard
|
>
|
||||||
title={t("credentials.customizeDensityTitle")}
|
<SettingRow
|
||||||
icon={<Rows3 className="size-3.5" />}
|
label={t("credentials.customizeDensityTitle")}
|
||||||
|
description={
|
||||||
|
preferences.display.density === "comfortable"
|
||||||
|
? t("credentials.densityComfortableDesc")
|
||||||
|
: t("credentials.densityCompactDesc")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SettingRow
|
<select
|
||||||
label={t("credentials.customizeDensityTitle")}
|
value={preferences.display.density}
|
||||||
description={
|
onChange={(e) =>
|
||||||
preferences.display.density === "comfortable"
|
update((prev) => ({
|
||||||
? t("credentials.densityComfortableDesc")
|
...prev,
|
||||||
: t("credentials.densityCompactDesc")
|
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
|
<option value="comfortable">
|
||||||
value={preferences.display.density}
|
{t("credentials.displayDensityComfortable")}
|
||||||
onChange={(e) =>
|
</option>
|
||||||
update((prev) => ({
|
<option value="compact">
|
||||||
...prev,
|
{t("credentials.displayDensityCompact")}
|
||||||
display: {
|
</option>
|
||||||
...prev.display,
|
</select>
|
||||||
density: e.target.value as CredentialDensity,
|
</SettingRow>
|
||||||
},
|
</SectionCard>
|
||||||
}))
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={t("credentials.customizeBehaviorTitle")}
|
title={t("credentials.customizeBehaviorTitle")}
|
||||||
icon={<SquareStack className="size-3.5" />}
|
icon={<SquareStack className="size-3.5" />}
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
label={t("credentials.showCredentialTags")}
|
||||||
|
description={t("credentials.showCredentialTagsDesc")}
|
||||||
>
|
>
|
||||||
<SettingRow
|
<FakeSwitch
|
||||||
label={t("credentials.showCredentialTags")}
|
checked={preferences.display.showTags}
|
||||||
description={t("credentials.showCredentialTagsDesc")}
|
onChange={(v) =>
|
||||||
>
|
update((prev) => ({
|
||||||
<FakeSwitch
|
...prev,
|
||||||
checked={preferences.display.showTags}
|
display: { ...prev.display, showTags: v },
|
||||||
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">
|
||||||
</SettingRow>
|
{t("credentials.actionsVisibility")}
|
||||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
</span>
|
||||||
<div className="flex flex-col gap-0.5">
|
<span className="text-xs text-muted-foreground leading-snug">
|
||||||
<span className="text-sm font-medium leading-snug">
|
{t("credentials.actionsVisibilityDesc")}
|
||||||
{t("credentials.actionsVisibility")}
|
</span>
|
||||||
</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>
|
|
||||||
</div>
|
</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">
|
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||||
{t("credentials.customizeSortHint")}
|
{t("credentials.customizeSortHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</InlineView>
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Rows3, SquareStack } from "lucide-react";
|
import { Rows3, SquareStack } from "lucide-react";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||||
import type {
|
import type {
|
||||||
HostClickBehavior,
|
HostClickBehavior,
|
||||||
@@ -33,186 +27,182 @@ export function CustomizeSidebarPanel({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={t("hosts.customizeSidebar")}
|
||||||
{t("hosts.customizeSidebar")}
|
>
|
||||||
</DialogTitle>
|
<p className="text-xs text-muted-foreground">
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
{t("hosts.customizeSidebarDescription")}
|
||||||
{t("hosts.customizeSidebarDescription")}
|
</p>
|
||||||
</DialogDescription>
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
</DialogHeader>
|
<SectionCard
|
||||||
|
title={t("hosts.customizeDensityTitle")}
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
icon={<Rows3 className="size-3.5" />}
|
||||||
<SectionCard
|
>
|
||||||
title={t("hosts.customizeDensityTitle")}
|
<SettingRow
|
||||||
icon={<Rows3 className="size-3.5" />}
|
label={t("hosts.customizeDensityTitle")}
|
||||||
|
description={
|
||||||
|
preferences.display.density === "comfortable"
|
||||||
|
? t("hosts.densityComfortableDesc")
|
||||||
|
: t("hosts.densityCompactDesc")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SettingRow
|
<select
|
||||||
label={t("hosts.customizeDensityTitle")}
|
value={preferences.display.density}
|
||||||
description={
|
onChange={(e) =>
|
||||||
preferences.display.density === "comfortable"
|
update((prev) => ({
|
||||||
? t("hosts.densityComfortableDesc")
|
...prev,
|
||||||
: t("hosts.densityCompactDesc")
|
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
|
<option value="comfortable">
|
||||||
value={preferences.display.density}
|
{t("hosts.displayDensityComfortable")}
|
||||||
onChange={(e) =>
|
</option>
|
||||||
update((prev) => ({
|
<option value="compact">
|
||||||
...prev,
|
{t("hosts.displayDensityCompact")}
|
||||||
display: {
|
</option>
|
||||||
...prev.display,
|
</select>
|
||||||
density: e.target.value as HostDensity,
|
</SettingRow>
|
||||||
},
|
</SectionCard>
|
||||||
}))
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={t("hosts.customizeBehaviorTitle")}
|
title={t("hosts.customizeBehaviorTitle")}
|
||||||
icon={<SquareStack className="size-3.5" />}
|
icon={<SquareStack className="size-3.5" />}
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
label={t("newUi.sidebar.userProfile.showHostTags")}
|
||||||
|
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
|
||||||
>
|
>
|
||||||
<SettingRow
|
<FakeSwitch
|
||||||
label={t("newUi.sidebar.userProfile.showHostTags")}
|
checked={preferences.display.showTags}
|
||||||
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
|
onChange={(v) =>
|
||||||
>
|
update((prev) => ({
|
||||||
<FakeSwitch
|
...prev,
|
||||||
checked={preferences.display.showTags}
|
display: { ...prev.display, showTags: v },
|
||||||
onChange={(v) =>
|
}))
|
||||||
update((prev) => ({
|
}
|
||||||
...prev,
|
/>
|
||||||
display: { ...prev.display, showTags: v },
|
</SettingRow>
|
||||||
}))
|
<SettingRow
|
||||||
}
|
label={t("hosts.showFolderPaths")}
|
||||||
/>
|
description={t("hosts.showFolderPathsDesc")}
|
||||||
</SettingRow>
|
>
|
||||||
<SettingRow
|
<FakeSwitch
|
||||||
label={t("hosts.showFolderPaths")}
|
checked={preferences.display.showFolderPaths}
|
||||||
description={t("hosts.showFolderPathsDesc")}
|
onChange={(v) =>
|
||||||
>
|
update((prev) => ({
|
||||||
<FakeSwitch
|
...prev,
|
||||||
checked={preferences.display.showFolderPaths}
|
display: { ...prev.display, showFolderPaths: v },
|
||||||
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">
|
||||||
</SettingRow>
|
{t("hosts.actionsVisibility")}
|
||||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
</span>
|
||||||
<div className="flex flex-col gap-0.5">
|
<span className="text-xs text-muted-foreground leading-snug">
|
||||||
<span className="text-sm font-medium leading-snug">
|
{t("hosts.actionsVisibilityDesc")}
|
||||||
{t("hosts.actionsVisibility")}
|
</span>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
<select
|
||||||
<div className="flex flex-col gap-0.5">
|
value={preferences.display.trayTrigger}
|
||||||
<span className="text-sm font-medium leading-snug">
|
onChange={(e) =>
|
||||||
{t("hosts.hostClickBehavior")}
|
update((prev) => ({
|
||||||
</span>
|
...prev,
|
||||||
<span className="text-xs text-muted-foreground leading-snug">
|
display: {
|
||||||
{t("hosts.hostClickBehaviorDesc")}
|
...prev.display,
|
||||||
</span>
|
trayTrigger: e.target.value as HostTrayTrigger,
|
||||||
</div>
|
},
|
||||||
<select
|
}))
|
||||||
value={preferences.display.hostClickBehavior}
|
}
|
||||||
onChange={(e) =>
|
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
update((prev) => ({
|
>
|
||||||
...prev,
|
<option value="always">{t("hosts.actionsAlways")}</option>
|
||||||
display: {
|
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
||||||
...prev.display,
|
<option value="hover">{t("hosts.actionsHover")}</option>
|
||||||
hostClickBehavior: e.target.value as HostClickBehavior,
|
<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">
|
||||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
<div className="flex flex-col gap-0.5">
|
||||||
>
|
<span className="text-sm font-medium leading-snug">
|
||||||
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
|
{t("hosts.hostClickBehavior")}
|
||||||
<option value="focusExisting">
|
</span>
|
||||||
{t("hosts.hostClickFocusExisting")}
|
<span className="text-xs text-muted-foreground leading-snug">
|
||||||
</option>
|
{t("hosts.hostClickBehaviorDesc")}
|
||||||
<option value="focusExistingDoubleClickNew">
|
</span>
|
||||||
{t("hosts.hostClickFocusExistingDoubleNew")}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<SettingRow
|
<select
|
||||||
label={t("hosts.openOnDoubleClick")}
|
value={preferences.display.hostClickBehavior}
|
||||||
description={t("hosts.openOnDoubleClickDesc")}
|
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
|
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
|
||||||
checked={preferences.display.openOnDoubleClick}
|
<option value="focusExisting">
|
||||||
disabled={
|
{t("hosts.hostClickFocusExisting")}
|
||||||
preferences.display.hostClickBehavior ===
|
</option>
|
||||||
"focusExistingDoubleClickNew"
|
<option value="focusExistingDoubleClickNew">
|
||||||
}
|
{t("hosts.hostClickFocusExistingDoubleNew")}
|
||||||
onChange={(v) =>
|
</option>
|
||||||
update((prev) => ({
|
</select>
|
||||||
...prev,
|
</div>
|
||||||
display: { ...prev.display, openOnDoubleClick: v },
|
<SettingRow
|
||||||
}))
|
label={t("hosts.openOnDoubleClick")}
|
||||||
}
|
description={t("hosts.openOnDoubleClickDesc")}
|
||||||
/>
|
>
|
||||||
</SettingRow>
|
<FakeSwitch
|
||||||
<SettingRow
|
checked={preferences.display.openOnDoubleClick}
|
||||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
disabled={
|
||||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
preferences.display.hostClickBehavior ===
|
||||||
>
|
"focusExistingDoubleClickNew"
|
||||||
<FakeSwitch
|
}
|
||||||
checked={preferences.display.statusColorScheme === "status"}
|
onChange={(v) =>
|
||||||
onChange={(v) =>
|
update((prev) => ({
|
||||||
update((prev) => ({
|
...prev,
|
||||||
...prev,
|
display: { ...prev.display, openOnDoubleClick: v },
|
||||||
display: {
|
}))
|
||||||
...prev.display,
|
}
|
||||||
statusColorScheme: v ? "status" : "accent",
|
/>
|
||||||
},
|
</SettingRow>
|
||||||
}))
|
<SettingRow
|
||||||
}
|
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||||
/>
|
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||||
</SettingRow>
|
>
|
||||||
</SectionCard>
|
<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">
|
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||||
{t("hosts.customizeSortHint")}
|
{t("hosts.customizeSortHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</InlineView>
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import {
|
import {
|
||||||
@@ -124,125 +118,126 @@ export function FolderMetadataDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent className="sm:max-w-md">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle className="text-lg font-bold">
|
title={
|
||||||
{mode === "create"
|
<>
|
||||||
? t("hosts.createFolderTitle")
|
{mode === "create"
|
||||||
: t("hosts.editFolderTitle")}
|
? t("hosts.createFolderTitle")
|
||||||
</DialogTitle>
|
: t("hosts.editFolderTitle")}
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
</>
|
||||||
{t("hosts.folderDialogDescription")}
|
}
|
||||||
</DialogDescription>
|
>
|
||||||
</DialogHeader>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
{t("hosts.folderDialogDescription")}
|
||||||
<div className="flex flex-col gap-1.5">
|
</p>
|
||||||
<label className="text-xs font-semibold">
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
{t("hosts.folderNameLabel")}{" "}
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-accent-brand">*</span>
|
<label className="text-xs font-semibold">
|
||||||
</label>
|
{t("hosts.folderNameLabel")}{" "}
|
||||||
<Input
|
<span className="text-accent-brand">*</span>
|
||||||
placeholder={t("hosts.folderNamePlaceholder")}
|
</label>
|
||||||
value={name}
|
<Input
|
||||||
onChange={(e) => setName(e.target.value)}
|
placeholder={t("hosts.folderNamePlaceholder")}
|
||||||
onKeyDown={(e) => {
|
value={name}
|
||||||
if (e.key === "Enter" && !isDuplicate) handleSubmit();
|
onChange={(e) => setName(e.target.value)}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
aria-invalid={isDuplicate}
|
if (e.key === "Enter" && !isDuplicate) handleSubmit();
|
||||||
className={isDuplicate ? "border-destructive" : undefined}
|
}}
|
||||||
/>
|
aria-invalid={isDuplicate}
|
||||||
{isDuplicate ? (
|
className={isDuplicate ? "border-destructive" : undefined}
|
||||||
<p className="text-[10px] text-destructive">
|
/>
|
||||||
{t("hosts.folderNameDuplicate")}
|
{isDuplicate ? (
|
||||||
</p>
|
<p className="text-[10px] text-destructive">
|
||||||
) : (
|
{t("hosts.folderNameDuplicate")}
|
||||||
<p className="text-[10px] text-muted-foreground">
|
</p>
|
||||||
{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>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{t("hosts.folderCredentialHint")}
|
{t("hosts.folderNestingHint")}
|
||||||
</p>
|
</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">
|
</div>
|
||||||
<label className="text-xs font-semibold">
|
<div className="flex flex-col gap-2">
|
||||||
{t("hosts.folderPreview")}
|
<label className="text-xs font-semibold">
|
||||||
</label>
|
{t("hosts.folderColor")}
|
||||||
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
|
</label>
|
||||||
<FolderIconEl
|
<HexColorPicker value={color} onChange={setColor} />
|
||||||
icon={icon}
|
</div>
|
||||||
className="size-4 shrink-0"
|
<div className="flex flex-col gap-2">
|
||||||
style={{ color }}
|
<label className="text-xs font-semibold">
|
||||||
/>
|
{t("hosts.folderIcon")}
|
||||||
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
|
</label>
|
||||||
{name
|
<IconPicker value={icon} color={color} onChange={setIcon} />
|
||||||
? splitPath(name).map((seg, i) => (
|
</div>
|
||||||
<span key={i} className="flex items-center gap-0.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{i > 0 && (
|
<label className="text-xs font-semibold">
|
||||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
|
{t("hosts.folderCredential")}
|
||||||
)}
|
</label>
|
||||||
{seg}
|
<select
|
||||||
</span>
|
value={credentialId}
|
||||||
))
|
onChange={(e) => setCredentialId(e.target.value)}
|
||||||
: t("hosts.folderNameFallback")}
|
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"
|
||||||
</span>
|
>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 mt-2">
|
</div>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
{t("hosts.cancel")}
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
</Button>
|
{t("hosts.cancel")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
variant="outline"
|
||||||
onClick={handleSubmit}
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
disabled={!name.trim() || isDuplicate}
|
onClick={handleSubmit}
|
||||||
>
|
disabled={!name.trim() || isDuplicate}
|
||||||
{mode === "create"
|
>
|
||||||
? t("hosts.createFolderButton")
|
{mode === "create"
|
||||||
: t("hosts.saveFolderButton")}
|
? t("hosts.createFolderButton")
|
||||||
</Button>
|
: t("hosts.saveFolderButton")}
|
||||||
</div>
|
</Button>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</InlineView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import {
|
import {
|
||||||
getCredentials,
|
getCredentials,
|
||||||
getHostAuthOverride,
|
getHostAuthOverride,
|
||||||
@@ -123,78 +116,18 @@ export function HostAuthOverrideModal({
|
|||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<InlineView
|
||||||
<DialogContent>
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={onOpenChange}
|
||||||
<DialogTitle>
|
title={
|
||||||
{t("hosts.sharing.authOverrideTitleProtocol", {
|
<>
|
||||||
protocol: authProtocolLabel(protocol, t),
|
{t("hosts.sharing.authOverrideTitleProtocol", {
|
||||||
})}
|
protocol: authProtocolLabel(protocol, t),
|
||||||
</DialogTitle>
|
})}
|
||||||
<DialogDescription>
|
</>
|
||||||
{t(
|
}
|
||||||
ownerAuthShared
|
footer={
|
||||||
? "hosts.sharing.authOverrideDescriptionShared"
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
: "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>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
@@ -212,9 +145,70 @@ export function HostAuthOverrideModal({
|
|||||||
>
|
>
|
||||||
{saving ? t("common.saving") : t("common.save")}
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
}
|
||||||
</Dialog>
|
>
|
||||||
|
<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>
|
</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 { connectionOriginAppliesTo } from "./HostEditorData";
|
||||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||||
|
import type { HostFormErrors } from "@/manage/host-validation";
|
||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { useHostProtocols } from "./host-protocols";
|
import { useHostProtocols } from "./host-protocols";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ export function HostEditorGeneralTab({
|
|||||||
hosts,
|
hosts,
|
||||||
host,
|
host,
|
||||||
simpleMode = false,
|
simpleMode = false,
|
||||||
|
errors = {},
|
||||||
}: {
|
}: {
|
||||||
form: HostEditorForm;
|
form: HostEditorForm;
|
||||||
setField: HostEditorSetField;
|
setField: HostEditorSetField;
|
||||||
@@ -44,6 +46,7 @@ export function HostEditorGeneralTab({
|
|||||||
host: Host | null;
|
host: Host | null;
|
||||||
/** Hides organizational/advanced fields; their values still save unchanged. */
|
/** Hides organizational/advanced fields; their values still save unchanged. */
|
||||||
simpleMode?: boolean;
|
simpleMode?: boolean;
|
||||||
|
errors?: HostFormErrors;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const syncLinked = !!useSyncStatus()?.linked;
|
const syncLinked = !!useSyncStatus()?.linked;
|
||||||
@@ -210,8 +213,14 @@ export function HostEditorGeneralTab({
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.hostAddress")}
|
placeholder={t("placeholders.hostAddress")}
|
||||||
value={form.ip}
|
value={form.ip}
|
||||||
|
aria-invalid={!!errors.ip}
|
||||||
onChange={(e) => setField("ip", e.target.value)}
|
onChange={(e) => setField("ip", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{errors.ip && (
|
||||||
|
<span className="text-[10px] text-destructive">
|
||||||
|
{t(errors.ip)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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 { getErrorMessage } from "../lib/error-message.js";
|
||||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -7,13 +8,6 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
import { Checkbox } from "@/components/checkbox";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
|
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
|
||||||
import { isFolder } from "@/sidebar/SidebarTree";
|
import { isFolder } from "@/sidebar/SidebarTree";
|
||||||
import {
|
import {
|
||||||
@@ -220,126 +214,13 @@ export function HostExportDialog({
|
|||||||
const canExport = !loading && count > 0;
|
const canExport = !loading && count > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
<InlineView
|
||||||
<DialogContent className="flex flex-col w-[95vw] sm:max-w-6xl max-h-[85dvh] overflow-hidden">
|
open={open}
|
||||||
<DialogHeader>
|
onOpenChange={(next) => !next && onClose()}
|
||||||
<DialogTitle>{t("hosts.export.title")}</DialogTitle>
|
title={t("hosts.export.title")}
|
||||||
</DialogHeader>
|
width="wide"
|
||||||
|
footer={
|
||||||
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
{/* 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">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{t("hosts.export.cancel")}
|
{t("hosts.export.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -352,8 +233,121 @@ export function HostExportDialog({
|
|||||||
<Download className="size-3.5 mr-2" />
|
<Download className="size-3.5 mr-2" />
|
||||||
{t("hosts.export.confirm")}
|
{t("hosts.export.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
}
|
||||||
</Dialog>
|
>
|
||||||
|
<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 */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import {
|
import { type ComponentType, type ReactNode } from "react";
|
||||||
useEffect,
|
import { KeyRound, Settings, Terminal } from "lucide-react";
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ComponentType,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
KeyRound,
|
|
||||||
Settings,
|
|
||||||
Terminal,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||||
|
|
||||||
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
||||||
@@ -196,115 +183,4 @@ export function makeCredentialTabs(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabStrip({
|
export { TabStrip } from "@/components/tab-strip";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
type FolderAccessRule,
|
type FolderAccessRule,
|
||||||
} from "@/api/rbac-api";
|
} from "@/api/rbac-api";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Check,
|
Check,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Search,
|
Search,
|
||||||
@@ -70,6 +70,7 @@ export function HostShareModal({
|
|||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const isFolderShare = !host && !!folder;
|
const isFolderShare = !host && !!folder;
|
||||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -278,23 +279,20 @@ export function HostShareModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
<InlineView
|
||||||
{/* Header */}
|
open={open}
|
||||||
<button
|
onOpenChange={(next) => {
|
||||||
onClick={onClose}
|
if (!next) 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"
|
}}
|
||||||
>
|
icon={<Share2 className="size-4" />}
|
||||||
<ArrowLeft className="size-3.5 shrink-0" />
|
title={
|
||||||
<span className="truncate">
|
isFolderShare
|
||||||
{isFolderShare
|
? t("hosts.shareFolderTitle", { name: folder ?? "" })
|
||||||
? t("hosts.shareFolderTitle", { name: folder ?? "" })
|
: t("hosts.shareHostTitle", { name: host?.name ?? "" })
|
||||||
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
}
|
||||||
</span>
|
bare
|
||||||
</button>
|
>
|
||||||
|
|
||||||
{sharingLoadError && (
|
{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">
|
<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" />
|
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||||
@@ -544,8 +542,19 @@ export function HostShareModal({
|
|||||||
type="button"
|
type="button"
|
||||||
className="text-[10px] text-muted-foreground hover:text-destructive"
|
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||||
title={t("hosts.sharing.folderRuleRemove")}
|
title={t("hosts.sharing.folderRuleRemove")}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
void revokeFolderAccess(rule.id).then(refreshFolderRules);
|
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" />
|
<X className="size-3.5" />
|
||||||
@@ -626,6 +635,17 @@ export function HostShareModal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||||
onClick={async () => {
|
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 {
|
try {
|
||||||
await revokeHostAccess(
|
await revokeHostAccess(
|
||||||
Number(host!.id),
|
Number(host!.id),
|
||||||
@@ -676,6 +696,6 @@ export function HostShareModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</InlineView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
|
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
|
||||||
import { HostManager } from "@/sidebar/HostManager";
|
|
||||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||||
import { HostExportDialog } from "@/sidebar/HostExportDialog";
|
import { HostExportDialog } from "@/sidebar/HostExportDialog";
|
||||||
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
|
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
|
||||||
@@ -200,7 +199,6 @@ export function HostsPanel({
|
|||||||
onEditHost,
|
onEditHost,
|
||||||
hostTree,
|
hostTree,
|
||||||
loading,
|
loading,
|
||||||
onEditingChange,
|
|
||||||
active = true,
|
active = true,
|
||||||
}: {
|
}: {
|
||||||
onOpenTab: (
|
onOpenTab: (
|
||||||
@@ -215,6 +213,7 @@ export function HostsPanel({
|
|||||||
onEditHost: (host: Host) => void;
|
onEditHost: (host: Host) => void;
|
||||||
hostTree?: HostFolder;
|
hostTree?: HostFolder;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** Unused since editing moved to the Manage tab. */
|
||||||
onEditingChange?: (editing: boolean) => void;
|
onEditingChange?: (editing: boolean) => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -238,7 +237,6 @@ export function HostsPanel({
|
|||||||
const hostProtocols = useHostProtocols();
|
const hostProtocols = useHostProtocols();
|
||||||
const sshAuthProviders = useSshAuthProviders();
|
const sshAuthProviders = useSshAuthProviders();
|
||||||
const [hostSearch, setHostSearch] = useState("");
|
const [hostSearch, setHostSearch] = useState("");
|
||||||
const [managerEditing, setManagerEditing] = useState(false);
|
|
||||||
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -345,11 +343,6 @@ export function HostsPanel({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleEditingChange(editing: boolean) {
|
|
||||||
setManagerEditing(editing);
|
|
||||||
onEditingChange?.(editing);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectionMode() {
|
function toggleSelectionMode() {
|
||||||
setSelectionMode((v) => !v);
|
setSelectionMode((v) => !v);
|
||||||
}
|
}
|
||||||
@@ -422,7 +415,7 @@ export function HostsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
<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 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">
|
<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" />
|
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||||
@@ -1023,7 +1016,7 @@ export function HostsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
<CustomizeSidebarPanel
|
<CustomizeSidebarPanel
|
||||||
open={customizePanelOpen}
|
open={customizePanelOpen}
|
||||||
onOpenChange={setCustomizePanelOpen}
|
onOpenChange={setCustomizePanelOpen}
|
||||||
@@ -1031,7 +1024,7 @@ export function HostsPanel({
|
|||||||
update={updateSidebarPrefs}
|
update={updateSidebarPrefs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{active && !managerEditing && (
|
{active && (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
children={
|
children={
|
||||||
@@ -1069,12 +1062,6 @@ export function HostsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
|
||||||
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
|
|
||||||
>
|
|
||||||
<HostManager onEditingChange={handleEditingChange} active={active} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HostShareModal
|
<HostShareModal
|
||||||
open={shareModalHost !== null}
|
open={shareModalHost !== null}
|
||||||
onClose={() => setShareModalHost(null)}
|
onClose={() => setShareModalHost(null)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -9,14 +10,6 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
@@ -45,10 +38,7 @@ export function InterfacePresetSettings({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ctx = useUiPreferencesContext();
|
const ctx = useUiPreferencesContext();
|
||||||
const [pendingPreset, setPendingPreset] = useState<Exclude<
|
const confirm = useConfirm();
|
||||||
UiPreset,
|
|
||||||
"custom"
|
|
||||||
> | null>(null);
|
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
|
|
||||||
// The chosen preset stays highlighted even once knobs diverge from it --
|
// The chosen preset stays highlighted even once knobs diverge from it --
|
||||||
@@ -87,18 +77,25 @@ export function InterfacePresetSettings({
|
|||||||
|
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
|
|
||||||
async function confirmPreset() {
|
async function choosePreset(preset: Exclude<UiPreset, "custom">) {
|
||||||
if (!pendingPreset) return;
|
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);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
ctx.setPreset(pendingPreset);
|
ctx.setPreset(preset);
|
||||||
// Picking a preset is a deliberate reset, so any knob the user had
|
// Picking a preset is a deliberate reset, so any knob the user had
|
||||||
// pinned goes back to following the preset.
|
// pinned goes back to following the preset.
|
||||||
ctx.clearAllOverrides();
|
ctx.clearAllOverrides();
|
||||||
await applyPresetSideEffects(pendingPreset);
|
await applyPresetSideEffects(preset);
|
||||||
} finally {
|
} finally {
|
||||||
setApplying(false);
|
setApplying(false);
|
||||||
setPendingPreset(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +115,8 @@ export function InterfacePresetSettings({
|
|||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPendingPreset(preset)}
|
disabled={applying}
|
||||||
|
onClick={() => void choosePreset(preset)}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
|
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
|
||||||
active
|
active
|
||||||
@@ -203,44 +201,6 @@ export function InterfacePresetSettings({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</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 { useState, useRef, useEffect } from "react";
|
||||||
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -26,14 +27,6 @@ import { shell } from "@/plugin-host/shell-bridge";
|
|||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import { VersionBadge } from "@/components/version-badge";
|
import { VersionBadge } from "@/components/version-badge";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/dialog";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -56,8 +49,8 @@ import {
|
|||||||
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
||||||
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
|
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
|
||||||
import { InterfacePresetSettings } from "./InterfacePresetSettings";
|
import { InterfacePresetSettings } from "./InterfacePresetSettings";
|
||||||
|
import { useSettingsEmbedded } from "@/settings/settings-embed";
|
||||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||||
import { KeybindingsDialog } from "./KeybindingsDialog";
|
|
||||||
import {
|
import {
|
||||||
ACCENT_PRESET_COLORS,
|
ACCENT_PRESET_COLORS,
|
||||||
applyAccentColor,
|
applyAccentColor,
|
||||||
@@ -157,7 +150,19 @@ export function AccordionSection({
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const embedded = useSettingsEmbedded();
|
||||||
if (hidden) return null;
|
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 (
|
return (
|
||||||
<div className="border border-border bg-card overflow-hidden">
|
<div className="border border-border bg-card overflow-hidden">
|
||||||
@@ -253,88 +258,14 @@ export function NewApiKeyDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<InlineView
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (nextOpen || !createdToken) onOpenChange(nextOpen);
|
if (nextOpen || !createdToken) onOpenChange(nextOpen);
|
||||||
}}
|
}}
|
||||||
>
|
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
||||||
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
|
footer={
|
||||||
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
<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">
|
|
||||||
{createdToken ? (
|
{createdToken ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -363,9 +294,68 @@ export function NewApiKeyDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
}
|
||||||
</Dialog>
|
>
|
||||||
|
<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,
|
onLogout,
|
||||||
userPrefs,
|
userPrefs,
|
||||||
onPrefsChange,
|
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;
|
username?: string;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
userPrefs?: {
|
userPrefs?: {
|
||||||
@@ -509,6 +505,7 @@ export function UserProfilePanel({
|
|||||||
}) => void;
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const themeLabel: Record<ThemeId, string> = {
|
const themeLabel: Record<ThemeId, string> = {
|
||||||
system: t("newUi.sidebar.userProfile.themeSystem"),
|
system: t("newUi.sidebar.userProfile.themeSystem"),
|
||||||
light: t("newUi.sidebar.userProfile.themeLight"),
|
light: t("newUi.sidebar.userProfile.themeLight"),
|
||||||
@@ -603,7 +600,6 @@ export function UserProfilePanel({
|
|||||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||||
return v !== null ? v === "true" : true;
|
return v !== null ? v === "true" : true;
|
||||||
});
|
});
|
||||||
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
|
|
||||||
// Sidebar display customization (density, tags, tray trigger, status
|
// Sidebar display customization (density, tags, tray trigger, status
|
||||||
// colors) now lives in the dedicated Customize Sidebar panel opened from
|
// colors) now lives in the dedicated Customize Sidebar panel opened from
|
||||||
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
|
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
|
||||||
@@ -1182,9 +1178,18 @@ export function UserProfilePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canChangePasword = !isExternal || isDualAuth;
|
const canChangePasword = !isExternal || isDualAuth;
|
||||||
|
const isOpen = (id: string) =>
|
||||||
|
section ? section === id : openSections.has(id as UserProfileSection);
|
||||||
|
const shows = (id: string) => !section || section === id;
|
||||||
|
|
||||||
return (
|
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
|
<NewApiKeyDialog
|
||||||
open={newKeyOpen}
|
open={newKeyOpen}
|
||||||
onOpenChange={setNewKeyOpen}
|
onOpenChange={setNewKeyOpen}
|
||||||
@@ -1193,30 +1198,32 @@ export function UserProfilePanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Donate banner */}
|
{/* Donate banner */}
|
||||||
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
{shows("account") && (
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
||||||
{t("newUi.sidebar.userProfile.donateTitle")}
|
<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>
|
</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
|
{/* Storage mode toggle — only meaningful once a remote server is
|
||||||
connected; with no sync there's nowhere for "cloud" to sync to,
|
connected; with no sync there's nowhere for "cloud" to sync to,
|
||||||
so this stays forced to local storage and hidden. */}
|
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">
|
<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">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
||||||
@@ -1261,7 +1268,7 @@ export function UserProfilePanel({
|
|||||||
id="account"
|
id="account"
|
||||||
label={t("newUi.sidebar.userProfile.sectionAccount")}
|
label={t("newUi.sidebar.userProfile.sectionAccount")}
|
||||||
icon={<User className="size-3.5" />}
|
icon={<User className="size-3.5" />}
|
||||||
open={openSections.has("account")}
|
open={isOpen("account")}
|
||||||
onToggle={() => toggle("account")}
|
onToggle={() => toggle("account")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0 pt-2">
|
<div className="flex flex-col gap-0 pt-2">
|
||||||
@@ -1447,7 +1454,7 @@ export function UserProfilePanel({
|
|||||||
id="interface"
|
id="interface"
|
||||||
label={t("newUi.sidebar.userProfile.sectionInterface")}
|
label={t("newUi.sidebar.userProfile.sectionInterface")}
|
||||||
icon={<LayoutTemplate size={13} />}
|
icon={<LayoutTemplate size={13} />}
|
||||||
open={openSections.has("interface")}
|
open={isOpen("interface")}
|
||||||
onToggle={() => toggle("interface")}
|
onToggle={() => toggle("interface")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 pt-3">
|
<div className="flex flex-col gap-4 pt-3">
|
||||||
@@ -1464,7 +1471,7 @@ export function UserProfilePanel({
|
|||||||
id="appearance"
|
id="appearance"
|
||||||
label={t("newUi.sidebar.userProfile.sectionAppearance")}
|
label={t("newUi.sidebar.userProfile.sectionAppearance")}
|
||||||
icon={<Palette className="size-3.5" />}
|
icon={<Palette className="size-3.5" />}
|
||||||
open={openSections.has("appearance")}
|
open={isOpen("appearance")}
|
||||||
onToggle={() => toggle("appearance")}
|
onToggle={() => toggle("appearance")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 pt-3">
|
<div className="flex flex-col gap-4 pt-3">
|
||||||
@@ -1641,7 +1648,7 @@ export function UserProfilePanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setKeybindingsDialogOpen(true)}
|
onClick={() => onOpenSettingsPage?.("keybindings")}
|
||||||
>
|
>
|
||||||
{t("newUi.sidebar.userProfile.manageShortcuts")}
|
{t("newUi.sidebar.userProfile.manageShortcuts")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1812,7 +1819,7 @@ export function UserProfilePanel({
|
|||||||
id="security"
|
id="security"
|
||||||
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
||||||
icon={<Shield className="size-3.5" />}
|
icon={<Shield className="size-3.5" />}
|
||||||
open={openSections.has("security")}
|
open={isOpen("security")}
|
||||||
onToggle={() => toggle("security")}
|
onToggle={() => toggle("security")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 pt-3">
|
<div className="flex flex-col gap-4 pt-3">
|
||||||
@@ -1833,7 +1840,7 @@ export function UserProfilePanel({
|
|||||||
id="api-keys"
|
id="api-keys"
|
||||||
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
|
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
|
||||||
icon={<Network className="size-3.5" />}
|
icon={<Network className="size-3.5" />}
|
||||||
open={openSections.has("api-keys")}
|
open={isOpen("api-keys")}
|
||||||
onToggle={() => toggle("api-keys")}
|
onToggle={() => toggle("api-keys")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 pt-3">
|
<div className="flex flex-col gap-2 pt-3">
|
||||||
@@ -1896,6 +1903,13 @@ export function UserProfilePanel({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-muted-foreground hover:text-destructive"
|
className="size-6 text-muted-foreground hover:text-destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("admin.revokeKeyConfirm", {
|
||||||
|
name: key.name,
|
||||||
|
}),
|
||||||
|
confirmLabel: t("admin.revokeKey"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteApiKey(key.id);
|
await deleteApiKey(key.id);
|
||||||
setApiKeys((prev) =>
|
setApiKeys((prev) =>
|
||||||
@@ -1938,7 +1952,7 @@ export function UserProfilePanel({
|
|||||||
id="data"
|
id="data"
|
||||||
label={t("newUi.sidebar.userProfile.sectionData")}
|
label={t("newUi.sidebar.userProfile.sectionData")}
|
||||||
icon={<Database className="size-3.5" />}
|
icon={<Database className="size-3.5" />}
|
||||||
open={openSections.has("data")}
|
open={isOpen("data")}
|
||||||
onToggle={() => toggle("data")}
|
onToggle={() => toggle("data")}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 pt-3">
|
<div className="flex flex-col gap-3 pt-3">
|
||||||
@@ -2013,78 +2027,69 @@ export function UserProfilePanel({
|
|||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
scope="user"
|
scope="user"
|
||||||
open={openSections.has(featureSectionId(plugin.id))}
|
open={isOpen(featureSectionId(plugin.id))}
|
||||||
onToggle={() => toggle(featureSectionId(plugin.id))}
|
onToggle={() => toggle(featureSectionId(plugin.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Delete account dialog */}
|
{/* Delete account dialog */}
|
||||||
<Dialog
|
|
||||||
|
<InlineView
|
||||||
open={showDeleteConfirm}
|
open={showDeleteConfirm}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setShowDeleteConfirm(open);
|
setShowDeleteConfirm(open);
|
||||||
if (!open) setDeletePassword("");
|
if (!open) setDeletePassword("");
|
||||||
}}
|
}}
|
||||||
|
title={t("newUi.sidebar.userProfile.deleteAccount")}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-md">
|
<p className="text-xs text-muted-foreground">
|
||||||
<DialogHeader>
|
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
|
||||||
<DialogTitle className="text-lg font-bold text-destructive">
|
</p>
|
||||||
{t("newUi.sidebar.userProfile.deleteAccount")}
|
<div className="flex flex-col gap-3 mt-1">
|
||||||
</DialogTitle>
|
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||||
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
|
<span className="text-xs text-destructive">
|
||||||
</DialogDescription>
|
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
|
||||||
</DialogHeader>
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 mt-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Button
|
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
variant="ghost"
|
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
|
||||||
onClick={() => {
|
</label>
|
||||||
setShowDeleteConfirm(false);
|
<Input
|
||||||
setDeletePassword("");
|
type="password"
|
||||||
}}
|
placeholder={t(
|
||||||
>
|
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
|
||||||
{t("newUi.sidebar.userProfile.cancel")}
|
)}
|
||||||
</Button>
|
value={deletePassword}
|
||||||
<Button
|
onChange={(e) => setDeletePassword(e.target.value)}
|
||||||
variant="outline"
|
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
|
||||||
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>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
<KeybindingsDialog
|
<Button
|
||||||
open={keybindingsDialogOpen}
|
variant="ghost"
|
||||||
onOpenChange={setKeybindingsDialogOpen}
|
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>
|
</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> {
|
export function readHiddenRailTabs(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("hiddenRailTabs");
|
const raw = localStorage.getItem("hiddenRailTabs");
|
||||||
@@ -8,3 +13,47 @@ export function readHiddenRailTabs(): Set<string> {
|
|||||||
return new Set();
|
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 { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
|
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
|
||||||
import { HostEditor } from "../HostEditor";
|
import { HostEditor } from "../HostEditor";
|
||||||
import {
|
import { useHostEditorSections } from "../HostManagerTabs";
|
||||||
isSshGroupTab,
|
|
||||||
makeHostSshSubTabs,
|
|
||||||
makeHostTabs,
|
|
||||||
TabStrip,
|
|
||||||
useHostEditorSections,
|
|
||||||
} from "../HostManagerTabs";
|
|
||||||
import { listHostProtocols, type HostProtocols } from "../host-protocols";
|
import { listHostProtocols, type HostProtocols } from "../host-protocols";
|
||||||
|
|
||||||
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
|
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
|
||||||
@@ -18,8 +12,18 @@ export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
|
|||||||
folderName?: string;
|
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,
|
* and fields as a new host, minus what only one host can have (its name,
|
||||||
* address, secrets, folder).
|
* address, secrets, folder).
|
||||||
*/
|
*/
|
||||||
@@ -38,25 +42,14 @@ export function HostDefaultsEditorView({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useHostEditorSections();
|
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 protocols = useMemo<HostProtocols>(() => {
|
||||||
const all: HostProtocols = { enableSsh: true };
|
const all: HostProtocols = { enableSsh: true };
|
||||||
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
|
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
|
||||||
return all;
|
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 =
|
const description =
|
||||||
target.level === "admin"
|
target.level === "admin"
|
||||||
? t("hostDefaults.descriptionAdmin")
|
? t("hostDefaults.descriptionAdmin")
|
||||||
@@ -65,61 +58,22 @@ export function HostDefaultsEditorView({
|
|||||||
: t("hostDefaults.descriptionFolder");
|
: t("hostDefaults.descriptionFolder");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<HostEditor
|
||||||
<div className="flex flex-col shrink-0 border-b border-border">
|
key={`${target.level}:${target.folderId ?? ""}`}
|
||||||
<button
|
host={null}
|
||||||
onClick={onClose}
|
banner={
|
||||||
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"
|
<p className="border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<HostEditor
|
}
|
||||||
key={`${target.level}:${target.folderId ?? ""}`}
|
onBack={onClose}
|
||||||
host={null}
|
onSave={onClose}
|
||||||
activeTab={activeTab}
|
protocols={protocols}
|
||||||
onBack={onClose}
|
onProtocolChange={() => {}}
|
||||||
onSave={onClose}
|
onDirtyChange={onDirtyChange}
|
||||||
protocols={protocols}
|
hosts={hosts}
|
||||||
onProtocolChange={() => {}}
|
credentials={credentials}
|
||||||
onDirtyChange={onDirtyChange}
|
defaultsTarget={target}
|
||||||
onTabChange={setActiveTab}
|
/>
|
||||||
hosts={hosts}
|
|
||||||
credentials={credentials}
|
|
||||||
defaultsTarget={target}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Plug,
|
Plug,
|
||||||
Server,
|
Server,
|
||||||
|
LibraryBig,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
|
||||||
Zap,
|
Zap,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} 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
|
* This used to be duplicated in four places -- AppRail's button array, the
|
||||||
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
|
* 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,
|
* titles were hardcoded English, the alerts entry had no visibility toggle,
|
||||||
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
|
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
|
||||||
* here, so adding a destination is a single edit.
|
* here, so adding a destination is a single edit.
|
||||||
@@ -38,6 +38,8 @@ export interface RailItemDef {
|
|||||||
alwaysVisible?: boolean;
|
alwaysVisible?: boolean;
|
||||||
/** Renders a separator after this item in the rail. */
|
/** Renders a separator after this item in the rail. */
|
||||||
separatorAfter?: boolean;
|
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. */
|
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
|
||||||
mobilePrimary?: boolean;
|
mobilePrimary?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -72,13 +74,24 @@ export interface RailItemDef {
|
|||||||
useBadge?: () => number | null | undefined;
|
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[] = [
|
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",
|
id: "credentials",
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
labelKey: "nav.credentials",
|
labelKey: "nav.credentials",
|
||||||
separatorAfter: true,
|
separatorAfter: true,
|
||||||
|
group: "objects",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "connections",
|
id: "connections",
|
||||||
@@ -86,6 +99,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
|||||||
labelKey: "nav.connections",
|
labelKey: "nav.connections",
|
||||||
separatorAfter: true,
|
separatorAfter: true,
|
||||||
rightDockable: true,
|
rightDockable: true,
|
||||||
|
group: "tools",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "quick-connect",
|
id: "quick-connect",
|
||||||
@@ -93,6 +107,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
|||||||
labelKey: "nav.quickConnect",
|
labelKey: "nav.quickConnect",
|
||||||
separatorAfter: true,
|
separatorAfter: true,
|
||||||
mobilePrimary: true,
|
mobilePrimary: true,
|
||||||
|
group: "tools",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sync",
|
id: "sync",
|
||||||
@@ -100,10 +115,21 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
|||||||
labelKey: "nav.sync",
|
labelKey: "nav.sync",
|
||||||
electronOnly: true,
|
electronOnly: true,
|
||||||
placement: "footer",
|
placement: "footer",
|
||||||
|
group: "system",
|
||||||
useBadge: useSyncAttentionCount,
|
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
|
* 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
|
* be enabled or disabled while the app is open and the rail, the mobile bar
|
||||||
@@ -203,13 +229,10 @@ export function useRailItems(): RailItemDef[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Places outside the rail that still need a title, like the Settings tab. */
|
||||||
* Destinations that live outside the rail's hideable list but still need a
|
|
||||||
* title and a mobile entry.
|
|
||||||
*/
|
|
||||||
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
||||||
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
|
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
|
||||||
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
|
{ id: "host-manager", icon: LibraryBig, labelKey: "nav.manage" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Ids that may be opened in the right dock. */
|
/** 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" />
|
<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="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${host.cpu}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1281,7 +1281,7 @@ export function HostItem({
|
|||||||
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
|
<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="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${host.ram}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
|
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -179,10 +180,15 @@ export function SidebarTree({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectionMode) setSelectedHostIds(new Set());
|
if (!selectionMode) setSelectedHostIds(new Set());
|
||||||
}, [selectionMode, setSelectedHostIds]);
|
}, [selectionMode, setSelectedHostIds]);
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const confirm = useConfirm();
|
||||||
message: string;
|
const setConfirmDialog = (
|
||||||
onConfirm: () => Promise<void> | void;
|
request: { message: string; onConfirm: () => Promise<void> | void } | null,
|
||||||
} | null>(null);
|
) => {
|
||||||
|
if (!request) return;
|
||||||
|
void confirm({ title: request.message }).then((ok) => {
|
||||||
|
if (ok) void request.onConfirm();
|
||||||
|
});
|
||||||
|
};
|
||||||
const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } =
|
const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } =
|
||||||
useSidebarDragState();
|
useSidebarDragState();
|
||||||
const [folderDialog, setFolderDialog] = useState<{
|
const [folderDialog, setFolderDialog] = useState<{
|
||||||
@@ -1437,32 +1443,6 @@ export function SidebarTree({
|
|||||||
</div>
|
</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
|
<FolderMetadataDialog
|
||||||
open={folderDialog !== null}
|
open={folderDialog !== null}
|
||||||
mode={folderDialog?.mode ?? "create"}
|
mode={folderDialog?.mode ?? "create"}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/button.tsx";
|
|
||||||
import { Input } from "@/components/input.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 { Shield, Copy, ExternalLink, Check } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -50,91 +54,76 @@ export function BrowserSignInDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200 overflow-y-auto">
|
<PanePrompt
|
||||||
<div
|
open
|
||||||
className="absolute inset-0 bg-canvas"
|
layer="connection"
|
||||||
style={{ backgroundColor: backgroundColor || undefined }}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
icon={<Shield className="size-4" />}
|
||||||
<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">
|
title={t("sshAuth.browserSignInRequired", { provider: label })}
|
||||||
<div className="p-4 border-b border-border">
|
className="max-w-md"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Shield className="size-4 text-accent-brand" />
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
{code && code !== "N/A" && (
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("sshAuth.browserSignInUrl")}
|
{t("sshAuth.browserSignInCode")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="border border-border bg-muted/10 p-4 text-center">
|
||||||
<Input
|
<div className="text-2xl font-mono font-bold tracking-wider text-accent-brand">
|
||||||
type="text"
|
{code}
|
||||||
value={url}
|
</div>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Button
|
<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"
|
type="button"
|
||||||
variant="ghost"
|
onClick={handleCopyUrl}
|
||||||
onClick={onCancel}
|
className={`${PROMPT_BUTTON} h-9 w-9 shrink-0 px-0`}
|
||||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest sm:mr-auto"
|
title={t("common.copy")}
|
||||||
|
aria-label={t("common.copy")}
|
||||||
>
|
>
|
||||||
{t("common.cancel")}
|
{copied ? (
|
||||||
</Button>
|
<Check className="size-4 text-accent-brand" />
|
||||||
<Button
|
) : (
|
||||||
type="button"
|
<Copy className="size-4" />
|
||||||
variant="outline"
|
)}
|
||||||
onClick={onContinue}
|
</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</PanePrompt>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/button.tsx";
|
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 { Shield, AlertTriangle, Copy, Check } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { copyToClipboard } from "@/lib/clipboard";
|
import { copyToClipboard } from "@/lib/clipboard";
|
||||||
@@ -20,6 +25,47 @@ interface HostKeyVerificationDialogProps {
|
|||||||
backgroundColor?: string;
|
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({
|
export function HostKeyVerificationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
scenario,
|
scenario,
|
||||||
@@ -50,168 +96,101 @@ export function HostKeyVerificationDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFingerprint = (fp: string) =>
|
const changed = scenario === "changed";
|
||||||
fp.match(/.{1,2}/g)?.join(":") || fp;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
<PanePrompt
|
||||||
<div
|
open
|
||||||
className="absolute inset-0 bg-canvas rounded-md"
|
layer="connection"
|
||||||
style={{ backgroundColor: backgroundColor || undefined }}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
tone={changed ? "destructive" : "default"}
|
||||||
<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">
|
icon={
|
||||||
<div className="p-4 border-b border-border">
|
changed ? (
|
||||||
<div className="flex items-center gap-2">
|
<AlertTriangle className="size-4" />
|
||||||
{scenario === "new" ? (
|
) : (
|
||||||
<Shield className="size-4 text-accent-brand" />
|
<Shield className="size-4" />
|
||||||
) : (
|
)
|
||||||
<AlertTriangle className="size-4 text-destructive" />
|
}
|
||||||
)}
|
title={
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
changed ? t("hostKey.keyChangedWarning") : t("hostKey.verifyNewHost")
|
||||||
{scenario === "new"
|
}
|
||||||
? t("hostKey.verifyNewHost")
|
description={
|
||||||
: t("hostKey.keyChangedWarning")}
|
<span className="font-mono">
|
||||||
</h3>
|
{hostname || ip}:{port}
|
||||||
</div>
|
</span>
|
||||||
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
|
}
|
||||||
{hostname || ip}:{port}
|
className="max-w-lg"
|
||||||
</p>
|
actions={
|
||||||
</div>
|
<>
|
||||||
|
<button type="button" onClick={onReject} className={PROMPT_BUTTON}>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAccept}
|
onClick={onAccept}
|
||||||
variant="outline"
|
|
||||||
className={
|
className={
|
||||||
scenario === "changed"
|
changed ? PROMPT_DESTRUCTIVE_BUTTON : PROMPT_PRIMARY_BUTTON
|
||||||
? "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"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{scenario === "new"
|
{changed
|
||||||
? t("hostKey.acceptAndContinue")
|
? t("hostKey.acceptNewKey")
|
||||||
: t("hostKey.acceptNewKey")}
|
: t("hostKey.acceptAndContinue")}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</PanePrompt>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/button.tsx";
|
|
||||||
import { PasswordInput } from "@/components/password-input.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 { KeyRound } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -38,55 +42,36 @@ export function PassphraseDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
<PanePrompt
|
||||||
<div
|
open
|
||||||
className="absolute inset-0 bg-canvas rounded-md"
|
layer="connection"
|
||||||
style={{ backgroundColor: backgroundColor || undefined }}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
icon={<KeyRound className="size-4" />}
|
||||||
<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">
|
title={t("auth.passphraseRequired")}
|
||||||
<div className="p-4 border-b border-border">
|
description={<span className="font-mono">{hostDisplay}</span>}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<KeyRound className="size-4 text-accent-brand" />
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
<div className="flex flex-col gap-1.5">
|
||||||
{t("auth.passphraseRequired")}
|
<PasswordInput
|
||||||
</h3>
|
id="passphrase"
|
||||||
</div>
|
name="passphrase"
|
||||||
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
|
autoFocus
|
||||||
{hostDisplay}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-4">
|
<div className="flex justify-end gap-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<button type="button" onClick={onCancel} className={PROMPT_BUTTON}>
|
||||||
<PasswordInput
|
{t("common.cancel")}
|
||||||
id="passphrase"
|
</button>
|
||||||
name="passphrase"
|
<button type="submit" className={PROMPT_PRIMARY_BUTTON}>
|
||||||
autoFocus
|
{t("common.connect")}
|
||||||
placeholder={t("placeholders.keyPassword")}
|
</button>
|
||||||
className="rounded-none bg-muted/50 border-border text-xs"
|
</div>
|
||||||
/>
|
</form>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
</PanePrompt>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,11 @@ import React, { useState } from "react";
|
|||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { PasswordInput } from "@/components/password-input.tsx";
|
import { PasswordInput } from "@/components/password-input.tsx";
|
||||||
import { Label } from "@/components/label.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 {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -120,191 +125,176 @@ export function SSHAuthDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
<PanePrompt
|
||||||
<div
|
open
|
||||||
className="absolute inset-0 bg-canvas rounded-md"
|
layer="connection"
|
||||||
style={{ backgroundColor: backgroundColor || undefined }}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
icon={<Shield className="size-4" />}
|
||||||
<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">
|
title={t("auth.sshAuthenticationRequired")}
|
||||||
<div className="p-4 border-b border-border">
|
description={<span className="font-mono">{hostDisplay}</span>}
|
||||||
<div className="flex items-center gap-2">
|
className="max-w-xl"
|
||||||
<Shield className="size-4 text-accent-brand" />
|
>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
<form onSubmit={handleSubmit}>
|
||||||
{t("auth.sshAuthenticationRequired")}
|
<div className="flex flex-col gap-3">
|
||||||
</h3>
|
<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>
|
</div>
|
||||||
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
|
|
||||||
{hostDisplay}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<Tabs
|
||||||
<div className="p-4 flex flex-col gap-4">
|
value={authTab}
|
||||||
<div
|
onValueChange={(v) => setAuthTab(v as "password" | "key")}
|
||||||
className={`flex items-start gap-3 p-3 border ${
|
>
|
||||||
reason === "auth_failed"
|
<TabsList className="w-full rounded-none">
|
||||||
? "border-destructive/20 bg-destructive/10"
|
<TabsTrigger
|
||||||
: "border-border bg-muted/10"
|
value="password"
|
||||||
}`}
|
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||||
>
|
>
|
||||||
<AlertCircle
|
{t("credentials.password")}
|
||||||
className={`size-4 shrink-0 mt-0.5 ${reason === "auth_failed" ? "text-destructive" : "text-accent-brand"}`}
|
</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] text-muted-foreground">
|
||||||
<p
|
{t("auth.sshPasswordDescription")}
|
||||||
className={`text-[10px] font-bold uppercase tracking-widest ${reason === "auth_failed" ? "text-destructive" : ""}`}
|
</p>
|
||||||
>
|
</TabsContent>
|
||||||
{getReasonMessage()}
|
|
||||||
</p>
|
<TabsContent value="key" className="mt-3 flex flex-col gap-3">
|
||||||
<p
|
<div className="flex flex-col gap-2">
|
||||||
className={`text-xs mt-1 ${reason === "auth_failed" ? "text-destructive/80" : "text-muted-foreground"}`}
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
>
|
{t("credentials.sshPrivateKey")}
|
||||||
{getReasonDescription()}
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
|
|
||||||
<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">
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("credentials.password")}
|
{t("credentials.password")} ({t("common.optional")})
|
||||||
</Label>
|
</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
placeholder={t("placeholders.enterPassword")}
|
placeholder={t("placeholders.enterPassword")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoFocus
|
|
||||||
className="rounded-none bg-muted/50 border-border text-xs"
|
className="rounded-none bg-muted/50 border-border text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
</div>
|
||||||
{t("auth.sshPasswordDescription")}
|
</TabsContent>
|
||||||
</p>
|
</Tabs>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="key" className="mt-3 flex flex-col gap-3">
|
<div className="flex justify-end gap-2 pt-3">
|
||||||
<div className="flex flex-col gap-2">
|
<button
|
||||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
type="button"
|
||||||
{t("credentials.sshPrivateKey")}
|
onClick={onCancel}
|
||||||
</Label>
|
disabled={loading}
|
||||||
<div className="relative">
|
className={PROMPT_BUTTON}
|
||||||
<input
|
>
|
||||||
id="key-upload"
|
{t("common.cancel")}
|
||||||
type="file"
|
</button>
|
||||||
accept="*,.pem,.key,.ppk,.txt"
|
<button
|
||||||
onChange={handleKeyFileUpload}
|
type="submit"
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
disabled={!canSubmit() || loading}
|
||||||
/>
|
className={PROMPT_PRIMARY_BUTTON}
|
||||||
<Button
|
>
|
||||||
type="button"
|
{loading ? t("common.connecting") : t("common.connect")}
|
||||||
variant="outline"
|
</button>
|
||||||
className="w-full justify-start rounded-none text-[10px] font-bold uppercase tracking-widest border-border"
|
</div>
|
||||||
>
|
</form>
|
||||||
<Upload className="size-3.5 mr-2" />
|
</PanePrompt>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/button.tsx";
|
|
||||||
import { Input } from "@/components/input.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 { Shield, Loader2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { MFAPromptMode } from "@termix/plugin-sdk/frontend";
|
import type { MFAPromptMode } from "@termix/plugin-sdk/frontend";
|
||||||
@@ -17,6 +21,9 @@ interface TOTPDialogProps {
|
|||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CODE_INPUT =
|
||||||
|
"rounded-none bg-muted/50 border-border text-center text-sm tracking-widest";
|
||||||
|
|
||||||
export function TOTPDialog({
|
export function TOTPDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -68,99 +75,73 @@ export function TOTPDialog({
|
|||||||
|
|
||||||
const label = prompt || (isTotp ? t("sshAuth.totpCodeLabel") : undefined);
|
const label = prompt || (isTotp ? t("sshAuth.totpCodeLabel") : undefined);
|
||||||
|
|
||||||
|
const cancel = (
|
||||||
|
<button type="button" onClick={onCancel} className={PROMPT_BUTTON}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
<PanePrompt
|
||||||
<div
|
open
|
||||||
className="absolute inset-0 bg-canvas rounded-md"
|
layer="connection"
|
||||||
style={{ backgroundColor: backgroundColor || undefined }}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
icon={<Shield className="size-4" />}
|
||||||
<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">
|
title={title}
|
||||||
<div className="p-4 border-b border-border">
|
description={label}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Shield className="size-4 text-accent-brand" />
|
{showWaiting ? (
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
<div className="flex flex-col gap-3">
|
||||||
{title}
|
<div className="flex items-center gap-3 py-1">
|
||||||
</h3>
|
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
{label && (
|
{t("sshAuth.mfaWaitingApproval")}
|
||||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
|
||||||
{label}
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : (
|
<div className="flex justify-end gap-2">{cancel}</div>
|
||||||
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-4">
|
</div>
|
||||||
{isPush ? null : isMenu ? (
|
) : (
|
||||||
<Input
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
id="totpCode"
|
{isPush ? null : isMenu ? (
|
||||||
name="totpCode"
|
<Input
|
||||||
type="text"
|
id="totpCode"
|
||||||
autoFocus
|
name="totpCode"
|
||||||
placeholder={t("sshAuth.mfaMenuPlaceholder")}
|
type="text"
|
||||||
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
|
autoFocus
|
||||||
/>
|
placeholder={t("sshAuth.mfaMenuPlaceholder")}
|
||||||
) : allowsPushKeyword ? (
|
className={CODE_INPUT}
|
||||||
<Input
|
/>
|
||||||
id="totpCode"
|
) : allowsPushKeyword ? (
|
||||||
name="totpCode"
|
<Input
|
||||||
type="text"
|
id="totpCode"
|
||||||
autoFocus
|
name="totpCode"
|
||||||
placeholder={t("sshAuth.mfaCodeOrPushPlaceholder")}
|
type="text"
|
||||||
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
|
autoFocus
|
||||||
/>
|
placeholder={t("sshAuth.mfaCodeOrPushPlaceholder")}
|
||||||
) : (
|
className={CODE_INPUT}
|
||||||
<Input
|
/>
|
||||||
id="totpCode"
|
) : (
|
||||||
name="totpCode"
|
<Input
|
||||||
type="text"
|
id="totpCode"
|
||||||
autoFocus
|
name="totpCode"
|
||||||
// Some Secure Auth codes can be longer than 6 digits, so allow up to 8 digits here.
|
type="text"
|
||||||
maxLength={8}
|
autoFocus
|
||||||
pattern="[0-9]*"
|
// Some Secure Auth codes can be longer than 6 digits, so allow up to 8 digits here.
|
||||||
inputMode="numeric"
|
maxLength={8}
|
||||||
placeholder="000000"
|
pattern="[0-9]*"
|
||||||
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
|
inputMode="numeric"
|
||||||
/>
|
placeholder="000000"
|
||||||
)}
|
className={CODE_INPUT}
|
||||||
<div className="flex justify-end gap-2">
|
/>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
<div className="flex justify-end gap-2">
|
||||||
variant="ghost"
|
{cancel}
|
||||||
onClick={onCancel}
|
<button type="submit" className={PROMPT_PRIMARY_BUTTON}>
|
||||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
{isPush ? t("sshAuth.mfaSendRequest") : t("sshAuth.totpVerify")}
|
||||||
>
|
</button>
|
||||||
{t("common.cancel")}
|
</div>
|
||||||
</Button>
|
</form>
|
||||||
<Button
|
)}
|
||||||
type="submit"
|
</PanePrompt>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,9 +114,9 @@ describe("Auth local desktop flow", () => {
|
|||||||
renderAuth();
|
renderAuth();
|
||||||
|
|
||||||
await waitFor(() => {
|
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.queryByText("common.register")).toBeNull();
|
||||||
expect(screen.queryByLabelText("common.username")).toBeNull();
|
expect(screen.queryByLabelText("common.username")).toBeNull();
|
||||||
expect(screen.queryByText("auth.forgotPassword")).toBeNull();
|
expect(screen.queryByText("auth.forgotPassword")).toBeNull();
|
||||||
@@ -134,11 +134,9 @@ describe("Auth local desktop flow", () => {
|
|||||||
renderAuth();
|
renderAuth();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(screen.getByText("auth.localDesktopUnavailable")).toBeTruthy();
|
||||||
screen.getByText("Local desktop session unavailable"),
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Retry local desktop session")).toBeTruthy();
|
expect(screen.getByText("auth.retryLocalDesktop")).toBeTruthy();
|
||||||
expect(screen.queryByText("common.register")).toBeNull();
|
expect(screen.queryByText("common.register")).toBeNull();
|
||||||
expect(screen.queryByLabelText("common.username")).toBeNull();
|
expect(screen.queryByLabelText("common.username")).toBeNull();
|
||||||
expect(mainAxios.requestDesktopAutoSession).toHaveBeenCalled();
|
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