v1.9.0 #437
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user