diff --git a/src/ui/desktop/apps/credentials/CredentialsManager.tsx b/src/ui/desktop/apps/credentials/CredentialsManager.tsx index 0bd1efc0..21305a21 100644 --- a/src/ui/desktop/apps/credentials/CredentialsManager.tsx +++ b/src/ui/desktop/apps/credentials/CredentialsManager.tsx @@ -16,6 +16,18 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; import { Search, Key, @@ -32,7 +44,9 @@ import { Upload, Server, User, + ChevronsUpDown, } from "lucide-react"; +import { cn } from "@/lib/utils"; import { getCredentials, deleteCredential, @@ -82,9 +96,7 @@ export function CredentialsManager({ >([]); const [selectedHostId, setSelectedHostId] = useState(""); const [deployLoading, setDeployLoading] = useState(false); - const [hostSearchQuery, setHostSearchQuery] = useState(""); - const [dropdownOpen, setDropdownOpen] = useState(false); - const dropdownRef = useRef(null); + const [hostComboboxOpen, setHostComboboxOpen] = useState(false); const dragCounter = useRef(0); useEffect(() => { @@ -94,41 +106,11 @@ export function CredentialsManager({ useEffect(() => { if (showDeployDialog) { - setDropdownOpen(false); - setHostSearchQuery(""); + setHostComboboxOpen(false); setSelectedHostId(""); - setTimeout(() => { - if ( - document.activeElement && - (document.activeElement as HTMLElement).blur - ) { - (document.activeElement as HTMLElement).blur(); - } - }, 50); } }, [showDeployDialog]); - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setDropdownOpen(false); - } - } - - if (dropdownOpen) { - document.addEventListener("mousedown", handleClickOutside); - } else { - document.removeEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [dropdownOpen]); - const fetchHosts = async () => { try { const hosts = await getSSHHosts(); @@ -168,8 +150,7 @@ export function CredentialsManager({ } setDeployingCredential(credential); setSelectedHostId(""); - setHostSearchQuery(""); - setDropdownOpen(false); + setHostComboboxOpen(false); setShowDeployDialog(true); }; @@ -899,67 +880,62 @@ export function CredentialsManager({ {t("credentials.targetHost")} -
- { - setHostSearchQuery(e.target.value); - }} - onClick={() => { - setDropdownOpen(true); - }} - className="w-full" - autoFocus={false} - tabIndex={0} - /> - {dropdownOpen && ( -
- {availableHosts.length === 0 ? ( -
- {t("credentials.noHostsAvailable")} -
- ) : availableHosts.filter( - (host) => - !hostSearchQuery || - host.name - ?.toLowerCase() - .includes(hostSearchQuery.toLowerCase()) || - host.ip - ?.toLowerCase() - .includes(hostSearchQuery.toLowerCase()) || - host.username - ?.toLowerCase() - .includes(hostSearchQuery.toLowerCase()), - ).length === 0 ? ( -
- {t("credentials.noHostsMatchSearch")} -
- ) : ( - availableHosts - .filter( - (host) => - !hostSearchQuery || - host.name - ?.toLowerCase() - .includes(hostSearchQuery.toLowerCase()) || - host.ip - ?.toLowerCase() - .includes(hostSearchQuery.toLowerCase()) || - host.username - ?.toLowerCase() - .includes(hostSearchQuery.toLowerCase()), - ) - .map((host) => ( -
{ - setSelectedHostId(host.id.toString()); - setHostSearchQuery(host.name || host.ip); - setDropdownOpen(false); - }} - > + + + + + + + + + {availableHosts.length === 0 + ? t("credentials.noHostsAvailable") + : t("credentials.noHostsMatchSearch")} + + + {availableHosts.map((host) => ( + { + setSelectedHostId(host.id.toString()); + setHostComboboxOpen(false); + }} + > + +
@@ -972,11 +948,12 @@ export function CredentialsManager({
- )) - )} -
- )} - + + ))} + + + +
diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index 8f2e707a..f0516e78 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -336,7 +336,6 @@ export function HostManagerEditor({ const [snippets, setSnippets] = useState< Array<{ id: number; name: string; content: string }> >([]); - const [snippetSearch, setSnippetSearch] = useState(""); const [authTab, setAuthTab] = useState< "password" | "key" | "credential" | "none" @@ -2304,76 +2303,102 @@ export function HostManagerEditor({ ( - - {t("hosts.startupSnippet")} - - setSnippetSearch(e.target.value) - } - className="h-8" - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - /> -
-
- - {t("hosts.snippetNone")} - - {snippets - .filter((snippet) => - snippet.name - .toLowerCase() - .includes( - snippetSearch.toLowerCase(), - ), - ) - .map((snippet) => ( - { + const [open, setOpen] = React.useState(false); + const selectedSnippet = snippets.find( + (s) => s.id === field.value, + ); + + return ( + + + {t("hosts.startupSnippet")} + + + + + + + + + + + + {t("hosts.noSnippetFound")} + + + { + field.onChange(null); + setOpen(false); + }} > - {snippet.name} - - ))} - {snippets.filter((snippet) => - snippet.name - .toLowerCase() - .includes(snippetSearch.toLowerCase()), - ).length === 0 && - snippetSearch && ( -
- No snippets found -
- )} -
- - - - Execute a snippet when the terminal connects - - - )} + + {t("hosts.snippetNone")} + + {snippets.map((snippet) => ( + { + field.onChange(snippet.id); + setOpen(false); + }} + > + +
+ + {snippet.name} + + + {snippet.content} + +
+
+ ))} + + + + + + Execute a snippet when the terminal connects + + + ); + }} /> ( const [isReady, setIsReady] = useState(false); const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); - const [isFitted, setIsFitted] = useState(false); + const [isFitted, setIsFitted] = useState(true); const [, setConnectionError] = useState(null); const [, setIsAuthenticated] = useState(false); const [totpRequired, setTotpRequired] = useState(false); @@ -246,6 +246,9 @@ export const Terminal = forwardRef( const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); const notifyTimerRef = useRef(null); + const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>( + null, + ); const DEBOUNCE_MS = 140; const logTerminalActivity = async () => { @@ -323,20 +326,30 @@ export const Terminal = forwardRef( return; } + const lastSize = lastFittedSizeRef.current; + if ( + lastSize && + lastSize.cols === terminal.cols && + lastSize.rows === terminal.rows + ) { + return; + } + isFittingRef.current = true; - requestAnimationFrame(() => { - try { - fitAddonRef.current?.fit(); - if (terminal && terminal.cols > 0 && terminal.rows > 0) { - scheduleNotify(terminal.cols, terminal.rows); - } - hardRefresh(); - setIsFitted(true); - } finally { - isFittingRef.current = false; + try { + fitAddonRef.current?.fit(); + if (terminal && terminal.cols > 0 && terminal.rows > 0) { + scheduleNotify(terminal.cols, terminal.rows); + lastFittedSizeRef.current = { + cols: terminal.cols, + rows: terminal.rows, + }; } - }); + setIsFitted(true); + } finally { + isFittingRef.current = false; + } } function handleTotpSubmit(code: string) { @@ -1414,19 +1427,14 @@ export const Terminal = forwardRef( return; } - let rafId1: number; - let rafId2: number; + let rafId: number; - rafId1 = requestAnimationFrame(() => { - rafId2 = requestAnimationFrame(() => { - hardRefresh(); - performFit(); - }); + rafId = requestAnimationFrame(() => { + performFit(); }); return () => { - if (rafId1) cancelAnimationFrame(rafId1); - if (rafId2) cancelAnimationFrame(rafId2); + if (rafId) cancelAnimationFrame(rafId); }; }, [isVisible, isReady, splitScreen, terminal]); @@ -1452,10 +1460,8 @@ export const Terminal = forwardRef( ref={xtermRef} className="h-full w-full" style={{ - opacity: isReady && !isConnecting && isFitted ? 1 : 0, - transition: "opacity 100ms ease-in-out", - pointerEvents: - isReady && !isConnecting && isFitted ? "auto" : "none", + visibility: isReady ? "visible" : "hidden", + pointerEvents: isReady ? "auto" : "none", }} onClick={() => { if (terminal && !splitScreen) {