feat: seperated host manager into 2 rail tabs, electron fixes for auth, shared credential fixes

This commit is contained in:
LukeGus
2026-05-20 22:55:16 -05:00
parent 15e815a632
commit 170080399b
12 changed files with 289 additions and 156 deletions
@@ -1,5 +1,5 @@
import { db } from "../database/db/index.js"; import { db } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js"; import { sshCredentials, sharedCredentials } from "../database/db/schema.js";
import { eq, and, or, isNull } from "drizzle-orm"; import { eq, and, or, isNull } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js"; import { DataCrypto } from "./data-crypto.js";
import { SystemCrypto } from "./system-crypto.js"; import { SystemCrypto } from "./system-crypto.js";
@@ -64,7 +64,7 @@ export class CredentialSystemEncryptionMigration {
cred.keyPassword, cred.keyPassword,
userDEK, userDEK,
cred.id.toString(), cred.id.toString(),
"key_password", "keyPassword",
) )
: null; : null;
@@ -105,6 +105,11 @@ export class CredentialSystemEncryptionMigration {
}) })
.where(eq(sshCredentials.id, cred.id)); .where(eq(sshCredentials.id, cred.id));
await db
.update(sharedCredentials)
.set({ needsReEncryption: true })
.where(eq(sharedCredentials.originalCredentialId, cred.id));
migrated++; migrated++;
} catch (error) { } catch (error) {
databaseLogger.warn( databaseLogger.warn(
+5 -3
View File
@@ -8,7 +8,7 @@ import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { Toaster } from "@/components/sonner"; import { Toaster } from "@/components/sonner";
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth"; import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
import { getUserInfo } from "@/main-axios"; import { getUserInfo, appReadyPromise } from "@/main-axios";
import { applyAccentColor, applyFontSize } from "@/lib/theme"; import { applyAccentColor, applyFontSize } from "@/lib/theme";
import type { FontSizeId } from "@/types/ui-types"; import type { FontSizeId } from "@/types/ui-types";
import { useServiceWorker } from "@/hooks/use-service-worker"; import { useServiceWorker } from "@/hooks/use-service-worker";
@@ -128,10 +128,12 @@ function App() {
}; };
}, []); }, []);
// Verify stored session against the server before rendering AppShell // Verify stored session against the server before rendering AppShell.
// Wait for API instances to be initialized with correct embedded/server config first.
useEffect(() => { useEffect(() => {
if (phase !== "verifying") return; if (phase !== "verifying") return;
getUserInfo() appReadyPromise
.then(() => getUserInfo())
.then(() => setPhase("idle-app")) .then(() => setPhase("idle-app"))
.catch(() => { .catch(() => {
clearStoredAuth(); clearStoredAuth();
+38 -48
View File
@@ -18,6 +18,7 @@ import { HistoryPanel } from "@/sidebar/HistoryPanel";
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel"; import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
import { SplitView } from "@/shell/SplitView"; import { SplitView } from "@/shell/SplitView";
import { TabBar } from "@/shell/TabBar"; import { TabBar } from "@/shell/TabBar";
import type { import type {
@@ -138,9 +139,9 @@ export function AppShell({
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState<RailView>("hosts"); const [railView, setRailView] = useState<RailView>("hosts");
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
const [hostManagerExpanded, setHostManagerExpanded] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(266); const [sidebarWidth, setSidebarWidth] = useState(266);
const [sidebarDragging, setSidebarDragging] = useState(false); const [sidebarDragging, setSidebarDragging] = useState(false);
const [sidebarEditing, setSidebarEditing] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -155,10 +156,6 @@ export function AppShell({
.catch(() => setIsAdmin(false)); .catch(() => setIsAdmin(false));
}, []); }, []);
const pendingHostManagerEditId = useRef<string | null>(null);
const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>(
null,
);
const lastShiftTime = useRef(0); const lastShiftTime = useRef(0);
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>( const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
new Map(), new Map(),
@@ -166,6 +163,7 @@ export function AppShell({
const sidebarTitle: Record<RailView, string> = { const sidebarTitle: Record<RailView, string> = {
hosts: "Hosts", hosts: "Hosts",
credentials: "Credentials",
"quick-connect": "Quick Connect", "quick-connect": "Quick Connect",
"ssh-tools": "SSH Tools", "ssh-tools": "SSH Tools",
snippets: "Snippets", snippets: "Snippets",
@@ -340,21 +338,25 @@ export function AppShell({
function openSingletonTab(type: TabType, pendingEvent?: string) { function openSingletonTab(type: TabType, pendingEvent?: string) {
if (type === "host-manager") { if (type === "host-manager") {
if (pendingEvent === "host-manager:add-host") if (pendingEvent === "host-manager:add-credential") {
pendingHostManagerAction.current = "add-host"; setSidebarOpen(true);
else if (pendingEvent === "host-manager:add-credential") setRailView("credentials");
pendingHostManagerAction.current = "add-credential"; setTimeout(
() =>
setHostManagerExpanded(true); window.dispatchEvent(
setSidebarOpen(true); new CustomEvent("host-manager:add-credential"),
setRailView("hosts"); ),
0,
if (pendingEvent) { );
// Use a small delay to ensure HostManager is mounted if it wasn't already, } else {
// and to allow the current render cycle to complete. setSidebarOpen(true);
setTimeout(() => { setRailView("hosts");
window.dispatchEvent(new CustomEvent(pendingEvent)); if (pendingEvent) {
}, 0); setTimeout(
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
0,
);
}
} }
return; return;
} }
@@ -422,16 +424,20 @@ export function AppShell({
if (railView === view && sidebarOpen) { if (railView === view && sidebarOpen) {
setSidebarOpen(false); setSidebarOpen(false);
} else { } else {
if (view !== railView) setSidebarEditing(false);
setRailView(view); setRailView(view);
setSidebarOpen(true); setSidebarOpen(true);
} }
} }
function editHostInManager(host: Host) { function editHostInManager(host: Host) {
pendingHostManagerEditId.current = host.id;
setHostManagerExpanded(true);
setSidebarOpen(true); setSidebarOpen(true);
setRailView("hosts"); setRailView("hosts");
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("host-manager:edit-host", { detail: host.id }),
);
}, 0);
} }
const onSidebarMouseDown = useCallback( const onSidebarMouseDown = useCallback(
@@ -465,23 +471,20 @@ export function AppShell({
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{railView === "hosts" && ( {railView === "hosts" && (
<HostsPanel <HostsPanel
expanded={hostManagerExpanded}
onExpand={() => setHostManagerExpanded(true)}
onCollapse={() => {
setHostManagerExpanded(false);
loadHosts();
}}
pendingEditId={pendingHostManagerEditId}
pendingAction={pendingHostManagerAction}
onOpenTab={(host, type) => { onOpenTab={(host, type) => {
connectHost(host, type); connectHost(host, type);
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
}} }}
onEditHost={editHostInManager} onEditHost={editHostInManager}
hostTree={realHostTree ?? undefined} hostTree={realHostTree ?? undefined}
onEditingChange={setSidebarEditing}
/> />
)} )}
{railView === "credentials" && (
<CredentialsPanel onEditingChange={setSidebarEditing} />
)}
{railView === "quick-connect" && ( {railView === "quick-connect" && (
<QuickConnectPanel <QuickConnectPanel
onConnect={(host, type) => { onConnect={(host, type) => {
@@ -547,7 +550,7 @@ export function AppShell({
<span className="flex-1 text-base font-bold tracking-tight text-foreground px-3"> <span className="flex-1 text-base font-bold tracking-tight text-foreground px-3">
{sidebarTitle[railView]} {sidebarTitle[railView]}
</span> </span>
{!hostManagerExpanded && !isMobile && ( {!isMobile && (
<> <>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<Button <Button
@@ -566,10 +569,7 @@ export function AppShell({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground" className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
onClick={() => { onClick={() => setSidebarOpen(false)}
setSidebarOpen(false);
setHostManagerExpanded(false);
}}
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
</Button> </Button>
@@ -597,18 +597,14 @@ export function AppShell({
<div <div
className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`} className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
style={{ style={{
width: sidebarOpen width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
? hostManagerExpanded && railView === "hosts"
? 720
: sidebarWidth
: 0,
transition: sidebarDragging ? "none" : "width 0.2s", transition: sidebarDragging ? "none" : "width 0.2s",
}} }}
> >
{sidebarHeader} {sidebarHeader}
{sidebarPanelContent} {sidebarPanelContent}
{sidebarOpen && !(hostManagerExpanded && railView === "hosts") && ( {sidebarOpen && !sidebarEditing && (
<div <div
onMouseDown={onSidebarMouseDown} onMouseDown={onSidebarMouseDown}
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`} className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
@@ -619,13 +615,7 @@ export function AppShell({
{/* Mobile: sidebar as overlay sheet */} {/* Mobile: sidebar as overlay sheet */}
{isMobile && ( {isMobile && (
<Sheet <Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
open={sidebarOpen}
onOpenChange={(open) => {
setSidebarOpen(open);
if (!open) setHostManagerExpanded(false);
}}
>
<SheetContent <SheetContent
side="left" side="left"
showCloseButton={false} showCloseButton={false}
+15 -12
View File
@@ -347,7 +347,7 @@ export function Auth({ onLogin }: AuthProps) {
platform: "desktop", platform: "desktop",
timestamp: Date.now(), timestamp: Date.now(),
}, },
window.location.origin, "*",
); );
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
window.history.replaceState( window.history.replaceState(
@@ -378,16 +378,19 @@ export function Auth({ onLogin }: AuthProps) {
}, [onLogin, t]); }, [onLogin, t]);
const handleElectronAuthSuccess = useCallback( const handleElectronAuthSuccess = useCallback(
async (previousJwt: string | null) => { async (token: string | null) => {
try { try {
const cookieReady = await window.electronAPI?.waitForSessionCookie?.( if (!token) {
"jwt", // No token in postMessage — fall back to waiting for the HttpOnly cookie
currentServerUrl, const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
previousJwt, "jwt",
5000, currentServerUrl,
); null,
if (cookieReady && !cookieReady.success) 5000,
throw new Error(cookieReady.error || "Auth cookie not ready"); );
if (cookieReady && !cookieReady.success)
throw new Error(cookieReady.error || "Auth cookie not ready");
}
const meRes = await getUserInfo(); const meRes = await getUserInfo();
if (!meRes) throw new Error("Failed to get user info"); if (!meRes) throw new Error("Failed to get user info");
storeAuth(meRes.username || ""); storeAuth(meRes.username || "");
@@ -447,7 +450,7 @@ export function Auth({ onLogin }: AuthProps) {
platform: "desktop", platform: "desktop",
timestamp: Date.now(), timestamp: Date.now(),
}, },
window.location.origin, "*",
); );
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
return; return;
@@ -539,7 +542,7 @@ export function Auth({ onLogin }: AuthProps) {
platform: "desktop", platform: "desktop",
timestamp: Date.now(), timestamp: Date.now(),
}, },
window.location.origin, "*",
); );
setWebviewAuthSuccess(true); setWebviewAuthSuccess(true);
return; return;
+5 -1
View File
@@ -8,7 +8,9 @@
"upload": "Upload", "upload": "Upload",
"keyPassword": "Key Password", "keyPassword": "Key Password",
"sshKey": "SSH Key", "sshKey": "SSH Key",
"uploadPrivateKeyFile": "Upload Private Key File" "uploadPrivateKeyFile": "Upload Private Key File",
"searchCredentials": "Search credentials...",
"addCredential": "Add Credential"
}, },
"homepage": { "homepage": {
"failedToLoadAlerts": "Failed to load alerts", "failedToLoadAlerts": "Failed to load alerts",
@@ -138,6 +140,7 @@
"hosts": "Hosts", "hosts": "Hosts",
"snippets": "Snippets", "snippets": "Snippets",
"hostManager": "Host Manager", "hostManager": "Host Manager",
"credentials": "Credentials",
"roleAdministrator": "Administrator", "roleAdministrator": "Administrator",
"roleUser": "User" "roleUser": "User"
}, },
@@ -536,6 +539,7 @@
"loadingHost": "Loading host...", "loadingHost": "Loading host...",
"hostNotFound": "Host not found", "hostNotFound": "Host not found",
"searchHosts": "Search hosts...", "searchHosts": "Search hosts...",
"addHost": "Add Host",
"guac": { "guac": {
"connection": "Connection", "connection": "Connection",
"authentication": "Authentication", "authentication": "Authentication",
+9
View File
@@ -865,6 +865,11 @@ export let dockerApi: AxiosInstance;
// Pre-initialize with default values to avoid undefined errors during early mounting // Pre-initialize with default values to avoid undefined errors during early mounting
initializeApiInstances(); initializeApiInstances();
let _resolveAppReady!: () => void;
export const appReadyPromise: Promise<void> = new Promise((resolve) => {
_resolveAppReady = resolve;
});
function initializeApp() { function initializeApp() {
if (isElectron()) { if (isElectron()) {
Promise.all([getServerConfig(), getEmbeddedServerStatus()]) Promise.all([getServerConfig(), getEmbeddedServerStatus()])
@@ -895,9 +900,13 @@ function initializeApp() {
error, error,
); );
initializeApiInstances(); initializeApiInstances();
})
.finally(() => {
_resolveAppReady();
}); });
} else { } else {
initializeApiInstances(); initializeApiInstances();
_resolveAppReady();
} }
} }
+5
View File
@@ -37,6 +37,11 @@ const PRIMARY_ITEMS: {
]; ];
const MORE_ITEMS: { view: RailView; icon: React.ReactNode; title: string }[] = [ const MORE_ITEMS: { view: RailView; icon: React.ReactNode; title: string }[] = [
{
view: "credentials",
icon: <KeyRound className="size-4" />,
title: "Credentials",
},
{ view: "history", icon: <Clock className="size-4" />, title: "History" }, { view: "history", icon: <Clock className="size-4" />, title: "History" },
{ {
view: "split-screen", view: "split-screen",
+6
View File
@@ -21,6 +21,7 @@ import type { SplitMode, ToolsTab } from "@/types/ui-types";
export type RailView = export type RailView =
| "hosts" | "hosts"
| "credentials"
| "quick-connect" | "quick-connect"
| ToolsTab | ToolsTab
| "user-profile" | "user-profile"
@@ -42,6 +43,11 @@ function buildRailButtons(
): RailItem[] { ): RailItem[] {
return [ return [
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") }, { view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
{
view: "credentials",
icon: <KeyRound size={16} />,
title: t("nav.credentials"),
},
{ kind: "separator" }, { kind: "separator" },
{ {
view: "quick-connect", view: "quick-connect",
+66
View File
@@ -0,0 +1,66 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Search, X } from "lucide-react";
import { HostManager } from "@/sidebar/HostManager";
export function CredentialsPanel({
onEditingChange,
}: {
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
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 items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("credentials.searchCredentials")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
)
}
title={t("credentials.addCredential")}
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
>
<Plus className="size-3 shrink-0" />
{t("credentials.addCredential")}
</button>
</div>
)}
<div className="flex flex-col flex-1 min-h-0">
<HostManager
initialSection="credentials"
hideListHeader
externalSearch={managerEditing ? undefined : search}
onEditingChange={handleEditingChange}
/>
</div>
</div>
);
}
+85 -42
View File
@@ -99,6 +99,7 @@ import {
subscribeTunnelStatuses, subscribeTunnelStatuses,
connectTunnel, connectTunnel,
disconnectTunnel, disconnectTunnel,
getCredentialDetails,
} from "@/main-axios"; } from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
@@ -5047,7 +5048,15 @@ function CredentialEditorView({
{["password", "key"].map((m) => ( {["password", "key"].map((m) => (
<button <button
key={m} key={m}
onClick={() => setCredField("type", m as any)} onClick={() =>
setCredForm((p) => ({
...p,
type: m as any,
value: "",
publicKey: "",
passphrase: "",
}))
}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`} className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
> >
{m === "key" {m === "key"
@@ -5275,13 +5284,23 @@ export function HostManager({
onCollapse, onCollapse,
pendingEditId, pendingEditId,
pendingAction, pendingAction,
onEditingChange,
initialSection,
hideListHeader,
externalSearch,
}: { }: {
onCollapse?: () => void; onCollapse?: () => void;
pendingEditId?: MutableRefObject<string | null>; pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>; pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
onEditingChange?: (editing: boolean) => void;
initialSection?: "hosts" | "credentials";
hideListHeader?: boolean;
externalSearch?: string;
} = {}) { } = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [section, setSection] = useState<"hosts" | "credentials">("hosts"); const [section, setSection] = useState<"hosts" | "credentials">(
initialSection ?? "hosts",
);
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null); const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
const [editingCredential, setEditingCredential] = useState< const [editingCredential, setEditingCredential] = useState<
Credential | "new" | null Credential | "new" | null
@@ -5289,6 +5308,7 @@ export function HostManager({
const [activeHostTab, setActiveHostTab] = useState("general"); const [activeHostTab, setActiveHostTab] = useState("general");
const [activeCredentialTab, setActiveCredentialTab] = useState("general"); const [activeCredentialTab, setActiveCredentialTab] = useState("general");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const effectiveSearch = externalSearch ?? searchQuery;
const [hosts, setHosts] = useState<Host[]>([]); const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>( const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
@@ -5501,16 +5521,16 @@ export function HostManager({
const allHosts = hosts; const allHosts = hosts;
const filteredHosts = allHosts.filter( const filteredHosts = allHosts.filter(
(h) => (h) =>
h.name.toLowerCase().includes(searchQuery.toLowerCase()) || h.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
h.ip.toLowerCase().includes(searchQuery.toLowerCase()) || h.ip.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
h.tags?.some((tg) => h.tags?.some((tg) =>
tg.toLowerCase().includes(searchQuery.toLowerCase()), tg.toLowerCase().includes(effectiveSearch.toLowerCase()),
), ),
); );
const filteredCredentials = credentials.filter( const filteredCredentials = credentials.filter(
(c) => (c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
c.username.toLowerCase().includes(searchQuery.toLowerCase()), c.username.toLowerCase().includes(effectiveSearch.toLowerCase()),
); );
const folders = Array.from(new Set(allHosts.map((h) => h.folder))).sort(); const folders = Array.from(new Set(allHosts.map((h) => h.folder))).sort();
@@ -6193,10 +6213,14 @@ export function HostManager({
const isEditing = !!editingHost || !!editingCredential; const isEditing = !!editingHost || !!editingCredential;
useEffect(() => {
onEditingChange?.(isEditing);
}, [isEditing]);
return ( return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Top bar: section switcher + actions */} {/* Top bar: section switcher + actions */}
{!isEditing && ( {!isEditing && !hideListHeader && (
<div className="flex items-center gap-0 shrink-0 border-b border-border/60"> <div className="flex items-center gap-0 shrink-0 border-b border-border/60">
{/* Section tabs */} {/* Section tabs */}
<button <button
@@ -6414,30 +6438,32 @@ export function HostManager({
renderEditorView() renderEditorView()
) : ( ) : (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Search bar */} {/* Search bar — hidden when parent supplies its own */}
<div className="px-2 py-1.5 shrink-0 border-b border-border/40"> {!hideListHeader && (
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60"> <div className="px-2 py-1.5 shrink-0 border-b border-border/40">
<Search className="size-3 text-muted-foreground/60 shrink-0" /> <div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
<input <Search className="size-3 text-muted-foreground/60 shrink-0" />
value={searchQuery} <input
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
placeholder={ onChange={(e) => setSearchQuery(e.target.value)}
section === "hosts" placeholder={
? t("hosts.searchHostsPlaceholder") section === "hosts"
: t("hosts.searchCredentialsPlaceholder") ? t("hosts.searchHostsPlaceholder")
} : t("hosts.searchCredentialsPlaceholder")
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" }
/> className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
{searchQuery && ( />
<button {searchQuery && (
onClick={() => setSearchQuery("")} <button
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors" onClick={() => setSearchQuery("")}
> className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
<X className="size-3" /> >
</button> <X className="size-3" />
)} </button>
)}
</div>
</div> </div>
</div> )}
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
{section === "hosts" && ( {section === "hosts" && (
@@ -6743,15 +6769,17 @@ export function HostManager({
{cred.type === "key" ? "KEY" : "PWD"} {cred.type === "key" ? "KEY" : "PWD"}
</span> </span>
</div> </div>
<span className="text-[11px] text-muted-foreground/50 truncate"> {(cred.username || usedByHosts.length > 0) && (
{cred.username} <span className="text-[11px] text-muted-foreground/50 truncate">
{usedByHosts.length > 0 && ( {cred.username}
<span className="text-muted-foreground/30"> {usedByHosts.length > 0 && (
{" "} <span className="text-muted-foreground/30">
· {usedByHosts.length}h {cred.username ? " · " : ""}
</span> {usedByHosts.length}h
)} </span>
</span> )}
</span>
)}
{cred.tags && cred.tags.length > 0 && ( {cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-0.5 mt-0.5 flex-wrap"> <div className="flex items-center gap-0.5 mt-0.5 flex-wrap">
{cred.tags.slice(0, 3).map((tag) => ( {cred.tags.slice(0, 3).map((tag) => (
@@ -6805,8 +6833,23 @@ export function HostManager({
)} )}
<button <button
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted rounded transition-colors" className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted rounded transition-colors"
onClick={() => { onClick={async () => {
setEditingCredential(cred); try {
const full = await getCredentialDetails(
Number(cred.id),
);
setEditingCredential({
...cred,
value:
(full as any).password ??
(full as any).key ??
"",
passphrase:
(full as any).keyPassword ?? "",
});
} catch {
setEditingCredential(cred);
}
setActiveCredentialTab("general"); setActiveCredentialTab("general");
}} }}
> >
+47 -47
View File
@@ -1,73 +1,67 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Search, Settings, X } from "lucide-react"; import { Plus, Search, X } from "lucide-react";
import { SidebarTree } from "@/sidebar/SidebarTree"; import { SidebarTree } from "@/sidebar/SidebarTree";
import { HostManager } from "@/sidebar/HostManager"; import { HostManager } from "@/sidebar/HostManager";
import type { Host, HostFolder, TabType } from "@/types/ui-types"; import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type { MutableRefObject } from "react";
export function HostsPanel({ export function HostsPanel({
expanded,
onExpand,
onCollapse,
pendingEditId,
pendingAction,
onOpenTab, onOpenTab,
onEditHost, onEditHost,
hostTree, hostTree,
onEditingChange,
}: { }: {
expanded: boolean;
onExpand: () => void;
onCollapse: () => void;
pendingEditId: MutableRefObject<string | null>;
pendingAction: MutableRefObject<"add-host" | "add-credential" | null>;
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
onEditHost: (host: Host) => void; onEditHost: (host: Host) => void;
hostTree?: HostFolder; hostTree?: HostFolder;
onEditingChange?: (editing: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [hostSearch, setHostSearch] = useState(""); const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
if (expanded) { function handleEditingChange(editing: boolean) {
return ( setManagerEditing(editing);
<HostManager onEditingChange?.(editing);
onCollapse={onCollapse}
pendingEditId={pendingEditId}
pendingAction={pendingAction}
/>
);
} }
return ( return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60"> {!managerEditing && (
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0"> <div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
<Search className="size-3 text-muted-foreground/60 shrink-0" /> <div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
<input <Search className="size-3 text-muted-foreground/60 shrink-0" />
value={hostSearch} <input
onChange={(e) => setHostSearch(e.target.value)} value={hostSearch}
placeholder={t("hosts.searchHosts")} onChange={(e) => setHostSearch(e.target.value)}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" placeholder={t("hosts.searchHosts")}
/> className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
{hostSearch && ( />
<button {hostSearch && (
onClick={() => setHostSearch("")} <button
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors" onClick={() => setHostSearch("")}
> className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
<X className="size-3" /> >
</button> <X className="size-3" />
)} </button>
)}
</div>
<button
onClick={() =>
window.dispatchEvent(new CustomEvent("host-manager:add-host"))
}
title={t("hosts.addHost")}
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
>
<Plus className="size-3 shrink-0" />
{t("hosts.addHost")}
</button>
</div> </div>
<button )}
onClick={onExpand}
title="Manage Hosts" <div
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 border border-border/60 rounded-sm shrink-0 transition-colors" className={`flex-1 min-h-0 overflow-y-auto py-1 ${managerEditing ? "hidden" : ""}`}
> >
<Settings className="size-3 shrink-0" />
Manage
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
<SidebarTree <SidebarTree
children={hostTree?.children ?? []} children={hostTree?.children ?? []}
onOpenTab={onOpenTab} onOpenTab={onOpenTab}
@@ -75,6 +69,12 @@ export function HostsPanel({
query={hostSearch.trim().toLowerCase()} query={hostSearch.trim().toLowerCase()}
/> />
</div> </div>
<div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
>
<HostManager onEditingChange={handleEditingChange} />
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -282,7 +282,7 @@ export function HostItem({
e.stopPropagation(); e.stopPropagation();
onEditHost(); onEditHost();
}} }}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" className="flex items-center justify-center size-7 rounded text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors"
> >
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
</button> </button>