import { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/button"; import { Separator } from "@/components/separator"; import { KeyRound, Terminal } from "lucide-react"; import type { Tab } from "@/types/ui-types"; import { getCookie, getHostPassword, setCookie } from "@/main-axios"; import { toast } from "sonner"; export function SshToolsPanel({ terminalTabs, activeTabId, }: { terminalTabs: Tab[]; activeTabId: string; }) { const { t } = useTranslation(); const [keyRecording, setKeyRecording] = useState(false); const [rightClickPaste, setRightClickPaste] = useState( () => getCookie("rightClickCopyPaste") !== "false", ); const [copyOnSelect, setCopyOnSelect] = useState( () => getCookie("copyOnSelect") === "true", ); const [selectedTabIds, setSelectedTabIds] = useState>( () => new Set( activeTabId && terminalTabs.some((t) => t.id === activeTabId) ? [activeTabId] : [], ), ); const inputRef = useRef(null); useEffect(() => { if (keyRecording) inputRef.current?.focus(); }, [keyRecording]); function toggleTab(id: string) { setSelectedTabIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); } function selectAll() { setSelectedTabIds(new Set(terminalTabs.map((t) => t.id))); } function deselectAll() { setSelectedTabIds(new Set()); } function broadcast(data: string) { for (const tabId of selectedTabIds) { const tab = terminalTabs.find((t) => t.id === tabId); tab?.terminalRef?.current?.sendInput?.(data); } } async function fillPassword() { let filled = 0; let missing = 0; for (const tabId of selectedTabIds) { const tab = terminalTabs.find((t) => t.id === tabId); const hostId = tab?.host?.id ? Number(tab.host.id) : null; const ref = tab?.terminalRef?.current; if (!hostId || !ref?.sendInput) { missing++; continue; } const password = await getHostPassword(hostId, "password"); if (!password) { missing++; continue; } ref.sendInput(password + "\r"); filled++; } if (filled > 0) { toast.success( t("newUi.sidebar.sshTools.fillPasswordSuccess", { count: filled }), ); } if (missing > 0) { toast.error( t("newUi.sidebar.sshTools.fillPasswordMissing", { count: missing }), ); } } function broadcastArrow(normalSeq: string, appSeq: string) { for (const tabId of selectedTabIds) { const tab = terminalTabs.find((t) => t.id === tabId); const ref = tab?.terminalRef?.current; if (!ref) continue; const appMode = ref.getApplicationCursorKeysMode?.() ?? false; ref.sendInput?.(appMode ? appSeq : normalSeq); } } function handlePaste(e: React.ClipboardEvent) { e.preventDefault(); e.stopPropagation(); const text = e.clipboardData.getData("text"); if (text) broadcast(text); } function handleKeyDown(e: React.KeyboardEvent) { const ctrl = e.ctrlKey; const { key } = e; // Let Ctrl/Cmd+V fall through so the browser fires a paste event // instead of being swallowed by the preventDefault() below. if ((ctrl || e.metaKey) && key.toLowerCase() === "v") { return; } e.preventDefault(); e.stopPropagation(); if (ctrl) { const ctrlMap: Record = { c: "\x03", d: "\x04", l: "\x0C", u: "\x15", k: "\x0B", a: "\x01", e: "\x05", w: "\x17", z: "\x1A", r: "\x12", }; const seq = ctrlMap[key.toLowerCase()]; if (seq) { broadcast(seq); return; } } if (key === "ArrowUp") { broadcastArrow("\x1B[A", "\x1BOA"); return; } if (key === "ArrowDown") { broadcastArrow("\x1B[B", "\x1BOB"); return; } if (key === "ArrowRight") { broadcastArrow("\x1B[C", "\x1BOC"); return; } if (key === "ArrowLeft") { broadcastArrow("\x1B[D", "\x1BOD"); return; } const specialMap: Record = { Enter: "\r", Backspace: "\x7F", Delete: "\x1B[3~", Tab: "\t", Escape: "\x1B", Home: "\x1B[H", End: "\x1B[F", PageUp: "\x1B[5~", PageDown: "\x1B[6~", Insert: "\x1B[2~", F1: "\x1BOP", F2: "\x1BOQ", F3: "\x1BOR", F4: "\x1BOS", F5: "\x1B[15~", F6: "\x1B[17~", F7: "\x1B[18~", F8: "\x1B[19~", F9: "\x1B[20~", F10: "\x1B[21~", F11: "\x1B[23~", F12: "\x1B[24~", }; const seq = specialMap[key]; if (seq) { broadcast(seq); return; } if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) { broadcast(key); } } function toggleRecording() { const next = !keyRecording; if (!next) { // clear the phantom text when stopping if (inputRef.current) inputRef.current.value = ""; } setKeyRecording(next); } return (
{t("newUi.sidebar.sshTools.keyRecordingTitle")} {/* Terminal selector */}
{t("newUi.sidebar.sshTools.recordToTerminals")}
{terminalTabs.length === 0 ? (
{t("newUi.sidebar.sshTools.noTerminalTabsOpen")}
) : (
{terminalTabs.map((tab) => { const selected = selectedTabIds.has(tab.id); return ( ); })}
)}
{keyRecording && ( { // Keystrokes are broadcast directly via handleKeyDown; the // field itself must stay empty. This also catches paste // insertion on browsers that fire "input" before we can // intercept it in onPaste. e.target.value = ""; }} placeholder={t("newUi.sidebar.sshTools.broadcastInputPlaceholder")} className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent" /> )}
{t("newUi.sidebar.sshTools.settingsTitle")}
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
{t("newUi.sidebar.sshTools.copyOnSelect")}
); }