feat: port the demo redesign with Manage and Settings tabs

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

No files matched your search

+7
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -52,6 +53,7 @@ export function AdminApiKeysSection({
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
@@ -244,6 +246,11 @@ export function AdminApiKeysSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", { name: key.name }),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -112,6 +113,7 @@ export function AdminUsersSection({
onPageChange,
}: UsersSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 0;
const hasNext = page + 1 < pageCount;
@@ -249,6 +251,13 @@ export function AdminUsersSection({
className="size-6 text-muted-foreground hover:text-destructive"
disabled={user.isAdmin}
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: user.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteUser(user.username);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
@@ -321,6 +330,7 @@ export function AdminSessionsSection({
loadSessions,
}: SessionsSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
@@ -375,6 +385,13 @@ export function AdminSessionsSection({
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeAllSessionsConfirm", {
username: session.username ?? "",
}),
confirmLabel: t("admin.revokeAll"),
});
if (!ok) return;
try {
await revokeAllUserSessions(session.userId);
setSessions((prev) =>
@@ -393,6 +410,11 @@ export function AdminSessionsSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeSessionConfirm"),
confirmLabel: t("admin.revokeSession"),
});
if (!ok) return;
try {
await revokeSession(session.id);
setSessions((prev) =>
@@ -447,6 +469,7 @@ export function AdminRolesSection({
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
@@ -652,6 +675,12 @@ export function AdminRolesSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteRoleConfirm", {
name: role.displayName,
}),
});
if (!ok) return;
await deleteRole(role.id);
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
+33 -12
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useBranding } from "@/contexts/BrandingContext";
@@ -102,11 +103,15 @@ const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
section,
}: {
onEditingChange?: (editing: boolean) => void;
onOpenHostTab?: (host: Host) => void;
/** Shows just this section, flat, as a Settings page. */
section?: AdminSection;
} = {}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [openSections, setOpenSections] = useState<
Set<AdminSection | FeatureSectionId>
>(() => new Set(["general"]));
@@ -567,6 +572,13 @@ export function AdminSettingsPanel({
async function handleDeleteEditUser() {
if (!editUserTarget) return;
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: editUserTarget.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
setEditUserLoading(true);
try {
await deleteUser(editUserTarget.username);
@@ -734,6 +746,9 @@ export function AdminSettingsPanel({
}
}
const isOpen = (id: AdminSection | FeatureSectionId) =>
section ? section === id : openSections.has(id);
if (manageUser) {
return (
<AdminUserManagePanel
@@ -758,9 +773,15 @@ export function AdminSettingsPanel({
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"
}
>
<AdminGeneralSettingsSection
open={openSections.has("general")}
open={isOpen("general")}
onToggle={() => toggle("general")}
notificationPrivateEndpoints={notificationPrivateEndpoints}
onSaveNotificationPrivateEndpoints={
@@ -790,7 +811,7 @@ export function AdminSettingsPanel({
/>
<AdminUsersSection
open={openSections.has("users")}
open={isOpen("users")}
onToggle={() => toggle("users")}
users={users}
setUsers={setUsers}
@@ -812,7 +833,7 @@ export function AdminSettingsPanel({
/>
<AdminSessionsSection
open={openSections.has("sessions")}
open={isOpen("sessions")}
onToggle={() => toggle("sessions")}
sessions={sessions}
setSessions={setSessions}
@@ -820,7 +841,7 @@ export function AdminSettingsPanel({
/>
<AdminRolesSection
open={openSections.has("roles")}
open={isOpen("roles")}
onToggle={() => toggle("roles")}
roles={roles}
setRoles={setRoles}
@@ -837,12 +858,12 @@ export function AdminSettingsPanel({
/>
<AdminHostDefaultsSection
open={openSections.has("host-defaults")}
open={isOpen("host-defaults")}
onToggle={() => toggle("host-defaults")}
/>
<AdminBrandingSection
open={openSections.has("branding")}
open={isOpen("branding")}
onToggle={() => toggle("branding")}
settings={brandingSettings}
setSettings={setBrandingSettings}
@@ -852,7 +873,7 @@ export function AdminSettingsPanel({
/>
<AdminDatabaseSection
open={openSections.has("database")}
open={isOpen("database")}
onToggle={() => toggle("database")}
importFile={importFile}
setImportFile={setImportFile}
@@ -863,7 +884,7 @@ export function AdminSettingsPanel({
/>
<AdminSSLSection
open={openSections.has("ssl")}
open={isOpen("ssl")}
onToggle={() => toggle("ssl")}
status={tlsStatus}
manualCertDraft={manualCertDraft}
@@ -875,7 +896,7 @@ export function AdminSettingsPanel({
/>
<AdminApiKeysSection
open={openSections.has("api-keys")}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
@@ -896,7 +917,7 @@ export function AdminSettingsPanel({
/>
<AdminAuditLogSection
open={openSections.has("audit-log")}
open={isOpen("audit-log")}
onToggle={() => toggle("audit-log")}
users={users}
/>
@@ -906,7 +927,7 @@ export function AdminSettingsPanel({
key={plugin.id}
plugin={plugin}
scope="admin"
open={openSections.has(featureSectionId(plugin.id))}
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
+2 -2
View File
@@ -372,7 +372,7 @@ function certState(
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
expiring: "text-warning",
expired: "text-destructive",
};
@@ -470,7 +470,7 @@ export function AdminSSLSection({
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
<div className="p-2 border border-warning/40 bg-warning/10 text-[10px] text-warning">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
+10
View File
@@ -1,5 +1,6 @@
import type React from "react";
import { ChevronDown } from "lucide-react";
import { useSettingsEmbedded } from "@/settings/settings-embed";
export function AdminToggle({
on,
@@ -36,6 +37,15 @@ export function AccordionSection({
onToggle: () => void;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (embedded) {
if (!open) return null;
return (
<div className="flex flex-col border border-border bg-card px-3 pb-3">
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden shrink-0">
<button
+368 -395
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -9,13 +10,6 @@ import {
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
@@ -63,83 +57,80 @@ export function AdminCreateUserDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.createUserTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.createUserTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</InlineView>
);
}
@@ -173,222 +164,210 @@ export function AdminEditUserDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.editUserTitle", { username: editUserTarget?.username })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.editUserDesc")}
</DialogDescription>
</DialogHeader>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">
{t("admin.userRoles")}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
>
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter(
(r) => r.roleId !== ur.roleId,
),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(
editUserTarget.id,
r.id,
);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
)}
</DialogContent>
</Dialog>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">{t("admin.userRoles")}</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter((r) => r.roleId !== ur.roleId),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(editUserTarget.id, r.id);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</InlineView>
);
}
@@ -424,45 +403,42 @@ export function AdminUnlinkAccountDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.unlinkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</DialogContent>
</Dialog>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.unlinkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</p>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</InlineView>
);
}
@@ -529,74 +505,71 @@ export function AdminLinkAccountDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.linkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.linkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
/>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</InlineView>
);
}
+30 -82
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
import { getUserList } from "@/main-axios";
@@ -50,7 +51,8 @@ import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
import { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
type ApiErrorLike = {
@@ -114,14 +116,18 @@ export function AdminUserManagePanel({
.catch(() => {});
}, [user.id]);
const [editor, setEditor] = useState<EditorState>(null);
const [editorTab, setEditorTab] = useState("general");
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
@@ -323,57 +329,34 @@ export function AdminUserManagePanel({
</span>
);
// Full-panel host/credential editor view (mirrors HostManager's editor view)
// Editing one of the user's hosts or credentials takes over the panel.
if (editor) {
const isHost = editor.kind === "host";
const tabs = isHost
? makeHostTabs(
t,
editorProtocols as unknown as Record<string, boolean>,
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
: makeCredentialTabs(t);
const close = () => setEditor(null);
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
setEditor(null);
setEditorTab("general");
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{t("admin.manageEditorBack", { username: user.username })}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={editorTab}
onTabChange={setEditorTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<PanelShell
leading={<BackButton onClick={close} />}
title={t("admin.manageEditorBack", { username: user.username })}
status={
editor.kind === "host"
? (editor.host?.name ?? t("manage.newHost"))
: (editor.credential?.name ?? t("manage.newCredential"))
}
scroll={false}
>
{editor.kind === "host" ? (
<HostEditor
key={editor.host ? editor.host.id : "new-host"}
host={editor.host}
activeTab={editorTab}
onBack={() => {
setEditor(null);
setEditorTab("general");
}}
onBack={close}
onSave={() => {
setEditor(null);
setEditorTab("general");
close();
reloadHosts();
}}
protocols={editorProtocols}
onProtocolChange={(p) =>
setEditorProtocols((prev) => ({ ...prev, ...p }))
}
onTabChange={setEditorTab}
hosts={hosts}
credentials={credentials}
adminTargetUserId={user.id}
@@ -382,7 +365,6 @@ export function AdminUserManagePanel({
<CredentialEditorView
key={editor.credential ? editor.credential.id : "new-cred"}
credential={editor.credential}
activeTab={editorTab}
existingFolders={Array.from(
new Set(
credentials
@@ -390,19 +372,15 @@ export function AdminUserManagePanel({
.filter((f): f is string => !!f),
),
).sort()}
onBack={() => {
setEditor(null);
setEditorTab("general");
}}
onBack={close}
onSave={() => {
setEditor(null);
setEditorTab("general");
close();
reloadCredentials();
}}
adminTargetUserId={user.id}
/>
)}
</div>
</PanelShell>
</div>
);
}
@@ -678,7 +656,6 @@ export function AdminUserManagePanel({
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorProtocols(hostProtocolFlags(null));
setEditorTab("general");
setEditor({ kind: "host", host: null });
}}
>
@@ -724,7 +701,6 @@ export function AdminUserManagePanel({
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorProtocols(hostProtocolFlags(host));
setEditorTab("general");
setEditor({ kind: "host", host });
}}
>
@@ -790,7 +766,6 @@ export function AdminUserManagePanel({
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorTab("general");
setEditor({ kind: "credential", credential: null });
}}
>
@@ -827,7 +802,6 @@ export function AdminUserManagePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorTab("general");
setEditor({ kind: "credential", credential: cred });
}}
>
@@ -1013,32 +987,6 @@ export function AdminUserManagePanel({
</div>
)}
</div>
{/* Confirm dialog overlay */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("common.confirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+321 -443
View File
@@ -1,106 +1,122 @@
import { useState, useEffect, type ReactElement } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Eye,
EyeOff,
LogOut,
PanelRight,
Pin,
Settings,
Search,
SlidersHorizontal,
SquareArrowOutUpRight,
User,
} from "lucide-react";
import type { TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
import {
groupRailItems,
useRailItems,
type RailGroup,
type RailItemDef,
} from "./rail-items";
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
| "hosts"
| "credentials"
| "quick-connect"
| ToolsTab
| "connections"
| "user-profile"
| "admin-settings";
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
export type RailView = CoreRailView | (string & {});
type RailItem =
| {
kind?: undefined;
view: RailView;
icon: React.ReactNode;
title: string;
promotable?: boolean;
rightDockable?: boolean;
useBadge?: () => number | null | undefined;
}
| {
kind: "tab";
tabType: TabType;
icon: React.ReactNode;
title: string;
useBadge?: () => number | null | undefined;
}
| { kind: "separator" };
const btnBase =
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
const activeClass = "text-accent-brand bg-accent-brand/10";
function buildRailButtons(
items: RailItemDef[],
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
const all: RailItem[] = [];
for (const item of items) {
const Icon = item.icon;
if (item.kind === "tab") {
all.push({
kind: "tab",
tabType: item.id as TabType,
icon: <Icon size={16} />,
title: t(item.labelKey),
useBadge: item.useBadge,
});
} else {
all.push({
view: item.id as RailView,
icon: <Icon size={16} />,
title: t(item.labelKey),
promotable: item.promotable,
rightDockable: item.rightDockable,
useBadge: item.useBadge,
});
}
if (item.separatorAfter) all.push({ kind: "separator" });
}
// Filter out hidden items, then collapse consecutive/leading/trailing separators
const filtered = all.filter((item) => {
if (item.kind === "separator") return true;
if ("tabType" in item) return !hidden.has(item.tabType);
return !hidden.has(item.view);
});
const result: RailItem[] = [];
for (const item of filtered) {
if (item.kind === "separator") {
if (result.length === 0 || result[result.length - 1].kind === "separator")
continue;
result.push(item);
} else {
result.push(item);
}
}
if (result[result.length - 1]?.kind === "separator") result.pop();
return result;
function RailLabel({
expanded,
children,
}: {
expanded: boolean;
children: React.ReactNode;
}) {
return (
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{children}
</span>
);
}
const btnBase =
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
function RailIcon({
children,
useBadge,
}: {
children: React.ReactNode;
useBadge?: () => number | null | undefined;
}) {
return (
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{children}
{useBadge && (
<RailBadge useBadge={useBadge} className="-top-1.5 -right-2" />
)}
</span>
);
}
function Rule({ expanded }: { expanded: boolean }) {
return (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: expanded ? `calc(100% - ${rem(16)})` : rem(20) }}
/>
);
}
/**
* The band name rides on the divider between bands, so expanding the rail
* never pushes items down from under the pointer.
*/
function GroupDivider({
group,
expanded,
}: {
group: RailGroup;
expanded: boolean;
}) {
const { t } = useTranslation();
return (
<div className="my-0.5 flex h-3 shrink-0 items-center gap-1.5 px-2.5">
<span
aria-hidden={!expanded}
className={`shrink-0 overflow-hidden text-[10px] font-semibold uppercase leading-none tracking-widest whitespace-nowrap text-muted-foreground/60 transition-[opacity,max-width] duration-200 ${
expanded ? "max-w-32 opacity-100 delay-75" : "max-w-0 opacity-0"
}`}
>
{t(`nav.group.${group}`)}
</span>
<div className="h-px min-w-3 flex-1 bg-border" />
</div>
);
}
interface MenuTarget {
id: string;
title: string;
promotable?: boolean;
rightDockable?: boolean;
hideable: boolean;
}
export function AppRail({
railView,
@@ -111,6 +127,8 @@ export function AppRail({
onRailClick,
onOpenTab,
onOpenInRightDock,
onOpenSettings,
onOpenPalette,
onLogout,
}: {
railView: RailView;
@@ -122,6 +140,8 @@ export function AppRail({
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
onOpenSettings: () => void;
onOpenPalette?: () => void;
onLogout: (options?: { manual?: boolean }) => void;
}) {
const { t } = useTranslation();
@@ -134,22 +154,9 @@ export function AppRail({
readRailPreference("showPinAppRailButton"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
// Which promotable item was right-clicked, so the menu can offer to open it
// as a tab. Null when the right-click landed on empty rail space.
const [menuTarget, setMenuTarget] = useState<{
view: RailView;
title: string;
promotable?: boolean;
rightDockable?: boolean;
} | null>(null);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
const [managing, setManaging] = useState(false);
const hiddenTabs = useHiddenRailTabs();
useEffect(() => {
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
@@ -194,33 +201,20 @@ export function AppRail({
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
useEffect(() => {
const handler = () => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
} catch {
setHiddenTabs(new Set());
}
};
window.addEventListener("hiddenRailTabsChanged", handler);
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(
railItems.filter((item) => item.placement !== "footer"),
t,
hiddenTabs,
const railExpanded = pinned || (expandOnHover && hovered) || managing;
const mainItems = railItems.filter((item) => item.placement !== "footer");
const bands = useMemo(
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
[mainItems, hiddenTabs],
);
const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
const footerItems = railItems.filter(
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
);
const setRailPinned = (nextPinned: boolean) => {
setPinned(nextPinned);
localStorage.setItem("pinAppRail", String(nextPinned));
window.dispatchEvent(new Event("pinAppRailChanged"));
};
useEffect(() => {
if (hiddenItems.length === 0) setManaging(false);
}, [hiddenItems.length]);
const togglePinned = () => {
setRailPreference("pinAppRail", !pinned);
@@ -232,29 +226,92 @@ export function AppRail({
setMenuPos(null);
};
const renderItem = (item: RailItemDef) => {
const Icon = item.icon;
const title = t(item.labelKey);
const isTab = item.kind === "tab";
const active = !isTab && sidebarOpen && railView === item.id;
return (
<button
key={item.id}
type="button"
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() =>
setMenuTarget({
id: item.id,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
hideable: !item.alwaysVisible && item.hideable !== false,
})
}
data-rail-item=""
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle}`}
>
<RailIcon useBadge={item.useBadge}>
<Icon size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{title}</RailLabel>
</button>
);
};
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: rem(railExpanded ? 160 : 40) }}
style={{ width: rem(railExpanded ? 172 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
e.preventDefault();
// Keep the menu on screen when right-clicking near the viewport edges
const MENU_W = 190;
const MENU_H = 96;
const MENU_W = 200;
const MENU_H = 180;
setMenuPos({
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
});
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
setMenuTarget(null);
}
}}
>
{onOpenPalette && (
<>
<button
type="button"
onClick={onOpenPalette}
title={t("nav.searchEverything")}
aria-label={t("nav.searchEverything")}
style={btnStyle}
className={`${btnBase} ${idle}`}
>
<RailIcon>
<Search size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{!pluginsSettled
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
<div
key={`rail-skeleton-${i}`}
style={btnStyle}
@@ -263,307 +320,106 @@ export function AppRail({
<Skeleton className="size-4 shrink-0" />
</div>
))
: railButtons.map((item, i) =>
item.kind === "separator" ? (
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
) : "tabType" in item ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
: bands.map((band, i) => (
<div key={band.group} className="flex flex-col gap-1">
{i > 0 && (
<GroupDivider group={band.group} expanded={railExpanded} />
)}
{band.items.map(renderItem)}
</div>
))}
{pluginsSettled && hiddenItems.length > 0 && (
<>
<Rule expanded={railExpanded} />
<button
type="button"
onClick={() => setManaging((v) => !v)}
aria-expanded={managing}
title={t("nav.hiddenCount", { count: hiddenItems.length })}
style={btnStyle}
className={`${btnBase} ${managing ? activeClass : idle}`}
>
<RailIcon>
<EyeOff size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{t("nav.hiddenCount", { count: hiddenItems.length })}
</RailLabel>
</button>
{managing &&
hiddenItems.map((item) => {
const Icon = item.icon;
return (
<button
key={`hidden-${item.id}`}
type="button"
onClick={() => void toggleHiddenRailTab(item.id)}
title={t("nav.showInRail")}
style={btnStyle}
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
),
)}
<RailIcon>
<Icon size={16} />
</RailIcon>
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
{t(item.labelKey)}
</span>
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
</button>
);
})}
</>
)}
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
{showPinButton && (
<button
onClick={() => setRailPinned(!pinned)}
style={btnStyle}
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
className={`${btnBase} ${
pinned
? "text-accent-brand bg-accent-brand/10 hover:text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Pin size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
</span>
</button>
)}
{showPinButton && (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
)}
{footerItems.map((item) => {
const Icon = item.icon;
const title = t(item.labelKey);
return (
<>
<button
key={item.id}
onClick={(e) => {
if (item.kind === "tab") {
onOpenTab?.(item.id as TabType);
return;
}
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.id as RailView,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
}
type="button"
onClick={() => setRailPreference("pinAppRail", !pinned)}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.id
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Icon size={16} />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{title}
</span>
</button>
);
})}
{(
[
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
] as {
view: RailView;
icon: ReactElement;
title: string;
promotable?: boolean;
}[]
).map((item) => (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: true,
});
}}
data-rail-promotable={item.promotable ? "" : undefined}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
className={`${btnBase} ${pinned ? activeClass : idle}`}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{item.title}
</span>
</button>
))}
<div className="mx-2 my-1 border-t border-border" />
<RailIcon>
<Pin size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
{footerItems.map(renderItem)}
{footerItems.length > 0 && <Rule expanded={railExpanded} />}
<button
type="button"
onClick={() => onLogout({ manual: true })}
style={btnStyle}
title={t("common.logout")}
aria-label={t("common.logout")}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<RailIcon>
<LogOut size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{t("common.logout")}
</span>
</RailIcon>
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
</button>
</div>
<div className="shrink-0 border-t border-border">
<button
onClick={() => onRailClick("user-profile")}
title={t("nav.userProfile")}
aria-label={t("nav.userProfile")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
style={{ padding: "0 8px" }}
type="button"
onClick={onOpenSettings}
title={t("nav.openSettings")}
aria-label={t("nav.openSettings")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
style={{ padding: `0 ${rem(8)}` }}
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
@@ -577,7 +433,7 @@ export function AppRail({
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || "User"}
{username || t("nav.roleUser")}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
@@ -589,79 +445,101 @@ export function AppRail({
{menuPos && (
<div
data-rail-context-menu
role="menu"
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
>
{menuTarget && (
<>
{menuTarget.promotable && (
<button
<MenuItem
icon={<SquareArrowOutUpRight className="size-3" />}
onClick={() => {
onOpenTab?.(menuTarget.view as TabType);
onOpenTab?.(menuTarget.id as TabType);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SquareArrowOutUpRight className="size-3" />
</span>
{t("nav.openAsTab")}
</button>
</MenuItem>
)}
{menuTarget.rightDockable && (
<button
<MenuItem
icon={<PanelRight className="size-3" />}
onClick={() => {
onOpenInRightDock?.(menuTarget.view);
onOpenInRightDock?.(menuTarget.id);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<PanelRight className="size-3" />
</span>
{t("nav.openInRightDock")}
</button>
</MenuItem>
)}
{menuTarget.hideable && (
<MenuItem
icon={<EyeOff className="size-3" />}
onClick={() => {
void toggleHiddenRailTab(menuTarget.id);
setMenuPos(null);
}}
>
{t("nav.hideFromRail")}
</MenuItem>
)}
<div className="h-px bg-border my-1" />
</>
)}
<button
<MenuItem
icon={pinned ? <Check className="size-3" /> : null}
onClick={togglePinned}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
role="menuitemcheckbox"
aria-checked={pinned}
checked={pinned}
>
<span className="shrink-0 w-3 flex items-center justify-center">
{pinned && <Check className="size-3" />}
</span>
{t("newUi.sidebar.userProfile.pinAppRail")}
</button>
<button
</MenuItem>
<MenuItem
icon={expandOnHover ? <Check className="size-3" /> : null}
onClick={toggleExpandOnHover}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
role="menuitemcheckbox"
aria-checked={expandOnHover}
checked={expandOnHover}
>
<span className="shrink-0 w-3 flex items-center justify-center">
{expandOnHover && <Check className="size-3" />}
</span>
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
</button>
</MenuItem>
<div className="h-px bg-border my-1" />
<button
<MenuItem
icon={<SlidersHorizontal className="size-3" />}
onClick={() => {
onRailClick("user-profile");
onOpenSettings();
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SlidersHorizontal className="size-3" />
</span>
{t("nav.sidebarSettings")}
</button>
{t("nav.openSettings")}
</MenuItem>
</div>
)}
</div>
);
}
function MenuItem({
icon,
onClick,
checked,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
checked?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role={checked === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={checked}
onClick={onClick}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
{icon}
</span>
{children}
</button>
);
}
+9
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
@@ -298,6 +299,7 @@ export function ConnectionsPanel({
onReorderTabs?: (tabs: Tab[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [now, setNow] = useState(Date.now());
const [persistMinutes, setPersistMinutes] = useState(30);
@@ -597,6 +599,13 @@ export function ConnectionsPanel({
onReopenTab(record, liveSession?.sessionId ?? null);
}}
onClose={async () => {
const ok = await confirm({
title: t("connections.forgetConfirm", {
name: host?.name ?? record.label,
}),
confirmLabel: t("connections.forget"),
});
if (!ok) return;
await deleteOpenTab(record.id).catch(() => {});
onForgetBackground(record.id);
}}
+75 -43
View File
@@ -1,4 +1,6 @@
import type React from "react";
import { useRef, useState } from "react";
import { EditorPane, EditorSection } from "@/manage/EditorPane";
import { PluginComponent } from "@/plugin-host/component-registry";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -23,7 +25,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
export function CredentialEditorView({
credential,
activeTab,
banner,
onBack,
onSave,
adminTargetUserId,
@@ -31,7 +33,8 @@ export function CredentialEditorView({
saveAsNewHost,
}: {
credential: Credential | null;
activeTab: string;
/** Shown above the form. */
banner?: React.ReactNode;
onBack: () => void;
onSave: (
saved: Record<string, unknown>,
@@ -170,9 +173,73 @@ export function CredentialEditorView({
}
};
const nameMissing = !credForm.name.trim();
const bands = [
{
id: "credential",
label: t("manage.bandCredential"),
items: [
{
id: "general",
label: t("hosts.basicInformation"),
icon: <Info className="size-3.5" />,
error: nameMissing,
},
{
id: "auth",
label: t("hosts.authDetailsSection"),
icon: <Lock className="size-3.5" />,
},
],
},
];
return (
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<EditorPane
bands={bands}
banner={banner}
errorCount={nameMissing ? 1 : 0}
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
size="sm"
className="px-4"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew || nameMissing}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
onClick={handleSave}
disabled={saving || savingAsNew || nameMissing}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</>
}
>
<EditorSection id="general" showHeading={false}>
<SectionCard
title={t("hosts.basicInformation")}
icon={<Info className="size-3.5" />}
@@ -265,9 +332,9 @@ export function CredentialEditorView({
</div>
</div>
</SectionCard>
)}
</EditorSection>
{activeTab === "auth" && (
<EditorSection id="auth" showHeading={false}>
<SectionCard
title={t("hosts.authDetailsSection")}
icon={<Lock className="size-3.5" />}
@@ -544,42 +611,7 @@ export function CredentialEditorView({
</div>
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 mt-3">
<Button
variant="ghost"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
className="px-6"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
onClick={handleSave}
disabled={saving || savingAsNew}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</div>
</div>
</EditorSection>
</EditorPane>
);
}
+317
View File
@@ -0,0 +1,317 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Upload } from "lucide-react";
import { Button } from "@/components/button";
import { Select2 } from "@/components/select2";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
deleteCredential,
deployCredentialToHost,
duplicateCredential,
getCredentials,
getSSHHosts,
renameCredentialFolder,
updateCredential,
} from "@/main-axios";
import { requestManage } from "@/manage/manage-requests";
import type { Credential, Host } from "@/types/ui-types";
import type {
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialShareModal } from "./CredentialShareModal";
import { CredentialSidebarTree } from "./credential-tree";
import { credentialPassesFilters, sortCredentials } from "./credential-sort";
import { enabledHostProtocols } from "./host-protocols";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
const UNCATEGORIZED = "Uncategorized";
/**
* The credentials sidebar list. Editing happens in the Manage tab; this keeps
* the tree, its folder moves and the quick actions on each row.
*/
export function CredentialList({
search = "",
sort = "default",
arrangeLocked = true,
filter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
onTagsChange,
}: {
search?: string;
sort?: CredentialSortKey;
arrangeLocked?: boolean;
filter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
onTagsChange?: (tags: string[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
const [deployTarget, setDeployTarget] = useState<Credential | null>(null);
const [deployHostId, setDeployHostId] = useState("");
const [deploying, setDeploying] = useState(false);
const [editingFolderName, setEditingFolderName] = useState<string | null>(
null,
);
const [editingFolderValue, setEditingFolderValue] = useState("");
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentials]);
useEffect(() => {
const reloadHosts = () => {
getSSHHosts()
.then((raw) => setHosts(raw.map(sshHostToHost)))
.catch(() => {});
};
const reloadCredentials = () => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {})
.finally(() => setLoading(false));
};
reloadHosts();
reloadCredentials();
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, []);
const needle = search.toLowerCase();
const searched = credentials.filter(
(c) =>
c.name.toLowerCase().includes(needle) ||
c.username.toLowerCase().includes(needle),
);
const filtered = sortCredentials(
filter
? searched.filter((c) => credentialPassesFilters(c, filter))
: searched,
sort,
);
const folderNames = Array.from(
new Set(filtered.map((c) => c.folder || UNCATEGORIZED)),
).sort();
const folderTree = folderNames.map((name) => ({
name,
children: filtered.filter((c) => (c.folder || UNCATEGORIZED) === name),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filtered) {
usedByCounts.set(
cred.id,
hosts.filter((h) => h.credentialId === cred.id).length,
);
}
const reload = async () => {
const res = await getCredentials();
setCredentials(mapCredentials(res));
};
const renameFolder = async (folder: string, newName: string) => {
try {
await renameCredentialFolder(folder, newName);
await reload();
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("credentials.failedToRenameFolder"));
}
};
const moveToFolder = async (credentialId: string, targetFolder: string) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || UNCATEGORIZED) === targetFolder) return;
const folderValue = targetFolder === UNCATEGORIZED ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
const clone = async (cred: Credential) => {
try {
await duplicateCredential(Number(cred.id), {
name: t("credentials.clonedCredentialName", { name: cred.name }),
});
await reload();
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.clonedCredential", { name: cred.name }));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("credentials.failedToCloneCredential"));
}
};
const remove = async (cred: Credential) => {
const ok = await confirm({
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
};
const deploy = async () => {
if (!deployTarget || !deployHostId) return;
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployTarget.id),
Number(deployHostId),
);
toast.success(t("credentials.keyDeployedSuccess"));
setDeployTarget(null);
} catch {
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
};
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!loading && filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10"
onClick={() =>
requestManage({ kind: "credential", credentialId: null })
}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={folderTree}
usedByCounts={usedByCounts}
query=""
loading={loading}
arrangeLocked={arrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={setEditingFolderName}
onEditingFolderValueChange={setEditingFolderValue}
onRenameFolder={renameFolder}
onMoveCredentialToFolder={moveToFolder}
onDeployCredential={(cred) => {
setDeployHostId("");
setDeployTarget(cred);
}}
onEditCredential={(cred) =>
requestManage({ kind: "credential", credentialId: cred.id })
}
onCloneCredential={clone}
onDeleteCredential={remove}
onShareCredential={setShareCredential}
/>
)}
<CredentialShareModal
credential={shareCredential}
onClose={() => setShareCredential(null)}
/>
<InlineView
open={!!deployTarget}
onOpenChange={(open) => {
if (!open) setDeployTarget(null);
}}
icon={<Upload className="size-4" />}
title={t("credentials.deployDialogTitle")}
footer={
<div className="ml-auto flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployTarget(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployHostId || deploying}
onClick={() => void deploy()}
>
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("credentials.deployDialogDesc", { name: deployTarget?.name })}
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.targetHostLabel")}
</label>
<Select2
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={deployHostId}
onChange={(e) => setDeployHostId(e.target.value)}
>
<option value="">{t("credentials.selectHostOption")}</option>
{hosts
.filter(
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
</div>
</InlineView>
</div>
);
}
+19 -15
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Check,
ListChecks,
Search,
@@ -13,6 +12,7 @@ import {
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { Input } from "@/components/input";
import {
DropdownMenu,
@@ -72,6 +72,7 @@ export function CredentialShareModal({
onClose: () => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [shareUsers, setShareUsers] = useState<
@@ -228,6 +229,13 @@ export function CredentialShareModal({
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
const who =
record.username ?? record.roleDisplayName ?? record.roleName ?? "";
const ok = await confirm({
title: t("sharing.revokeConfirm", { name: who }),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeCredentialAccess(credentialId, record.id);
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
@@ -237,20 +245,16 @@ export function CredentialShareModal({
}
}
if (!credential) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{t("credentials.share.title", { name: credential.name ?? "" })}
</span>
</button>
<InlineView
open={!!credential}
onOpenChange={(open) => {
if (!open) onClose();
}}
icon={<Share2 className="size-4" />}
title={t("credentials.share.title", { name: credential?.name ?? "" })}
bare
>
{loadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
@@ -570,6 +574,6 @@ export function CredentialShareModal({
})}
</div>
</div>
</div>
</InlineView>
);
}
+11 -22
View File
@@ -13,7 +13,8 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { HostManager } from "@/sidebar/HostManager";
import { CredentialList } from "@/sidebar/CredentialList";
import { requestManage } from "@/manage/manage-requests";
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
@@ -30,8 +31,7 @@ import {
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
export function CredentialsPanel({
onEditingChange,
active = true,
active: _active = true,
}: {
onEditingChange?: (editing: boolean) => void;
active?: boolean;
@@ -41,7 +41,6 @@ export function CredentialsPanel({
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
@@ -94,14 +93,9 @@ export function CredentialsPanel({
}));
}
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
@@ -297,9 +291,7 @@ export function CredentialsPanel({
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
)
requestManage({ kind: "credential", credentialId: null })
}
title={t("credentials.addCredential")}
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
@@ -312,21 +304,18 @@ export function CredentialsPanel({
</div>
</div>
</div>
)}
}
<div className="flex flex-col flex-1 min-h-0">
<HostManager
hideListHeader
externalSearch={managerEditing ? undefined : search}
externalSort={sortKey}
externalArrangeLocked={arrangeLocked}
externalFilter={filterState}
<CredentialList
search={search}
sort={sortKey}
arrangeLocked={arrangeLocked}
filter={filterState}
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
onTagsChange={setAllTags}
onEditingChange={handleEditingChange}
active={active}
/>
</div>
@@ -1,12 +1,6 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
CredentialDensity,
@@ -32,108 +26,104 @@ export function CustomizeCredentialsSidebarPanel({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("credentials.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("credentials.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
</SectionCard>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
</div>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
+169 -179
View File
@@ -1,12 +1,6 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
@@ -33,186 +27,182 @@ export function CustomizeSidebarPanel({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("hosts.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("hosts.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
+117 -122
View File
@@ -1,13 +1,7 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
@@ -124,125 +118,126 @@ export function FolderMetadataDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderNestingHint")}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</>
}
>
<p className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
{t("hosts.folderNestingHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</InlineView>
);
}
+77 -83
View File
@@ -1,15 +1,8 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
getCredentials,
getHostAuthOverride,
@@ -123,78 +116,18 @@ export function HostAuthOverrideModal({
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</DialogTitle>
<DialogDescription>
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</select>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-amber-500/30 bg-amber-500/5 p-2 text-[10px] text-amber-600 dark:text-amber-400">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
)}
<DialogFooter>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</>
}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
@@ -212,9 +145,70 @@ export function HostAuthOverrideModal({
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
</p>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</select>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-warning/30 bg-warning/5 p-2 text-[10px] text-warning">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
)}
</InlineView>
</span>
);
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -14,6 +14,7 @@ import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import type { HostFormErrors } from "@/manage/host-validation";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
import {
@@ -35,6 +36,7 @@ export function HostEditorGeneralTab({
hosts,
host,
simpleMode = false,
errors = {},
}: {
form: HostEditorForm;
setField: HostEditorSetField;
@@ -44,6 +46,7 @@ export function HostEditorGeneralTab({
host: Host | null;
/** Hides organizational/advanced fields; their values still save unchanged. */
simpleMode?: boolean;
errors?: HostFormErrors;
}) {
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
@@ -210,8 +213,14 @@ export function HostEditorGeneralTab({
<Input
placeholder={t("placeholders.hostAddress")}
value={form.ip}
aria-invalid={!!errors.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
{errors.ip && (
<span className="text-[10px] text-destructive">
{t(errors.ip)}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+124 -130
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -7,13 +8,6 @@ import { toast } from "sonner";
import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
@@ -220,126 +214,13 @@ export function HostExportDialog({
const canExport = !loading && count > 0;
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex flex-col w-[95vw] sm:max-w-6xl max-h-[85dvh] overflow-hidden">
<DialogHeader>
<DialogTitle>{t("hosts.export.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
<DialogFooter className="shrink-0">
<InlineView
open={open}
onOpenChange={(next) => !next && onClose()}
title={t("hosts.export.title")}
width="wide"
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("hosts.export.cancel")}
</Button>
@@ -352,8 +233,121 @@ export function HostExportDialog({
<Download className="size-3.5 mr-2" />
{t("hosts.export.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
</InlineView>
);
}
-928
View File
@@ -1,928 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
enabledHostProtocols,
hostProtocolFlags,
type HostProtocols,
} from "./host-protocols";
import React, {
useState,
useEffect,
useRef,
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import type { HostDefaultsLevel } from "@/types/host-defaults";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import {
HostDefaultsEditorView,
type HostDefaultsEditorTarget,
} from "./host-defaults/HostDefaultsEditorView";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal";
import { Button } from "@/components/button";
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { toast } from "sonner";
import {
getSSHHosts,
getCredentials,
getCredentialDetails,
deleteCredential,
duplicateCredential,
updateCredential,
deployCredentialToHost,
renameCredentialFolder,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import type { SSHHostWithStatus } from "@/main-axios";
import type {
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialEditorView } from "./CredentialEditorView";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import { CredentialSidebarTree } from "./credential-tree";
import { sortCredentials, credentialPassesFilters } from "./credential-sort";
import {
makeCredentialTabs,
makeHostTabs,
makeHostSshSubTabs,
isSshGroupTab,
useHostEditorSections,
TabStrip,
} from "./HostManagerTabs";
import { Select2 } from "@/components/select2";
export function HostManager({
pendingEditId,
pendingAction,
onEditingChange,
hideListHeader,
externalSearch,
externalSort,
externalArrangeLocked = true,
externalFilter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
onTagsChange,
active = true,
}: {
pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
onEditingChange?: (editing: boolean) => void;
hideListHeader?: boolean;
externalSearch?: string;
externalSort?: CredentialSortKey;
externalArrangeLocked?: boolean;
externalFilter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
onTagsChange?: (tags: string[]) => void;
active?: boolean;
} = {}) {
// Re-render when a plugin adds or removes a host editor tab.
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
// A level of host defaults open in the editor instead of a host.
const [editingDefaults, setEditingDefaults] =
useState<HostDefaultsEditorTarget | null>(null);
// Fields a plugin filled in for a new host, keyed so a second draft remounts
// the editor.
const [hostDraft, setHostDraft] = useState<{
key: number;
draft: HostDraft;
} | null>(null);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
const [editingCredential, setEditingCredential] = useState<
Credential | "new" | null
>(null);
const [activeHostTab, setActiveHostTab] = useState("general");
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
const [searchQuery, setSearchQuery] = useState("");
const effectiveSearch = externalSearch ?? searchQuery;
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [credentialsLoading, setCredentialsLoading] = useState(true);
const [deployDialog, setDeployDialog] = useState<{
cred: Credential;
hostId: string;
} | null>(null);
const [deploying, setDeploying] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const [hostEditorDirty, setHostEditorDirty] = useState(false);
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
// Expanding advanced is a per-session choice; fixing one host's SSH options
// shouldn't quietly move the whole app off the Simple preset.
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
const hostsRef = useRef<Host[]>([]);
useEffect(() => {
hostsRef.current = hosts;
}, [hosts]);
const [editingCredFolderName, setEditingCredFolderName] = useState<
string | null
>(null);
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
// Remembers the host being edited when its credential is opened for
// editing, so "back" can return to that host instead of the list.
const [credentialReturnHost, setCredentialReturnHost] = useState<
Host | "new" | null
>(null);
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
}, [credentials]);
const applyPendingEdit = (hostList: Host[]) => {
if (pendingEditId?.current) {
const id = pendingEditId.current;
pendingEditId.current = null;
const host = hostList.find((h) => h.id === id);
if (host) {
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols(hostProtocolFlags(host));
return true;
}
}
return false;
};
const reloadHosts = () => {
getSSHHosts()
.then((raw) => {
const converted = raw.map(sshHostToHost);
setHosts(converted);
applyPendingEdit(converted);
})
.catch(() => {});
};
const reloadCredentials = () => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {})
.finally(() => setCredentialsLoading(false));
};
useEffect(() => {
reloadHosts();
reloadCredentials();
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("hosts:refresh", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener("hosts:refresh", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, []);
useEffect(() => {
if (pendingAction?.current) {
const action = pendingAction.current;
pendingAction.current = null;
if (action === "add-host") {
setEditingHost("new");
setHostDraft(null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
} else if (action === "add-credential") {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
}
}
}, [pendingEditId, pendingAction]);
useEffect(() => {
if (!active) return;
const handleAddHost = (e: Event) => {
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
setEditingHost("new");
setHostDraft(draft ? { key: Date.now(), draft } : null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
};
const handleAddCredential = () => {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
};
const handleEditDefaults = (e: Event) => {
const detail = (
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
).detail;
if (!detail) return;
const open = (target: HostDefaultsEditorTarget) => {
setEditingHost(null);
setEditingCredential(null);
setEditingDefaults(target);
};
if (detail.level === "folder" && detail.folderName) {
const folderName = detail.folderName;
getFolderDefaultsId(folderName)
.then((folderId) => open({ level: "folder", folderId, folderName }))
.catch(() => toast.error(t("hostDefaults.loadFailed")));
} else if (detail.level !== "folder") {
open({ level: detail.level });
}
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
const host = hostsRef.current.find((h) => h.id === id);
if (host) {
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols(hostProtocolFlags(host));
}
};
window.addEventListener("host-manager:add-host", handleAddHost);
window.addEventListener("host-manager:add-credential", handleAddCredential);
window.addEventListener("host-manager:edit-host", handleEditHost);
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
return () => {
window.removeEventListener(
"host-manager:edit-defaults",
handleEditDefaults,
);
window.removeEventListener("host-manager:add-host", handleAddHost);
window.removeEventListener(
"host-manager:add-credential",
handleAddCredential,
);
window.removeEventListener("host-manager:edit-host", handleEditHost);
};
}, [active]);
const allHosts = hosts;
const searchedCredentials = credentials.filter(
(c) =>
c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
c.username.toLowerCase().includes(effectiveSearch.toLowerCase()),
);
const filteredCredentials = sortCredentials(
externalFilter
? searchedCredentials.filter((c) =>
credentialPassesFilters(c, externalFilter),
)
: searchedCredentials,
externalSort ?? "default",
);
const credentialFolderNames = Array.from(
new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")),
).sort();
const credentialFolderTree = credentialFolderNames.map((name) => ({
name,
children: filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === name,
),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filteredCredentials) {
usedByCounts.set(
cred.id,
allHosts.filter((h) => h.credentialId === cred.id).length,
);
}
const handleRenameCredentialFolder = async (
folder: string,
newName: string,
) => {
try {
await renameCredentialFolder(folder, newName);
const res = await getCredentials();
setCredentials(mapCredentials(res));
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("credentials.failedToRenameFolder"));
}
};
const handleMoveCredentialToFolder = async (
credentialId: string,
targetFolder: string,
) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || "Uncategorized") === targetFolder) return;
const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
const handleCloneCredential = async (cred: Credential) => {
try {
await duplicateCredential(Number(cred.id), {
name: t("credentials.clonedCredentialName", { name: cred.name }),
});
const res = await getCredentials();
setCredentials(mapCredentials(res));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.clonedCredential", { name: cred.name }));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("credentials.failedToCloneCredential"));
}
};
const handleDeleteCredential = async (cred: Credential) => {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
};
function handleConfirmDeleteCredential(cred: Credential) {
setConfirmDialog({
message: t("credentials.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await handleDeleteCredential(cred);
toast.success(
t("credentials.deletedCredential", { name: cred.name }),
);
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
},
});
}
async function handleEditCredential(cred: Credential) {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
setEditingCredential({
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
});
setActiveCredentialTab("general");
} catch {
setEditingCredential(cred);
setActiveCredentialTab("general");
}
}
// Opens a host's credential for editing from within the host editor,
// remembering the host so "back" returns to it instead of the list.
async function handleEditCredentialFromHost(credentialId: string) {
const cred = credentials.find((c) => String(c.id) === String(credentialId));
if (!cred) return;
setCredentialReturnHost(editingHost);
await handleEditCredential(cred);
// The editor view keys off editingHost, so it has to be cleared or the
// host editor keeps rendering over the credential we just opened.
setEditingHost(null);
}
// Editor view: full-width with top tab bar instead of side nav
const closeHostEditor = () => {
setHostEditorDirty(false);
setShowUnsavedHostDialog(false);
setEditingHost(null);
setEditingDefaults(null);
setActiveHostTab("general");
};
const requestCloseHostEditor = () => {
if (hostEditorDirty) {
setShowUnsavedHostDialog(true);
return;
}
closeHostEditor();
};
const renderEditorView = () => {
if (editingDefaults) {
return (
<HostDefaultsEditorView
target={editingDefaults}
onClose={requestCloseHostEditor}
onDirtyChange={setHostEditorDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
const isHost = !!editingHost;
// Simple mode keeps General and SSH -- between them they hold everything
// needed to reach a host (name, address, username, auth) -- and hides the
// other protocol tabs plus the seven SSH sub-tabs. Nothing is lost:
// createHostEditorForm still materializes every field with its default and
// buildHostEditorPayload still serializes all of them, so hiding a tab
// hides its inputs, not its values.
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
const protocols = editingProtocols as unknown as Record<string, boolean>;
// Plugin tabs decide their own visibility from the host's protocols.
const tabs = isHost
? makeHostTabs(t, protocols).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editingProtocols.enableSsh;
return !collapseAdvanced;
})
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
// Registered SSH-group tabs count too: checking a fixed list here is what
// left plugin tabs unreachable.
const hostTabVisible =
tabs.some((tab) => tab.id === activeHostTab) ||
(!collapseAdvanced &&
editingProtocols.enableSsh &&
sshSubTabs.some((tab) => tab.id === activeHostTab));
const effectiveHostTab = hostTabVisible
? activeHostTab
: collapseAdvanced && isSshGroupTab(activeHostTab)
? "ssh"
: "general";
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
const showSshSubTabs =
isHost &&
!collapseAdvanced &&
editingProtocols.enableSsh &&
isSshGroupTab(activeHostTab);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Back bar + tab strip */}
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
if (isHost) {
requestCloseHostEditor();
} else if (credentialReturnHost) {
setEditingHost(credentialReturnHost);
setCredentialReturnHost(null);
setEditingCredential(null);
setActiveCredentialTab("general");
} else {
setEditingCredential(null);
setActiveCredentialTab("general");
}
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{isHost
? t("hosts.backToHosts")
: credentialReturnHost
? t("hosts.backToHost")
: t("credentials.backToCredentials")}
</span>
{isHost && editingHost !== "new" && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={(editingHost as Host).name}
>
{(editingHost as Host).name}
</span>
)}
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (isHost && id === "ssh") {
if (!isSshGroupTab(activeHostTab)) {
setActiveHostTab("ssh");
}
} else {
setActiveTab(id);
}
}}
isActive={
isHost
? (id) =>
id === "ssh"
? isSshGroupTab(activeHostTab)
: activeHostTab === id
: undefined
}
/>
{showSshSubTabs && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeHostTab}
onTabChange={setActiveHostTab}
variant="secondary"
/>
)}
{isHost && simpleEditor && (
<button
onClick={() => {
if (showAdvancedEditor) setActiveHostTab("general");
setShowAdvancedEditor(!showAdvancedEditor);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
>
<ChevronDown
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
/>
{showAdvancedEditor
? t("hosts.hideAdvancedSettings")
: t("hosts.showAdvancedSettings")}
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<HostEditor
key={
editingHost === "new"
? `new-host-${hostDraft?.key ?? ""}`
: (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
draft={editingHost === "new" ? hostDraft?.draft : undefined}
activeTab={effectiveHostTab}
simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor}
onSave={(saved) => {
const updated = sshHostToHost(
saved as unknown as SSHHostWithStatus,
);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
closeHostEditor();
}}
protocols={editingProtocols}
onProtocolChange={(p) =>
setEditingProtocols((prev) => ({ ...prev, ...p }))
}
onDirtyChange={setHostEditorDirty}
onTabChange={setActiveHostTab}
hosts={hosts}
credentials={credentials}
onEditCredential={handleEditCredentialFromHost}
/>
) : (
<CredentialEditorView
key={
editingCredential === "new"
? "new-cred"
: (editingCredential as Credential).id
}
credential={
editingCredential === "new"
? null
: (editingCredential as Credential)
}
activeTab={activeCredentialTab}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
saveAsNewHost={
credentialReturnHost ? credentialReturnHost : undefined
}
onBack={() => {
if (credentialReturnHost) {
setEditingHost(credentialReturnHost);
setCredentialReturnHost(null);
setEditingCredential(null);
setActiveCredentialTab("general");
return;
}
setEditingCredential(null);
setActiveCredentialTab("general");
}}
onSave={(saved, options) => {
// The save endpoints are typed as an untyped record; these are
// the fields this view reads back off the response.
const result = saved as Partial<Credential> & {
id: number | string;
authType?: string;
};
setCredentials((prev) => {
const idx = prev.findIndex((c) => c.id === String(result.id));
const updated: Credential = {
id: String(result.id),
name: result.name ?? "",
username: result.username ?? "",
type: result.authType === "key" ? "key" : "password",
value: result.value,
password: result.password,
publicKey: result.publicKey,
passphrase: result.passphrase,
description: result.description,
folder: result.folder ?? "",
tags: result.tags ?? [],
};
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
if (options?.assignToHost && credentialReturnHost) {
const returnHost = credentialReturnHost;
setCredentialReturnHost(null);
if (returnHost !== "new") {
setHosts((prev) =>
prev.map((h) =>
h.id === returnHost.id
? { ...h, credentialId: String(result.id) }
: h,
),
);
}
setEditingHost(
returnHost === "new"
? "new"
: { ...returnHost, credentialId: String(result.id) },
);
setActiveHostTab("ssh");
setEditingCredential(null);
return;
}
setEditingCredential(null);
setActiveCredentialTab("general");
}}
/>
)}
</div>
</div>
);
};
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
useEffect(() => {
if (active) onEditingChange?.(isEditing);
}, [isEditing, active]);
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{isEditing ? (
renderEditorView()
) : (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Search bar — hidden when parent supplies its own */}
{!hideListHeader && (
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("credentials.searchCredentialsPlaceholder")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={credentialFolderTree}
usedByCounts={usedByCounts}
query=""
loading={credentialsLoading}
arrangeLocked={externalArrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
editingFolderName={editingCredFolderName}
editingFolderValue={editingCredFolderValue}
onEditingFolderNameChange={setEditingCredFolderName}
onEditingFolderValueChange={setEditingCredFolderValue}
onRenameFolder={handleRenameCredentialFolder}
onMoveCredentialToFolder={handleMoveCredentialToFolder}
onDeployCredential={(cred) =>
setDeployDialog({ cred, hostId: "" })
}
onEditCredential={handleEditCredential}
onCloneCredential={handleCloneCredential}
onDeleteCredential={handleConfirmDeleteCredential}
onShareCredential={setShareCredential}
/>
)}
</div>
)}
<CredentialShareModal
credential={shareCredential}
onClose={() => setShareCredential(null)}
/>
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
{showUnsavedHostDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-foreground">
{t("common.unsavedChanges")}
</p>
<p className="text-xs text-muted-foreground">
{t("hosts.unsavedChangesDescription")}
</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowUnsavedHostDialog(false)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.keepEditing")}
</button>
<button
onClick={closeHostEditor}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.discardChanges")}
</button>
</div>
</div>
</div>
)}
{/* Deploy credential dialog */}
{deployDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">
{t("credentials.deployDialogTitle")}
</span>
<button
onClick={() => setDeployDialog(null)}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="text-xs text-muted-foreground">
{t("credentials.deployDialogDesc", {
name: deployDialog.cred.name,
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.targetHostLabel")}
</label>
<Select2
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={deployDialog.hostId}
onChange={(e) =>
setDeployDialog({ ...deployDialog, hostId: e.target.value })
}
>
<option value="">{t("credentials.selectHostOption")}</option>
{allHosts
.filter(
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployDialog(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployDialog.hostId || deploying}
onClick={async () => {
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployDialog.cred.id),
Number(deployDialog.hostId),
);
toast.success(t("credentials.keyDeployedSuccess"));
setDeployDialog(null);
} catch {
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
}}
>
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+3 -127
View File
@@ -1,19 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import {
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
ChevronLeft,
ChevronRight,
KeyRound,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type ComponentType, type ReactNode } from "react";
import { KeyRound, Settings, Terminal } from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
@@ -196,115 +183,4 @@ export function makeCredentialTabs(
];
}
export function TabStrip({
tabs,
activeTab,
onTabChange,
isActive,
variant = "primary",
}: {
tabs: { id: string; label: string; icon: ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
isActive?: (id: string) => boolean;
variant?: "primary" | "secondary";
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer = new ResizeObserver(update);
observer.observe(el);
if (el.firstElementChild) observer.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const renderTab = (tab: (typeof tabs)[0]) => {
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 ${
variant === "secondary" ? "py-1.5 text-[11px]" : "py-2 text-xs"
} font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
active
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
</button>
);
};
const overflow = scroll.left || scroll.right;
return (
<div
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
</div>
);
}
export { TabStrip } from "@/components/tab-strip";
+40 -20
View File
@@ -5,8 +5,8 @@ import {
type FolderAccessRule,
} from "@/api/rbac-api";
import { useTranslation } from "react-i18next";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
ArrowLeft,
Check,
ListChecks,
Search,
@@ -70,6 +70,7 @@ export function HostShareModal({
folder?: string | null;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const isFolderShare = !host && !!folder;
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
@@ -278,23 +279,20 @@ export function HostShareModal({
}
}
if (!open) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
{/* Header */}
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
</span>
</button>
<InlineView
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
icon={<Share2 className="size-4" />}
title={
isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })
}
bare
>
{sharingLoadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
@@ -544,8 +542,19 @@ export function HostShareModal({
type="button"
className="text-[10px] text-muted-foreground hover:text-destructive"
title={t("hosts.sharing.folderRuleRemove")}
onClick={() => {
void revokeFolderAccess(rule.id).then(refreshFolderRules);
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
rule.username ??
rule.roleDisplayName ??
rule.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (ok)
void revokeFolderAccess(rule.id).then(refreshFolderRules);
}}
>
<X className="size-3.5" />
@@ -626,6 +635,17 @@ export function HostShareModal({
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
record.username ??
record.roleDisplayName ??
record.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeHostAccess(
Number(host!.id),
@@ -676,6 +696,6 @@ export function HostShareModal({
</div>
</div>
)}
</div>
</InlineView>
);
}
+4 -17
View File
@@ -26,7 +26,6 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
@@ -200,7 +199,6 @@ export function HostsPanel({
onEditHost,
hostTree,
loading,
onEditingChange,
active = true,
}: {
onOpenTab: (
@@ -215,6 +213,7 @@ export function HostsPanel({
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
loading?: boolean;
/** Unused since editing moved to the Manage tab. */
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
@@ -238,7 +237,6 @@ export function HostsPanel({
const hostProtocols = useHostProtocols();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@@ -345,11 +343,6 @@ export function HostsPanel({
};
}, []);
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
function toggleSelectionMode() {
setSelectionMode((v) => !v);
}
@@ -422,7 +415,7 @@ export function HostsPanel({
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-none">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
@@ -1023,7 +1016,7 @@ export function HostsPanel({
</div>
</div>
</div>
)}
}
<CustomizeSidebarPanel
open={customizePanelOpen}
onOpenChange={setCustomizePanelOpen}
@@ -1031,7 +1024,7 @@ export function HostsPanel({
update={updateSidebarPrefs}
/>
{active && !managerEditing && (
{active && (
<div className="flex flex-col flex-1 min-h-0">
<SidebarTree
children={
@@ -1069,12 +1062,6 @@ export function HostsPanel({
</div>
)}
<div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
>
<HostManager onEditingChange={handleEditingChange} active={active} />
</div>
<HostShareModal
open={shareModalHost !== null}
onClose={() => setShareModalHost(null)}
+16 -56
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useConfirm } from "@/components/surface/surface-scope";
import {
Check,
Gauge,
@@ -9,14 +10,6 @@ import {
Wrench,
} from "lucide-react";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { usePluginStore } from "@/plugin-host/plugin-store";
@@ -45,10 +38,7 @@ export function InterfacePresetSettings({
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const [pendingPreset, setPendingPreset] = useState<Exclude<
UiPreset,
"custom"
> | null>(null);
const confirm = useConfirm();
const [applying, setApplying] = useState(false);
// The chosen preset stays highlighted even once knobs diverge from it --
@@ -87,18 +77,25 @@ export function InterfacePresetSettings({
if (!ctx) return null;
async function confirmPreset() {
if (!pendingPreset) return;
async function choosePreset(preset: Exclude<UiPreset, "custom">) {
const ok = await confirm({
title: t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: t(`newUi.sidebar.userProfile.preset_${preset}`),
}),
description: t("newUi.sidebar.userProfile.presetConfirmDesc"),
confirmLabel: t("newUi.sidebar.userProfile.presetConfirmApply"),
destructive: false,
});
if (!ok) return;
setApplying(true);
try {
ctx.setPreset(pendingPreset);
ctx.setPreset(preset);
// Picking a preset is a deliberate reset, so any knob the user had
// pinned goes back to following the preset.
ctx.clearAllOverrides();
await applyPresetSideEffects(pendingPreset);
await applyPresetSideEffects(preset);
} finally {
setApplying(false);
setPendingPreset(null);
}
}
@@ -118,7 +115,8 @@ export function InterfacePresetSettings({
<button
key={preset}
type="button"
onClick={() => setPendingPreset(preset)}
disabled={applying}
onClick={() => void choosePreset(preset)}
aria-pressed={active}
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
active
@@ -203,44 +201,6 @@ export function InterfacePresetSettings({
</Button>
</div>
)}
<Dialog
open={!!pendingPreset}
onOpenChange={(open) => !open && setPendingPreset(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: pendingPreset
? t(`newUi.sidebar.userProfile.preset_${pendingPreset}`)
: "",
})}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.presetConfirmDesc")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingPreset(null)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={applying}
onClick={confirmPreset}
>
{t("newUi.sidebar.userProfile.presetConfirmApply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-494
View File
@@ -1,494 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
} from "@/types/keybindings";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "./FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
if (combo.alt) parts.push("Alt");
if (combo.shift) parts.push("Shift");
if (combo.meta) parts.push("Cmd");
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
return parts.join(" + ");
}
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Appearance > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
string | undefined
>(undefined);
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
if (!open) return;
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, [open]);
const conflictWarning = useMemo(() => {
if (!recordedCombo) return null;
const conflict = bindings.find(
(kb) =>
kb.id !== editingId &&
kb.enabled &&
kb.combo.ctrl === recordedCombo.ctrl &&
kb.combo.alt === recordedCombo.alt &&
kb.combo.shift === recordedCombo.shift &&
kb.combo.meta === recordedCombo.meta &&
kb.combo.key === recordedCombo.key,
);
if (conflict) {
return t("newUi.sidebar.keybindings.conflictWarning", {
combo: formatCombo(conflict.combo),
});
}
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
resetForm();
setFormOpen(true);
}
function openEditForm(kb: CustomKeybinding) {
setEditingId(kb.id);
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setDraft({ ...kb.action });
setFormOpen(true);
}
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
resetForm();
setOverridesDefaultId(defaultId);
setRecordedCombo(combo);
setFormOpen(true);
}
async function persist(updated: CustomKeybinding[]) {
try {
await saveUserPreferences({
customKeybindings: JSON.stringify(updated),
});
setBindings(updated);
window.dispatchEvent(new Event("customKeybindingsChanged"));
} catch {
toast.error(t("newUi.sidebar.keybindings.saveError"));
}
}
function handleDelete(id: string) {
persist(bindings.filter((kb) => kb.id !== id));
}
function handleResetAll() {
persist([]);
}
function startRecording(inputEl: HTMLInputElement) {
setRecording(true);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
const combo: KeyCombo = {
key: isAlnum ? e.key.toLowerCase() : e.code,
isCode: !isAlnum,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
};
setRecordedCombo(combo);
setRecording(false);
inputEl.removeEventListener("keydown", handler, true);
inputEl.blur();
};
inputEl.addEventListener("keydown", handler, true);
}
function handleSave() {
if (!recordedCombo) {
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
const now = new Date().toISOString();
const existing = editingId
? bindings.find((kb) => kb.id === editingId)
: undefined;
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const updated = editingId
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
: [...bindings, newBinding];
persist(updated);
setFormOpen(false);
resetForm();
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.keybindings.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(override.id)}
title={t(
"newUi.sidebar.keybindings.resetToDefault",
)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.customHeading")}
</span>
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
</div>
{loading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.loading")}
</span>
) : customOnlyBindings.length === 0 ? (
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.keybindings.noCustomBindings")}
</span>
) : (
customOnlyBindings.map((kb) => {
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(kb)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
</div>
<div className="flex items-center justify-between gap-2 mt-2">
<Button
variant="ghost"
className="text-muted-foreground"
onClick={handleResetAll}
disabled={bindings.length === 0}
>
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("newUi.sidebar.keybindings.close")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
? t("newUi.sidebar.keybindings.recording")
: recordedCombo
? formatCombo(recordedCombo)
: ""
}
placeholder={t(
"newUi.sidebar.keybindings.pressKeysPlaceholder",
)}
onFocus={(e) => startRecording(e.currentTarget)}
className="font-mono cursor-pointer"
/>
{conflictWarning && (
<span className="text-xs text-yellow-500">
{conflictWarning}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</select>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => setFormOpen(false)}>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.keybindings.saveBinding")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PanelRight, SquareArrowOutUpRight, X } from "lucide-react";
const STORAGE_KEY = "termix_multiPanelHintDismissed";
function multiPanelHintDismissed(): boolean {
return localStorage.getItem(STORAGE_KEY) === "true";
}
/**
* One-time nudge telling users a panel can leave the sidebar. Both actions are
* otherwise only reachable through modifier clicks and the rail context menu,
* which nobody finds on their own.
*/
export function MultiPanelHint({
canPromote,
canRightDock,
onOpenAsTab,
onOpenInRightDock,
}: {
canPromote: boolean;
canRightDock: boolean;
onOpenAsTab: () => void;
onOpenInRightDock: () => void;
}) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(multiPanelHintDismissed);
if (dismissed || (!canPromote && !canRightDock)) return null;
function dismiss() {
localStorage.setItem(STORAGE_KEY, "true");
setDismissed(true);
}
return (
<div className="relative shrink-0 border-b border-border bg-accent-brand/5 px-3 py-2.5">
<button
onClick={dismiss}
title={t("common.dismiss")}
aria-label={t("common.dismiss")}
className="absolute right-1.5 top-1.5 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
<p className="pr-5 text-xs font-semibold text-foreground">
{t("nav.multiPanelHintTitle")}
</p>
<p className="mt-0.5 pr-5 text-[11px] leading-snug text-muted-foreground">
{t("nav.multiPanelHintBody")}
</p>
<div className="mt-2 flex flex-col gap-1.5">
{canPromote && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<SquareArrowOutUpRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenAsTab();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openAsTab")}
</button>
{": "}
{t("nav.multiPanelHintTabHow")}
</span>
</div>
)}
{canRightDock && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<PanelRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenInRightDock();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openInRightDock")}
</button>
{": "}
{t("nav.multiPanelHintDockHow")}
</span>
</div>
)}
</div>
</div>
);
}
+182 -177
View File
@@ -1,3 +1,4 @@
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { useState, useRef, useEffect } from "react";
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
import { useTranslation } from "react-i18next";
@@ -26,14 +27,6 @@ import { shell } from "@/plugin-host/shell-bridge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { VersionBadge } from "@/components/version-badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/dialog";
import {
AlertCircle,
ChevronDown,
@@ -56,8 +49,8 @@ import {
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
import { InterfacePresetSettings } from "./InterfacePresetSettings";
import { useSettingsEmbedded } from "@/settings/settings-embed";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { KeybindingsDialog } from "./KeybindingsDialog";
import {
ACCENT_PRESET_COLORS,
applyAccentColor,
@@ -157,7 +150,19 @@ export function AccordionSection({
hidden?: boolean;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (hidden) return null;
if (embedded) {
if (!open) return null;
return (
<div
id={`${id}-content`}
className="flex flex-col border border-border bg-card px-3 pb-3"
>
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden">
@@ -253,88 +258,14 @@ export function NewApiKeyDialog({
};
return (
<Dialog
<InlineView
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || !createdToken) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-center gap-2.5">
<div className="size-8 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-3.5 text-accent-brand" />
</div>
<div>
<DialogTitle className="text-base font-bold leading-none">
{t("newUi.sidebar.userProfile.createApiKeyTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
</DialogDescription>
</div>
</div>
</DialogHeader>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t(
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
)}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
)}
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{createdToken ? (
<Button
variant="outline"
@@ -363,9 +294,68 @@ export function NewApiKeyDialog({
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
</p>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t("newUi.sidebar.userProfile.apiKeyNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
)}
</InlineView>
);
}
@@ -485,7 +475,13 @@ export function UserProfilePanel({
onLogout,
userPrefs,
onPrefsChange,
section,
onOpenSettingsPage,
}: {
/** Shows just this section, flat, as a Settings page. */
section?: string;
/** Moves the Settings tab to another page. */
onOpenSettingsPage?: (id: string) => void;
username?: string;
onLogout?: () => void;
userPrefs?: {
@@ -509,6 +505,7 @@ export function UserProfilePanel({
}) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const themeLabel: Record<ThemeId, string> = {
system: t("newUi.sidebar.userProfile.themeSystem"),
light: t("newUi.sidebar.userProfile.themeLight"),
@@ -603,7 +600,6 @@ export function UserProfilePanel({
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
// Sidebar display customization (density, tags, tray trigger, status
// colors) now lives in the dedicated Customize Sidebar panel opened from
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
@@ -1182,9 +1178,18 @@ export function UserProfilePanel({
}
const canChangePasword = !isExternal || isDualAuth;
const isOpen = (id: string) =>
section ? section === id : openSections.has(id as UserProfileSection);
const shows = (id: string) => !section || section === id;
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3"
}
>
<NewApiKeyDialog
open={newKeyOpen}
onOpenChange={setNewKeyOpen}
@@ -1193,30 +1198,32 @@ export function UserProfilePanel({
/>
{/* Donate banner */}
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
{shows("account") && (
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
)}
{/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isLinked) && (
{(!isElectron() || isLinked) && shows("data") && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")}
@@ -1261,7 +1268,7 @@ export function UserProfilePanel({
id="account"
label={t("newUi.sidebar.userProfile.sectionAccount")}
icon={<User className="size-3.5" />}
open={openSections.has("account")}
open={isOpen("account")}
onToggle={() => toggle("account")}
>
<div className="flex flex-col gap-0 pt-2">
@@ -1447,7 +1454,7 @@ export function UserProfilePanel({
id="interface"
label={t("newUi.sidebar.userProfile.sectionInterface")}
icon={<LayoutTemplate size={13} />}
open={openSections.has("interface")}
open={isOpen("interface")}
onToggle={() => toggle("interface")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1464,7 +1471,7 @@ export function UserProfilePanel({
id="appearance"
label={t("newUi.sidebar.userProfile.sectionAppearance")}
icon={<Palette className="size-3.5" />}
open={openSections.has("appearance")}
open={isOpen("appearance")}
onToggle={() => toggle("appearance")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1641,7 +1648,7 @@ export function UserProfilePanel({
<Button
variant="outline"
size="sm"
onClick={() => setKeybindingsDialogOpen(true)}
onClick={() => onOpenSettingsPage?.("keybindings")}
>
{t("newUi.sidebar.userProfile.manageShortcuts")}
</Button>
@@ -1812,7 +1819,7 @@ export function UserProfilePanel({
id="security"
label={t("newUi.sidebar.userProfile.sectionSecurity")}
icon={<Shield className="size-3.5" />}
open={openSections.has("security")}
open={isOpen("security")}
onToggle={() => toggle("security")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1833,7 +1840,7 @@ export function UserProfilePanel({
id="api-keys"
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={openSections.has("api-keys")}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
>
<div className="flex flex-col gap-2 pt-3">
@@ -1896,6 +1903,13 @@ export function UserProfilePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", {
name: key.name,
}),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) =>
@@ -1938,7 +1952,7 @@ export function UserProfilePanel({
id="data"
label={t("newUi.sidebar.userProfile.sectionData")}
icon={<Database className="size-3.5" />}
open={openSections.has("data")}
open={isOpen("data")}
onToggle={() => toggle("data")}
>
<div className="flex flex-col gap-3 pt-3">
@@ -2013,78 +2027,69 @@ export function UserProfilePanel({
key={plugin.id}
plugin={plugin}
scope="user"
open={openSections.has(featureSectionId(plugin.id))}
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
{/* Delete account dialog */}
<Dialog
<InlineView
open={showDeleteConfirm}
onOpenChange={(open) => {
setShowDeleteConfirm(open);
if (!open) setDeletePassword("");
}}
title={t("newUi.sidebar.userProfile.deleteAccount")}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-destructive">
{t("newUi.sidebar.userProfile.deleteAccount")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
</label>
<Input
type="password"
placeholder={t(
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
)}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
</p>
<div className="flex flex-col gap-3 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
</span>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
setDeletePassword("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword.trim()}
>
<Trash2 className="size-3.5" />
{deleteLoading
? t("newUi.sidebar.userProfile.deleting")
: t("newUi.sidebar.userProfile.deleteAccount")}
</Button>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
</label>
<Input
type="password"
placeholder={t(
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
)}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
/>
</div>
</DialogContent>
</Dialog>
<KeybindingsDialog
open={keybindingsDialogOpen}
onOpenChange={setKeybindingsDialogOpen}
/>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
setDeletePassword("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword.trim()}
>
<Trash2 className="size-3.5" />
{deleteLoading
? t("newUi.sidebar.userProfile.deleting")
: t("newUi.sidebar.userProfile.deleteAccount")}
</Button>
</div>
</InlineView>
</div>
);
}
+49
View File
@@ -1,3 +1,8 @@
import { useEffect, useState } from "react";
import { getUserPreferences, saveUserPreferences } from "@/main-axios";
export const HIDDEN_RAIL_TABS_EVENT = "hiddenRailTabsChanged";
export function readHiddenRailTabs(): Set<string> {
try {
const raw = localStorage.getItem("hiddenRailTabs");
@@ -8,3 +13,47 @@ export function readHiddenRailTabs(): Set<string> {
return new Set();
}
}
/**
* The one writer for hidden rail items: the rail's restore list, the
* Navigation toggles in settings and presets all go through here, so they
* never disagree. Saved to the account when settings sync to the cloud.
*/
export async function writeHiddenRailTabs(
next: Iterable<string>,
): Promise<void> {
const serialized = JSON.stringify([...next]);
try {
localStorage.setItem("hiddenRailTabs", serialized);
} catch {
// A private window refuses the write; the change still applies this session.
}
window.dispatchEvent(new Event(HIDDEN_RAIL_TABS_EVENT));
try {
const prefs = await getUserPreferences();
if (prefs?.storageMode === "cloud") {
await saveUserPreferences({ hiddenRailTabs: serialized });
}
} catch {
// Offline; the local copy is already updated.
}
}
/** Hides one item if it is shown, shows it if hidden. */
export function toggleHiddenRailTab(id: string): Promise<void> {
const hidden = readHiddenRailTabs();
if (hidden.has(id)) hidden.delete(id);
else hidden.add(id);
return writeHiddenRailTabs(hidden);
}
/** The hidden rail items, kept current as anything changes them. */
export function useHiddenRailTabs(): Set<string> {
const [hidden, setHidden] = useState(readHiddenRailTabs);
useEffect(() => {
const handler = () => setHidden(readHiddenRailTabs());
window.addEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
return () => window.removeEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
}, []);
return hidden;
}
@@ -1,16 +1,10 @@
import { useMemo, useState } from "react";
/* eslint-disable react-refresh/only-export-components */
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import type { Host } from "@/types/ui-types";
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
import { HostEditor } from "../HostEditor";
import {
isSshGroupTab,
makeHostSshSubTabs,
makeHostTabs,
TabStrip,
useHostEditorSections,
} from "../HostManagerTabs";
import { useHostEditorSections } from "../HostManagerTabs";
import { listHostProtocols, type HostProtocols } from "../host-protocols";
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
@@ -18,8 +12,18 @@ export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
folderName?: string;
}
/** The title a level of defaults goes by. */
export function hostDefaultsTitle(
target: HostDefaultsEditorTarget,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (target.level === "admin") return t("hostDefaults.titleAdmin");
if (target.level === "user") return t("hostDefaults.titleUser");
return t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
}
/**
* The host editor as a form for one level of host defaults: the same tabs
* The host editor as a form for one level of host defaults: the same sections
* and fields as a new host, minus what only one host can have (its name,
* address, secrets, folder).
*/
@@ -38,25 +42,14 @@ export function HostDefaultsEditorView({
}) {
const { t } = useTranslation();
useHostEditorSections();
const [activeTab, setActiveTab] = useState("general");
// Every protocol counts as on, so every tab that has defaults shows.
// Every protocol counts as on, so every section that has defaults shows.
const protocols = useMemo<HostProtocols>(() => {
const all: HostProtocols = { enableSsh: true };
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
return all;
}, []);
const flags = protocols as unknown as Record<string, boolean>;
const tabs = makeHostTabs(t, flags, true);
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
const inSshGroup = isSshGroupTab(activeTab);
const title =
target.level === "admin"
? t("hostDefaults.titleAdmin")
: target.level === "user"
? t("hostDefaults.titleUser")
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
const description =
target.level === "admin"
? t("hostDefaults.descriptionAdmin")
@@ -65,61 +58,22 @@ export function HostDefaultsEditorView({
: t("hostDefaults.descriptionFolder");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t("hosts.backToHosts")}</span>
<span
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
title={title}
>
{title}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (id === "ssh") {
if (!inSshGroup) setActiveTab("ssh");
} else {
setActiveTab(id);
}
}}
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
/>
{inSshGroup && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
variant="secondary"
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
banner={
<p className="border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{description}
</p>
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
activeTab={activeTab}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
onTabChange={setActiveTab}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
</div>
</div>
}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
);
}
+32 -9
View File
@@ -3,8 +3,8 @@ import {
KeyRound,
Plug,
Server,
LibraryBig,
Settings,
User,
Zap,
type LucideIcon,
} from "lucide-react";
@@ -21,7 +21,7 @@ import { getTabType } from "@/shell/tab-registry";
*
* This used to be duplicated in four places -- AppRail's button array, the
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
* MobileBottomBar's own primary/more lists -- which drifted: half the sidebar
* the mobile bar's own primary/more lists -- which drifted: half the sidebar
* titles were hardcoded English, the alerts entry had no visibility toggle,
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
* here, so adding a destination is a single edit.
@@ -38,6 +38,8 @@ export interface RailItemDef {
alwaysVisible?: boolean;
/** Renders a separator after this item in the rail. */
separatorAfter?: boolean;
/** The rail band it sits in. Defaults to "tools". */
group?: RailGroup;
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
mobilePrimary?: boolean;
/**
@@ -72,13 +74,24 @@ export interface RailItemDef {
useBadge?: () => number | null | undefined;
}
/** Things you keep, things you do, and the instance itself. */
export type RailGroup = "objects" | "tools" | "system";
export const RAIL_GROUP_ORDER: RailGroup[] = ["objects", "tools", "system"];
export const RAIL_ITEMS: RailItemDef[] = [
{ id: "hosts", icon: Server, labelKey: "nav.hosts", mobilePrimary: true },
{
id: "hosts",
icon: Server,
labelKey: "nav.hosts",
mobilePrimary: true,
group: "objects",
},
{
id: "credentials",
icon: KeyRound,
labelKey: "nav.credentials",
separatorAfter: true,
group: "objects",
},
{
id: "connections",
@@ -86,6 +99,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.connections",
separatorAfter: true,
rightDockable: true,
group: "tools",
},
{
id: "quick-connect",
@@ -93,6 +107,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.quickConnect",
separatorAfter: true,
mobilePrimary: true,
group: "tools",
},
{
id: "sync",
@@ -100,10 +115,21 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.sync",
electronOnly: true,
placement: "footer",
group: "system",
useBadge: useSyncAttentionCount,
},
];
/** Main rail items split into their bands, in band order, empty bands left out. */
export function groupRailItems(
items: RailItemDef[],
): { group: RailGroup; items: RailItemDef[] }[] {
return RAIL_GROUP_ORDER.map((group) => ({
group,
items: items.filter((item) => (item.group ?? "tools") === group),
})).filter((band) => band.items.length > 0);
}
/**
* Rail items registered by plugins at runtime. Reactive, because a plugin can
* be enabled or disabled while the app is open and the rail, the mobile bar
@@ -203,13 +229,10 @@ export function useRailItems(): RailItemDef[] {
);
}
/**
* Destinations that live outside the rail's hideable list but still need a
* title and a mobile entry.
*/
/** Places outside the rail that still need a title, like the Settings tab. */
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
{ id: "host-manager", icon: LibraryBig, labelKey: "nav.manage" },
];
/** Ids that may be opened in the right dock. */
+2 -2
View File
@@ -1267,7 +1267,7 @@ export function HostItem({
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-warning" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
@@ -1281,7 +1281,7 @@ export function HostItem({
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-warning" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
+10 -30
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
import {
useCallback,
@@ -179,10 +180,15 @@ export function SidebarTree({
useEffect(() => {
if (!selectionMode) setSelectedHostIds(new Set());
}, [selectionMode, setSelectedHostIds]);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => Promise<void> | void;
} | null>(null);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => Promise<void> | void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } =
useSidebarDragState();
const [folderDialog, setFolderDialog] = useState<{
@@ -1437,32 +1443,6 @@ export function SidebarTree({
</div>
)}
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
<FolderMetadataDialog
open={folderDialog !== null}
mode={folderDialog?.mode ?? "create"}