v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
3 changed files with 205 additions and 197 deletions
Showing only changes of commit 11875c22a0 - Show all commits

View File

@@ -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}
placeholder={t("credentials.chooseHostToDeploy")} onOpenChange={setHostComboboxOpen}
value={hostSearchQuery} >
onChange={(e) => { <PopoverTrigger asChild>
setHostSearchQuery(e.target.value); <Button
}} variant="outline"
onClick={() => { role="combobox"
setDropdownOpen(true); aria-expanded={hostComboboxOpen}
}} className="w-full justify-between"
className="w-full" >
autoFocus={false} {selectedHostId
tabIndex={0} ? (() => {
/> const host = availableHosts.find(
{dropdownOpen && ( (h) => h.id.toString() === selectedHostId,
<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 ? ( return host
<div className="p-3 text-sm text-muted-foreground text-center"> ? `${host.name || host.ip}`
{t("credentials.noHostsAvailable")} : t("credentials.chooseHostToDeploy");
</div> })()
) : availableHosts.filter( : t("credentials.chooseHostToDeploy")}
(host) => <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
!hostSearchQuery || </Button>
host.name </PopoverTrigger>
?.toLowerCase() <PopoverContent
.includes(hostSearchQuery.toLowerCase()) || className="p-0"
host.ip style={{ width: "var(--radix-popover-trigger-width)" }}
?.toLowerCase() >
.includes(hostSearchQuery.toLowerCase()) || <Command>
host.username <CommandInput
?.toLowerCase() placeholder={t("credentials.chooseHostToDeploy")}
.includes(hostSearchQuery.toLowerCase()), />
).length === 0 ? ( <CommandEmpty>
<div className="p-3 text-sm text-muted-foreground text-center"> {availableHosts.length === 0
{t("credentials.noHostsMatchSearch")} ? t("credentials.noHostsAvailable")
</div> : t("credentials.noHostsMatchSearch")}
) : ( </CommandEmpty>
availableHosts <CommandGroup className="max-h-[300px] overflow-y-auto">
.filter( {availableHosts.map((host) => (
(host) => <CommandItem
!hostSearchQuery || key={host.id}
host.name value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
?.toLowerCase() onSelect={() => {
.includes(hostSearchQuery.toLowerCase()) || setSelectedHostId(host.id.toString());
host.ip setHostComboboxOpen(false);
?.toLowerCase() }}
.includes(hostSearchQuery.toLowerCase()) || >
host.username <Check
?.toLowerCase() className={cn(
.includes(hostSearchQuery.toLowerCase()), "mr-2 h-4 w-4",
) selectedHostId === host.id.toString()
.map((host) => ( ? "opacity-100"
<div : "opacity-0",
key={host.id} )}
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer" />
onClick={() => { <div className="flex items-center gap-3">
setSelectedHostId(host.id.toString());
setHostSearchQuery(host.name || host.ip);
setDropdownOpen(false);
}}
>
<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">

View File

@@ -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), return (
); <FormItem>
setSnippetSearch(""); <FormLabel>
}} {t("hosts.startupSnippet")}
value={field.value?.toString() || "none"} </FormLabel>
> <Popover open={open} onOpenChange={setOpen}>
<FormControl> <PopoverTrigger asChild>
<SelectTrigger> <FormControl>
<SelectValue <Button
placeholder={t("hosts.selectSnippet")} variant="outline"
/> role="combobox"
</SelectTrigger> aria-expanded={open}
</FormControl> className="w-full justify-between"
<SelectContent> >
<div className="px-2 pb-2 sticky top-0 bg-popover z-10"> {selectedSnippet
<Input ? selectedSnippet.name
placeholder={t("hosts.searchSnippets")} : t("hosts.selectSnippet")}
value={snippetSearch} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
onChange={(e) => </Button>
setSnippetSearch(e.target.value) </FormControl>
} </PopoverTrigger>
className="h-8" <PopoverContent
onClick={(e) => e.stopPropagation()} className="p-0"
onKeyDown={(e) => e.stopPropagation()} style={{
/> width:
</div> "var(--radix-popover-trigger-width)",
<div className="max-h-[200px] overflow-y-auto"> }}
<SelectItem value="none"> >
{t("hosts.snippetNone")} <Command>
</SelectItem> <CommandInput
{snippets placeholder={t("hosts.searchSnippets")}
.filter((snippet) => />
snippet.name <CommandEmpty>
.toLowerCase() {t("hosts.noSnippetFound")}
.includes( </CommandEmpty>
snippetSearch.toLowerCase(), <CommandGroup className="max-h-[300px] overflow-y-auto">
), <CommandItem
) value="none"
.map((snippet) => ( onSelect={() => {
<SelectItem field.onChange(null);
key={snippet.id} setOpen(false);
value={snippet.id.toString()} }}
> >
{snippet.name} <Check
</SelectItem> className={cn(
))} "mr-2 h-4 w-4",
{snippets.filter((snippet) => !field.value
snippet.name ? "opacity-100"
.toLowerCase() : "opacity-0",
.includes(snippetSearch.toLowerCase()), )}
).length === 0 && />
snippetSearch && ( {t("hosts.snippetNone")}
<div className="px-2 py-6 text-center text-sm text-muted-foreground"> </CommandItem>
No snippets found {snippets.map((snippet) => (
</div> <CommandItem
)} key={snippet.id}
</div> value={`${snippet.name} ${snippet.content} ${snippet.id}`}
</SelectContent> onSelect={() => {
</Select> field.onChange(snippet.id);
<FormDescription> setOpen(false);
Execute a snippet when the terminal connects }}
</FormDescription> >
</FormItem> <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>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Execute a snippet when the terminal connects
</FormDescription>
</FormItem>
);
}}
/> />
<FormField <FormField

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,
hardRefresh(); rows: terminal.rows,
setIsFitted(true); };
} finally {
isFittingRef.current = false;
} }
}); setIsFitted(true);
} finally {
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(() => { performFit();
hardRefresh();
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) {