This commit is contained in:
LukeGus
2026-05-28 22:29:20 -05:00
parent 33dcde0827
commit 5777351145
238 changed files with 62303 additions and 98955 deletions
+170 -27
View File
@@ -1,16 +1,28 @@
import React, { useState, useEffect } from "react";
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost } from "@/main-axios.ts";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { Button } from "@/components/button.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import type { SSHHost } from "@/types";
interface GuacamoleAppProps {
hostId?: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
}
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
hostId,
tabId,
protocol,
}) => {
const { t } = useTranslation();
return (
@@ -18,20 +30,46 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<RefreshCw className="size-8 animate-spin" />
<span className="text-sm font-semibold uppercase tracking-widest">
{t("common.loading")}
</span>
<div className="relative w-full h-full">
<SimpleLoader visible={true} message={t("common.loading")} />
</div>
);
}
if (!hostConfig) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<AlertCircle className="size-8 text-destructive" />
<span className="text-sm font-semibold text-destructive">
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
if (!hostId) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
@@ -40,8 +78,10 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
return (
<GuacamoleAppInner
hostId={parseInt(hostId!, 10)}
hostId={parseInt(hostId, 10)}
hostConfig={hostConfig}
tabId={tabId}
protocol={protocol}
/>
);
}}
@@ -52,51 +92,154 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<SSHHost, "connectionType">;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
}
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
hostId,
hostConfig,
tabId,
protocol,
}) => {
const { t } = useTranslation();
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const displayRef = useRef<GuacamoleDisplayHandle>(null);
useEffect(() => {
getGuacamoleTokenFromHost(hostId)
.then((result) => setToken(result.token))
setToken(null);
setError(null);
getGuacdStatus()
.then((status) => {
if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable"));
return;
}
return getGuacamoleTokenFromHost(hostId, protocol);
})
.then((result) => {
if (result) setToken(result.token);
})
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
}, [hostId]);
}, [hostId, retryCount]);
const handleReconnect = useCallback(() => {
setConnectionError(null);
setError(null);
setToken(null);
setRetryCount((c) => c + 1);
}, []);
useEffect(() => {
if (!tabId) return;
const handler = (e: Event) => {
const { tabId: eventTabId } = (e as CustomEvent).detail;
if (eventTabId === tabId) handleReconnect();
};
window.addEventListener("termix:refresh-guacamole", handler);
return () =>
window.removeEventListener("termix:refresh-guacamole", handler);
}, [tabId, handleReconnect]);
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<AlertCircle className="size-8 text-destructive" />
<span className="text-sm font-semibold text-destructive">{error}</span>
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<p
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.connectionFailed")}
</p>
<p
className="text-xs max-w-xs text-center"
style={{ color: "var(--foreground-secondary)" }}
>
{error}
</p>
<Button variant="outline" size="sm" onClick={handleReconnect}>
<RefreshCw className="size-4 mr-2" />
{t("guacamole.retry")}
</Button>
</div>
);
}
if (!token) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<RefreshCw className="size-8 animate-spin" />
<span className="text-sm font-semibold uppercase tracking-widest">
{t("guacamole.connecting", {
type: (hostConfig.connectionType || "remote").toUpperCase(),
<div className="relative w-full h-full">
<SimpleLoader
visible={true}
message={t("guacamole.connecting", {
type: (
protocol ||
hostConfig.connectionType ||
"remote"
).toUpperCase(),
})}
</span>
/>
</div>
);
}
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
| "rdp"
| "vnc"
| "telnet";
return (
<div className="relative w-full h-full">
{connectionError && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<p
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.connectionFailed")}
</p>
<p
className="text-xs max-w-xs text-center"
style={{ color: "var(--foreground-secondary)" }}
>
{connectionError}
</p>
<Button variant="outline" size="sm" onClick={handleReconnect}>
<RefreshCw className="size-4 mr-2" />
{t("guacamole.reconnect")}
</Button>
</div>
)}
<GuacamoleDisplay
connectionConfig={{ token, protocol, type: protocol }}
key={token}
ref={displayRef}
connectionConfig={{
token,
protocol: resolvedProtocol,
type: resolvedProtocol,
}}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
onReconnect={handleReconnect}
/>
</div>
);
+55 -19
View File
@@ -65,6 +65,7 @@ export const GuacamoleDisplay = forwardRef<
typeof document === "undefined" ? true : document.hasFocus(),
);
const [isReady, setIsReady] = useState(false);
const [hasError, setHasError] = useState(false);
useImperativeHandle(ref, () => ({
disconnect: () => {
@@ -267,13 +268,20 @@ export const GuacamoleDisplay = forwardRef<
if (isConnectingRef.current) return;
isConnectingRef.current = true;
setIsReady(false);
setHasError(false);
let containerWidth = containerRef.current?.clientWidth || 0;
let containerHeight = containerRef.current?.clientHeight || 0;
// Wait two frames so the container is fully laid out before measuring.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const rect = containerRef.current?.getBoundingClientRect();
let containerWidth = rect?.width || 0;
let containerHeight = rect?.height || 0;
if (containerWidth < 100 || containerHeight < 100) {
containerWidth = 1280;
containerHeight = 720;
containerWidth = window.innerWidth || 1280;
containerHeight = window.innerHeight || 720;
}
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
@@ -303,6 +311,11 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(true);
};
const protocol = connectionConfig.protocol ?? connectionConfig.type;
if (protocol === "telnet") {
setIsReady(true);
}
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
@@ -351,8 +364,15 @@ export const GuacamoleDisplay = forwardRef<
case 2:
break;
case 3:
isConnectingRef.current = false;
setIsReady(true);
onConnect?.();
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (w > 0 && h > 0) client.sendSize(w, h);
}
break;
case 4:
break;
@@ -366,8 +386,10 @@ export const GuacamoleDisplay = forwardRef<
};
client.onerror = (error: Guacamole.Status) => {
const errorMessage = error.message || "Connection error";
const errorMessage = error.message || t("guacamole.connectionError");
setIsReady(false);
setHasError(true);
isConnectingRef.current = false;
onError?.(errorMessage);
};
@@ -388,6 +410,24 @@ export const GuacamoleDisplay = forwardRef<
Guacamole.AudioPlayer.getInstance(stream, mimetype);
};
client.onfile = (
stream: Guacamole.InputStream,
mimetype: string,
filename: string,
) => {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
};
client.connect();
}, [
getWebSocketUrl,
@@ -407,11 +447,7 @@ export const GuacamoleDisplay = forwardRef<
if (isVisible && !hasInitiatedRef.current) {
hasInitiatedRef.current = true;
requestAnimationFrame(() => {
if (isMountedRef.current) {
connect();
}
});
connect();
}
}, [isVisible, connect]);
@@ -475,26 +511,23 @@ export const GuacamoleDisplay = forwardRef<
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(() => {
rescaleDisplay(false);
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
resizeTimeoutRef.current = setTimeout(() => {
if (clientRef.current && containerRef.current) {
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
const rect = containerRef.current.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
}
}, 200);
}, 150);
});
resizeObserver.observe(containerRef.current);
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
return () => {
resizeObserver.disconnect();
clearTimeout(initialTimeout);
};
}, [rescaleDisplay]);
}, []);
const syncClipboard = useCallback(() => {
const client = clientRef.current;
@@ -553,7 +586,10 @@ export const GuacamoleDisplay = forwardRef<
}}
/>
<SimpleLoader visible={!isReady} message={connectingMessage} />
<SimpleLoader
visible={!isReady && !hasError}
message={connectingMessage}
/>
</div>
);
});
@@ -0,0 +1,478 @@
import React, {
useState,
useRef,
useCallback,
useLayoutEffect,
useEffect,
} from "react";
import {
GripVertical,
Monitor,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
ChevronsLeftRight,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet";
onReconnect: () => void;
}
const MODIFIER_KEYSYMS = {
ctrl: 0xffe3,
alt: 0xffe9,
shift: 0xffe1,
win: 0xff67,
} as const;
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
const BTN_BASE =
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
const BTN_ICON =
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
function TipBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(BTN_BASE, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
function TipIconBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(BTN_ICON, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef,
protocol,
onReconnect,
}) => {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: 0, y: 12 });
const [collapsed, setCollapsed] = useState(false);
const [showFKeys, setShowFKeys] = useState(false);
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
[MODIFIER_KEYSYMS.ctrl]: false,
[MODIFIER_KEYSYMS.alt]: false,
[MODIFIER_KEYSYMS.shift]: false,
[MODIFIER_KEYSYMS.win]: false,
});
const toolbarRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
const [isDragging, setIsDragging] = useState(false);
useLayoutEffect(() => {
const el = toolbarRef.current;
if (!el) return;
const parent = el.offsetParent as HTMLElement | null;
if (!parent) return;
const parentW = parent.clientWidth;
const toolbarW = el.offsetWidth;
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
}, [collapsed]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
const parentW = parent?.clientWidth ?? Infinity;
const parentH = parent?.clientHeight ?? Infinity;
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
const dx = e.clientX - dragOriginRef.current.mouseX;
const dy = e.clientY - dragOriginRef.current.mouseY;
setPosition({
x: Math.max(
0,
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
),
y: Math.max(
0,
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
),
});
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [isDragging]);
const startDrag = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
dragOriginRef.current = {
mouseX: e.clientX,
mouseY: e.clientY,
posX: position.x,
posY: position.y,
};
document.body.style.userSelect = "none";
setIsDragging(true);
},
[position],
);
const releaseStickyKeys = useCallback(() => {
const display = displayRef.current;
if (!display) return;
setStickyKeys((prev) => {
const next = { ...prev };
for (const [ksStr, active] of Object.entries(prev)) {
if (active) {
display.sendKey(Number(ksStr), false);
next[Number(ksStr)] = false;
}
}
return next;
});
}, [displayRef]);
const sendCombo = useCallback(
(...keysyms: number[]) => {
const display = displayRef.current;
if (!display) return;
for (const k of keysyms) display.sendKey(k, true);
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
releaseStickyKeys();
},
[displayRef, releaseStickyKeys],
);
const toggleStickyKey = useCallback(
(keysym: number) => {
const display = displayRef.current;
if (!display) return;
setStickyKeys((prev) => {
const isActive = prev[keysym];
display.sendKey(keysym, !isActive);
return { ...prev, [keysym]: !isActive };
});
},
[displayRef],
);
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
const containerStyle: React.CSSProperties = {
position: "absolute",
left: position.x,
top: position.y,
zIndex: 20,
};
return (
<TooltipProvider delayDuration={500}>
<div
ref={toolbarRef}
style={containerStyle}
onMouseDown={(e) => e.preventDefault()}
>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3" />
</button>
<div className="w-px h-4 bg-border" />
<button
type="button"
onClick={() => setCollapsed(false)}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Monitor className="size-3.5" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.expand")}
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
{/* Drag handle */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.dragHandle")}
</TooltipContent>
</Tooltip>
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.ctrlAltDel")}
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
>
CAD
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winL")}
onClick={() => sendCombo(0xff67, 0x006c)}
>
Win+L
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winKey")}
onClick={() => sendCombo(0xff67)}
>
Win
</TipBtn>
</>
)}
{/* Sticky modifiers — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
{(
[
[
"ctrl",
MODIFIER_KEYSYMS.ctrl,
t("guacamole.toolbar.ctrl"),
],
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
[
"shift",
MODIFIER_KEYSYMS.shift,
t("guacamole.toolbar.shift"),
],
["win", MODIFIER_KEYSYMS.win, t("guacamole.toolbar.win")],
] as [string, number, string][]
).map(([key, ks, label]) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => toggleStickyKey(ks)}
className={cn(
BTN_BASE,
stickyKeys[ks] &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{stickyKeys[ks]
? t("guacamole.toolbar.stickyActive", { key: label })
: t("guacamole.toolbar.stickyInactive", { key: label })}
</TooltipContent>
</Tooltip>
))}
</>
)}
{/* Function key toggle */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.fnToggle")}
onClick={() => setShowFKeys((v) => !v)}
className={cn(
showFKeys &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
Fn
</TipBtn>
{/* F1-F12 row */}
{showFKeys &&
FKEY_KEYSYMS.map((ks, i) => (
<TipBtn
key={ks}
tooltip={`F${i + 1}`}
onClick={() => sendCombo(ks)}
>
F{i + 1}
</TipBtn>
))}
{/* Navigation */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.esc")}
onClick={() => sendCombo(0xff1b)}
>
Esc
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.tab")}
onClick={() => sendCombo(0xff09)}
>
Tab
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.home")}
onClick={() => sendCombo(0xff50)}
>
Home
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.end")}
onClick={() => sendCombo(0xff57)}
>
End
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageUp")}
onClick={() => sendCombo(0xff55)}
>
PgUp
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageDown")}
onClick={() => sendCombo(0xff56)}
>
PgDn
</TipBtn>
{/* Arrow cluster */}
<div className="flex flex-col ml-0.5">
<div className="flex justify-center">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowUp")}
onClick={() => sendCombo(0xff52)}
>
<ChevronUp className="size-3" />
</TipIconBtn>
</div>
<div className="flex">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowLeft")}
onClick={() => sendCombo(0xff51)}
>
<ChevronLeft className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowDown")}
onClick={() => sendCombo(0xff54)}
>
<ChevronDown className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowRight")}
onClick={() => sendCombo(0xff53)}
>
<ChevronRight className="size-3" />
</TipIconBtn>
</div>
</div>
{/* Collapse */}
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCollapsed(true)}
className={cn(BTN_ICON)}
>
<ChevronsLeftRight className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.collapse")}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
);
};