fix: Terminal display issue, migrate host editor to use combobox

This commit is contained in:
LukeGus
2025-11-15 20:57:57 -06:00
parent 4e6ddd0670
commit 11875c22a0
3 changed files with 205 additions and 197 deletions
@@ -16,6 +16,18 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import { import {
Search, Search,
Key, Key,
@@ -32,7 +44,9 @@ import {
Upload, Upload,
Server, Server,
User, User,
ChevronsUpDown,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils";
import { import {
getCredentials, getCredentials,
deleteCredential, deleteCredential,
@@ -82,9 +96,7 @@ export function CredentialsManager({
>([]); >([]);
const [selectedHostId, setSelectedHostId] = useState<string>(""); const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false); const [deployLoading, setDeployLoading] = useState(false);
const [hostSearchQuery, setHostSearchQuery] = useState(""); const [hostComboboxOpen, setHostComboboxOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const dragCounter = useRef(0); const dragCounter = useRef(0);
useEffect(() => { useEffect(() => {
@@ -94,41 +106,11 @@ export function CredentialsManager({
useEffect(() => { useEffect(() => {
if (showDeployDialog) { if (showDeployDialog) {
setDropdownOpen(false); setHostComboboxOpen(false);
setHostSearchQuery("");
setSelectedHostId(""); setSelectedHostId("");
setTimeout(() => {
if (
document.activeElement &&
(document.activeElement as HTMLElement).blur
) {
(document.activeElement as HTMLElement).blur();
}
}, 50);
} }
}, [showDeployDialog]); }, [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 () => { const fetchHosts = async () => {
try { try {
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
@@ -168,8 +150,7 @@ export function CredentialsManager({
} }
setDeployingCredential(credential); setDeployingCredential(credential);
setSelectedHostId(""); setSelectedHostId("");
setHostSearchQuery(""); setHostComboboxOpen(false);
setDropdownOpen(false);
setShowDeployDialog(true); setShowDeployDialog(true);
}; };
@@ -899,67 +880,62 @@ export function CredentialsManager({
<Server className="h-4 w-4 mr-2 text-muted-foreground" /> <Server className="h-4 w-4 mr-2 text-muted-foreground" />
{t("credentials.targetHost")} {t("credentials.targetHost")}
</label> </label>
<div className="relative" ref={dropdownRef}> <Popover
<Input open={hostComboboxOpen}
onOpenChange={setHostComboboxOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={hostComboboxOpen}
className="w-full justify-between"
>
{selectedHostId
? (() => {
const host = availableHosts.find(
(h) => h.id.toString() === selectedHostId,
);
return host
? `${host.name || host.ip}`
: t("credentials.chooseHostToDeploy");
})()
: t("credentials.chooseHostToDeploy")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput
placeholder={t("credentials.chooseHostToDeploy")} placeholder={t("credentials.chooseHostToDeploy")}
value={hostSearchQuery}
onChange={(e) => {
setHostSearchQuery(e.target.value);
}}
onClick={() => {
setDropdownOpen(true);
}}
className="w-full"
autoFocus={false}
tabIndex={0}
/> />
{dropdownOpen && ( <CommandEmpty>
<div className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto"> {availableHosts.length === 0
{availableHosts.length === 0 ? ( ? t("credentials.noHostsAvailable")
<div className="p-3 text-sm text-muted-foreground text-center"> : t("credentials.noHostsMatchSearch")}
{t("credentials.noHostsAvailable")} </CommandEmpty>
</div> <CommandGroup className="max-h-[300px] overflow-y-auto">
) : availableHosts.filter( {availableHosts.map((host) => (
(host) => <CommandItem
!hostSearchQuery ||
host.name
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.ip
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.username
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()),
).length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{t("credentials.noHostsMatchSearch")}
</div>
) : (
availableHosts
.filter(
(host) =>
!hostSearchQuery ||
host.name
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.ip
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.username
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()),
)
.map((host) => (
<div
key={host.id} key={host.id}
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer" value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
onClick={() => { onSelect={() => {
setSelectedHostId(host.id.toString()); setSelectedHostId(host.id.toString());
setHostSearchQuery(host.name || host.ip); setHostComboboxOpen(false);
setDropdownOpen(false);
}} }}
> >
<Check
className={cn(
"mr-2 h-4 w-4",
selectedHostId === host.id.toString()
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded bg-muted"> <div className="p-1.5 rounded bg-muted">
<Server className="h-3 w-3 text-muted-foreground" /> <Server className="h-3 w-3 text-muted-foreground" />
</div> </div>
@@ -972,11 +948,12 @@ export function CredentialsManager({
</div> </div>
</div> </div>
</div> </div>
)) </CommandItem>
)} ))}
</div> </CommandGroup>
)} </Command>
</div> </PopoverContent>
</Popover>
</div> </div>
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-3 bg-blue-50 dark:bg-blue-900/20"> <div className="border border-blue-200 dark:border-blue-800 rounded-lg p-3 bg-blue-50 dark:bg-blue-900/20">
@@ -336,7 +336,6 @@ export function HostManagerEditor({
const [snippets, setSnippets] = useState< const [snippets, setSnippets] = useState<
Array<{ id: number; name: string; content: string }> Array<{ id: number; name: string; content: string }>
>([]); >([]);
const [snippetSearch, setSnippetSearch] = useState("");
const [authTab, setAuthTab] = useState< const [authTab, setAuthTab] = useState<
"password" | "key" | "credential" | "none" "password" | "key" | "credential" | "none"
@@ -2304,76 +2303,102 @@ export function HostManagerEditor({
<FormField <FormField
control={form.control} control={form.control}
name="terminalConfig.startupSnippetId" name="terminalConfig.startupSnippetId"
render={({ field }) => ( render={({ field }) => {
<FormItem> const [open, setOpen] = React.useState(false);
<FormLabel>{t("hosts.startupSnippet")}</FormLabel> const selectedSnippet = snippets.find(
<Select (s) => s.id === field.value,
onValueChange={(value) => {
field.onChange(
value === "none" ? null : parseInt(value),
); );
setSnippetSearch("");
}} return (
value={field.value?.toString() || "none"} <FormItem>
> <FormLabel>
{t("hosts.startupSnippet")}
</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl> <FormControl>
<SelectTrigger> <Button
<SelectValue variant="outline"
placeholder={t("hosts.selectSnippet")} role="combobox"
/> aria-expanded={open}
</SelectTrigger> className="w-full justify-between"
</FormControl>
<SelectContent>
<div className="px-2 pb-2 sticky top-0 bg-popover z-10">
<Input
placeholder={t("hosts.searchSnippets")}
value={snippetSearch}
onChange={(e) =>
setSnippetSearch(e.target.value)
}
className="h-8"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
<SelectItem value="none">
{t("hosts.snippetNone")}
</SelectItem>
{snippets
.filter((snippet) =>
snippet.name
.toLowerCase()
.includes(
snippetSearch.toLowerCase(),
),
)
.map((snippet) => (
<SelectItem
key={snippet.id}
value={snippet.id.toString()}
> >
{snippet.name} {selectedSnippet
</SelectItem> ? selectedSnippet.name
))} : t("hosts.selectSnippet")}
{snippets.filter((snippet) => <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
snippet.name </Button>
.toLowerCase() </FormControl>
.includes(snippetSearch.toLowerCase()), </PopoverTrigger>
).length === 0 && <PopoverContent
snippetSearch && ( className="p-0"
<div className="px-2 py-6 text-center text-sm text-muted-foreground"> style={{
No snippets found width:
</div> "var(--radix-popover-trigger-width)",
}}
>
<Command>
<CommandInput
placeholder={t("hosts.searchSnippets")}
/>
<CommandEmpty>
{t("hosts.noSnippetFound")}
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
<CommandItem
value="none"
onSelect={() => {
field.onChange(null);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!field.value
? "opacity-100"
: "opacity-0",
)} )}
/>
{t("hosts.snippetNone")}
</CommandItem>
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
onSelect={() => {
field.onChange(snippet.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === snippet.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{snippet.name}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
{snippet.content}
</span>
</div> </div>
</SelectContent> </CommandItem>
</Select> ))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription> <FormDescription>
Execute a snippet when the terminal connects Execute a snippet when the terminal connects
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} );
}}
/> />
<FormField <FormField
+22 -16
View File
@@ -106,7 +106,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(false); const [isFitted, setIsFitted] = useState(true);
const [, setConnectionError] = useState<string | null>(null); const [, setConnectionError] = useState<string | null>(null);
const [, setIsAuthenticated] = useState(false); const [, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false); const [totpRequired, setTotpRequired] = useState(false);
@@ -246,6 +246,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null); const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>(
null,
);
const DEBOUNCE_MS = 140; const DEBOUNCE_MS = 140;
const logTerminalActivity = async () => { const logTerminalActivity = async () => {
@@ -323,20 +326,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return; return;
} }
const lastSize = lastFittedSizeRef.current;
if (
lastSize &&
lastSize.cols === terminal.cols &&
lastSize.rows === terminal.rows
) {
return;
}
isFittingRef.current = true; isFittingRef.current = true;
requestAnimationFrame(() => {
try { try {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
if (terminal && terminal.cols > 0 && terminal.rows > 0) { if (terminal && terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows); scheduleNotify(terminal.cols, terminal.rows);
lastFittedSizeRef.current = {
cols: terminal.cols,
rows: terminal.rows,
};
} }
hardRefresh();
setIsFitted(true); setIsFitted(true);
} finally { } finally {
isFittingRef.current = false; isFittingRef.current = false;
} }
});
} }
function handleTotpSubmit(code: string) { function handleTotpSubmit(code: string) {
@@ -1414,19 +1427,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
return; return;
} }
let rafId1: number; let rafId: number;
let rafId2: number;
rafId1 = requestAnimationFrame(() => { rafId = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
hardRefresh();
performFit(); performFit();
}); });
});
return () => { return () => {
if (rafId1) cancelAnimationFrame(rafId1); if (rafId) cancelAnimationFrame(rafId);
if (rafId2) cancelAnimationFrame(rafId2);
}; };
}, [isVisible, isReady, splitScreen, terminal]); }, [isVisible, isReady, splitScreen, terminal]);
@@ -1452,10 +1460,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
ref={xtermRef} ref={xtermRef}
className="h-full w-full" className="h-full w-full"
style={{ style={{
opacity: isReady && !isConnecting && isFitted ? 1 : 0, visibility: isReady ? "visible" : "hidden",
transition: "opacity 100ms ease-in-out", pointerEvents: isReady ? "auto" : "none",
pointerEvents:
isReady && !isConnecting && isFitted ? "auto" : "none",
}} }}
onClick={() => { onClick={() => {
if (terminal && !splitScreen) { if (terminal && !splitScreen) {