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,
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<string>("");
const [deployLoading, setDeployLoading] = useState(false);
const [hostSearchQuery, setHostSearchQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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({
<Server className="h-4 w-4 mr-2 text-muted-foreground" />
{t("credentials.targetHost")}
</label>
<div className="relative" ref={dropdownRef}>
<Input
placeholder={t("credentials.chooseHostToDeploy")}
value={hostSearchQuery}
onChange={(e) => {
setHostSearchQuery(e.target.value);
}}
onClick={() => {
setDropdownOpen(true);
}}
className="w-full"
autoFocus={false}
tabIndex={0}
/>
{dropdownOpen && (
<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 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{t("credentials.noHostsAvailable")}
</div>
) : availableHosts.filter(
(host) =>
!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}
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer"
onClick={() => {
setSelectedHostId(host.id.toString());
setHostSearchQuery(host.name || host.ip);
setDropdownOpen(false);
}}
>
<Popover
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")}
/>
<CommandEmpty>
{availableHosts.length === 0
? t("credentials.noHostsAvailable")
: t("credentials.noHostsMatchSearch")}
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{availableHosts.map((host) => (
<CommandItem
key={host.id}
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
onSelect={() => {
setSelectedHostId(host.id.toString());
setHostComboboxOpen(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">
<Server className="h-3 w-3 text-muted-foreground" />
</div>
@@ -972,11 +948,12 @@ export function CredentialsManager({
</div>
</div>
</div>
))
)}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<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<
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({
<FormField
control={form.control}
name="terminalConfig.startupSnippetId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.startupSnippet")}</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(
value === "none" ? null : parseInt(value),
);
setSnippetSearch("");
}}
value={field.value?.toString() || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectSnippet")}
/>
</SelectTrigger>
</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()}
render={({ field }) => {
const [open, setOpen] = React.useState(false);
const selectedSnippet = snippets.find(
(s) => s.id === field.value,
);
return (
<FormItem>
<FormLabel>
{t("hosts.startupSnippet")}
</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedSnippet
? selectedSnippet.name
: t("hosts.selectSnippet")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width:
"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);
}}
>
{snippet.name}
</SelectItem>
))}
{snippets.filter((snippet) =>
snippet.name
.toLowerCase()
.includes(snippetSearch.toLowerCase()),
).length === 0 &&
snippetSearch && (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No snippets found
</div>
)}
</div>
</SelectContent>
</Select>
<FormDescription>
Execute a snippet when the terminal connects
</FormDescription>
</FormItem>
)}
<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>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Execute a snippet when the terminal connects
</FormDescription>
</FormItem>
);
}}
/>
<FormField

View File

@@ -106,7 +106,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
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<string | null>(null);
const [, setIsAuthenticated] = 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 pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(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<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
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) {