Files
ProxMenux/AppImage/components/terminal-panel.tsx
2025-12-06 20:27:00 +01:00

1058 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { API_PORT } from "../lib/api-config"
import { fetchApi } from "@/lib/api-config" // Cambiando import para usar fetchApi directamente
import { X, Search, Send, Lightbulb, Terminal, Plus, GripHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
type TerminalPanelProps = {
websocketUrl?: string
onClose?: () => void
initMessage?: Record<string, any>
onWebInteraction?: (interaction: any) => void
onWebSocketCreated?: (ws: WebSocket) => void
onTerminalOutput?: () => void
isScriptModal?: boolean
}
interface TerminalInstance {
id: string
title: string
term: any
ws: WebSocket | null
isConnected: boolean
fitAddon: any // Added fitAddon to TerminalInstance
}
function getWebSocketUrl(): string {
if (typeof window === "undefined") {
return "ws://localhost:8008/ws/terminal"
}
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
if (isStandardPort) {
return `${wsProtocol}//${hostname}/ws/terminal`
} else {
return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal`
}
}
function getApiUrl(endpoint?: string): string {
if (typeof window === "undefined") {
return "http://localhost:8008"
}
const { protocol, hostname } = window.location
const apiProtocol = protocol === "https:" ? "https:" : "http:"
return `${apiProtocol}//${hostname}:${API_PORT}${endpoint || ""}`
}
const proxmoxCommands = [
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
{ cmd: "qm status <vmid>", desc: "Show VM status" },
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
{ cmd: "pct list", desc: "List all LXC containers" },
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
{ cmd: "pvesm status", desc: "Show storage status" },
{ cmd: "pvesm list <storage>", desc: "List storage content" },
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
{ cmd: "pvecm status", desc: "Show cluster status" },
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
{ cmd: "zpool status", desc: "Show ZFS pool status" },
{ cmd: "zpool list", desc: "List all ZFS pools" },
{ cmd: "zfs list", desc: "List all ZFS datasets" },
{ cmd: "ls -la", desc: "List all files with details" },
{ cmd: "cd /path/to/dir", desc: "Change directory" },
{ cmd: "mkdir dirname", desc: "Create new directory" },
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
{ cmd: "cp source dest", desc: "Copy files or directories" },
{ cmd: "mv source dest", desc: "Move or rename files" },
{ cmd: "cat filename", desc: "Display file contents" },
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
{ cmd: "find . -name 'file'", desc: "Find files by name" },
{ cmd: "chmod 755 file", desc: "Change file permissions" },
{ cmd: "chown user:group file", desc: "Change file owner" },
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
{ cmd: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
{ cmd: "df -h", desc: "Show disk usage" },
{ cmd: "du -sh *", desc: "Show directory sizes" },
{ cmd: "free -h", desc: "Show memory usage" },
{ cmd: "top", desc: "Show running processes" },
{ cmd: "ps aux | grep process", desc: "Find running process" },
{ cmd: "kill -9 PID", desc: "Force kill process" },
{ cmd: "systemctl status service", desc: "Check service status" },
{ cmd: "systemctl start service", desc: "Start a service" },
{ cmd: "systemctl stop service", desc: "Stop a service" },
{ cmd: "systemctl restart service", desc: "Restart a service" },
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
{ cmd: "apt remove package", desc: "Remove package" },
{ cmd: "docker ps", desc: "List running containers" },
{ cmd: "docker images", desc: "List Docker images" },
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
{ cmd: "ip addr show", desc: "Show IP addresses" },
{ cmd: "ping host", desc: "Test network connectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" },
{ cmd: "wget url", desc: "Download file from URL" },
{ cmd: "ssh user@host", desc: "Connect via SSH" },
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
{ cmd: "history", desc: "Show command history" },
{ cmd: "clear", desc: "Clear terminal screen" },
]
export function TerminalPanel({
websocketUrl,
onClose,
initMessage,
onWebInteraction,
onWebSocketCreated,
onTerminalOutput,
isScriptModal = false,
}: TerminalPanelProps) {
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [layout, setLayout] = useState<"single" | "grid">("grid")
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
const [isSearching, setIsSearching] = useState(false)
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true)
const containerRefs = useRef<{ [key: string]: HTMLDivElement | null }>({})
const panelRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const updateDeviceType = () => {
const width = window.innerWidth
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
const isTabletSize = width >= 768 && width <= 1366 // iPads Pro pueden llegar a 1366px
setIsMobile(width < 768)
setIsTablet(isTouchDevice && isTabletSize)
}
updateDeviceType()
const handleResize = () => updateDeviceType()
window.addEventListener("resize", handleResize)
const savedHeight = localStorage.getItem("terminalHeight")
if (savedHeight) {
setTerminalHeight(Number.parseInt(savedHeight, 10))
}
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
// Bloquear solo en pantallas muy pequeñas (móviles)
if (window.innerWidth < 640 && !isTablet) {
return
}
e.preventDefault()
e.stopPropagation()
// Detectar si es touch o mouse
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
const startY = clientY
const startHeight = terminalHeight
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
const deltaY = currentY - startY
const newHeight = Math.max(200, Math.min(2400, startHeight + deltaY))
setTerminalHeight(newHeight)
}
const handleEnd = () => {
document.removeEventListener("mousemove", handleMove as any)
document.removeEventListener("mouseup", handleEnd)
document.removeEventListener("touchmove", handleMove as any)
document.removeEventListener("touchend", handleEnd)
localStorage.setItem("terminalHeight", terminalHeight.toString())
}
document.addEventListener("mousemove", handleMove as any)
document.addEventListener("mouseup", handleEnd)
document.addEventListener("touchmove", handleMove as any, { passive: false })
document.addEventListener("touchend", handleEnd)
}
useEffect(() => {
if (terminals.length === 0) {
addNewTerminal()
}
}, [])
useEffect(() => {
const searchCheatSh = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
return
}
try {
setIsSearching(true)
const searchEndpoint = `/api/terminal/search-command?q=${encodeURIComponent(query)}`
const data = await fetchApi<{ success: boolean; examples: any[] }>(searchEndpoint, {
method: "GET",
signal: AbortSignal.timeout(10000),
})
if (!data.success || !data.examples || data.examples.length === 0) {
throw new Error("No examples found")
}
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
command: example.command,
description: example.description || "",
examples: [example.command],
}))
setUseOnline(true)
setSearchResults(formattedResults)
} catch (error) {
const filtered = proxmoxCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()),
)
setFilteredCommands(filtered)
setSearchResults([])
setUseOnline(false)
} finally {
setIsSearching(false)
}
}
const debounce = setTimeout(() => {
if (searchQuery && searchQuery.length >= 2) {
searchCheatSh(searchQuery)
} else {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
}
}, 800)
return () => clearTimeout(debounce)
}, [searchQuery])
const addNewTerminal = () => {
if (terminals.length >= 4) return
const newId = `terminal-${Date.now()}`
setTerminals((prev) => [
...prev,
{
id: newId,
title: `Terminal ${prev.length + 1}`,
term: null,
ws: null,
isConnected: false,
fitAddon: null, // Added fitAddon initialization
},
])
setActiveTerminalId(newId)
}
const closeTerminal = (id: string) => {
const terminal = terminals.find((t) => t.id === id)
if (terminal) {
if (terminal.ws) {
terminal.ws.close()
}
if (terminal.term) {
terminal.term.dispose()
}
}
setTerminals((prev) => {
const filtered = prev.filter((t) => t.id !== id)
if (filtered.length > 0 && activeTerminalId === id) {
setActiveTerminalId(filtered[0].id)
}
return filtered
})
delete containerRefs.current[id]
}
const handleCloseTab = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
closeTerminal(id)
}
useEffect(() => {
terminals.forEach((terminal) => {
const container = containerRefs.current[terminal.id]
if (!terminal.term && container) {
initializeTerminal(terminal, container)
}
})
}, [terminals, isMobile])
useEffect(() => {
if (window.innerWidth < 640) return
terminals.forEach((terminal) => {
if (terminal.term && terminal.fitAddon && terminal.isConnected) {
try {
setTimeout(() => {
terminal.fitAddon?.fit()
if (terminal.ws?.readyState === WebSocket.OPEN) {
const cols = terminal.term?.cols || 80
const rows = terminal.term?.rows || 24
terminal.ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
)
}
}, 100)
} catch (err) {
console.warn("[Terminal] resize on height change failed:", err)
}
}
})
}, [terminalHeight, layout, terminals, isMobile])
useEffect(() => {
if (!isScriptModal) return
const mainContainer = containerRefs.current["main"]
if (!mainContainer) return
const resizeObserver = new ResizeObserver(() => {
terminals.forEach((terminal) => {
if (terminal.term && terminal.fitAddon && terminal.isConnected) {
try {
setTimeout(() => {
terminal.fitAddon?.fit()
if (terminal.ws?.readyState === WebSocket.OPEN) {
const cols = terminal.term?.cols || 80
const rows = terminal.term?.rows || 24
terminal.ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
)
}
}, 50)
} catch (err) {
// Silently handle resize errors
}
}
})
})
resizeObserver.observe(mainContainer)
return () => {
resizeObserver.disconnect()
}
}, [terminals, isScriptModal])
const initializeTerminal = async (terminal: TerminalInstance, container: HTMLDivElement) => {
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
import("xterm/css/xterm.css"),
]).then(([Terminal, FitAddon]) => [Terminal, FitAddon])
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
fontSize: fontSize,
lineHeight: 1,
cursorBlink: true,
scrollback: 2000,
disableStdin: false,
customGlyphs: true,
fontWeight: "500",
fontWeightBold: "700",
theme: {
background: "#000000",
foreground: "#ffffff",
cursor: "#ffffff",
cursorAccent: "#000000",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
})
const fitAddon = new FitAddonClass()
term.loadAddon(fitAddon)
term.open(container)
fitAddon.fit()
const wsUrl = websocketUrl || getWebSocketUrl()
const ws = new WebSocket(wsUrl)
const syncSizeWithBackend = () => {
try {
fitAddon.fit()
if (ws.readyState === WebSocket.OPEN) {
const cols = term.cols
const rows = term.rows
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
)
}
} catch (err) {
console.warn("[Terminal] resize failed:", err)
}
}
ws.onopen = () => {
setTerminals((prev) =>
prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws, fitAddon } : t)),
)
if (onWebSocketCreated) {
onWebSocketCreated(ws)
}
if (initMessage) {
ws.send(JSON.stringify(initMessage))
} else {
term.writeln("\x1b[32mConnected to ProxMenux terminal.\x1b[0m")
}
syncSizeWithBackend()
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === "web_interaction" && data.interaction && onWebInteraction) {
onWebInteraction(data.interaction)
return // Don't write to terminal
}
} catch (e) {
// Not JSON, it's regular terminal output
}
if (onTerminalOutput) {
onTerminalOutput()
}
term.write(event.data)
}
ws.onerror = () => {
setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t)))
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
}
ws.onclose = () => {
setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t)))
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
let resizeTimeout: any = null
const handleResize = () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(() => {
syncSizeWithBackend()
}, 150)
}
window.addEventListener("resize", handleResize)
return () => {
window.removeEventListener("resize", handleResize)
ws.close()
term.dispose()
}
}
const handleKeyButton = (key: string, e?: React.MouseEvent | React.TouchEvent) => {
// Prevenir comportamientos por defecto del navegador
if (e) {
e.preventDefault()
e.stopPropagation()
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (!activeTerminal || !activeTerminal.ws || activeTerminal.ws.readyState !== WebSocket.OPEN) return
let seq = ""
switch (key) {
case "UP":
seq = "\x1bOA"
break
case "DOWN":
seq = "\x1bOB"
break
case "RIGHT":
seq = "\x1bOC"
break
case "LEFT":
seq = "\x1bOD"
break
case "ESC":
seq = "\x1b"
break
case "TAB":
seq = "\t"
break
case "CTRL_C":
seq = "\x03"
break
default:
break
}
activeTerminal.ws.send(seq)
}
const handleClear = () => {
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (activeTerminal?.term) {
activeTerminal.term.clear()
}
}
const handleClose = () => {
terminals.forEach((terminal) => {
if (terminal.ws) terminal.ws.close()
if (terminal.term) terminal.term.dispose()
})
onClose?.()
}
const sendToActiveTerminal = (command: string) => {
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
activeTerminal.ws.send(command)
setTimeout(() => {
setSearchModalOpen(false)
}, 100)
}
}
const sendSequence = (seq: string, e?: React.MouseEvent | React.TouchEvent) => {
if (e) {
e.preventDefault()
e.stopPropagation()
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
activeTerminal.ws.send(seq)
}
}
const getLayoutClass = () => {
const count = terminals.length
if (isMobile || count === 1) return "grid grid-cols-1"
// Vista de cuadrícula 2x2
if (layout === "grid") {
if (count === 2) return "grid grid-cols-2"
if (count === 3) return "grid grid-cols-2 grid-rows-2"
if (count === 4) return "grid grid-cols-2 grid-rows-2"
}
if (count === 2) return "grid grid-cols-1 grid-rows-2"
if (count === 3) return "grid grid-cols-1 grid-rows-3"
if (count === 4) return "grid grid-cols-1 grid-rows-4"
// Vista de filas apiladas (single) - una terminal debajo de otra
return "grid grid-cols-1"
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
return (
<div
ref={panelRef}
className="flex flex-col bg-background"
style={isScriptModal ? { height: "100%" } : { height: `${terminalHeight}px` }}
>
{!isScriptModal && (
<div className="border-b border-border flex-none relative">
<div className="flex items-center justify-between px-2 pt-1">
<div className="flex gap-1 overflow-x-auto scrollbar-hide">
{terminals.map((terminal) => (
<button
key={terminal.id}
onClick={() => setActiveTerminalId(terminal.id)}
className={`px-3 py-1 text-sm rounded-t-md transition-colors whitespace-nowrap ${
terminal.id === activeTerminalId
? "bg-background text-foreground font-medium"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{terminal.title}
{terminals.length > 1 && (
<span onClick={(e) => handleCloseTab(e, terminal.id)} className="ml-2 hover:text-destructive">
×
</span>
)}
</button>
))}
</div>
<button
onClick={addNewTerminal}
className="text-muted-foreground hover:text-foreground transition-colors p-1"
title="New Terminal"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
)}
{/* Terminal Tabs */}
{!isScriptModal && (
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
{terminals.map((terminal) => (
<button
key={terminal.id}
onClick={() => setActiveTerminalId(terminal.id)}
className={`px-3 py-1 text-xs rounded-t transition-colors ${
terminal.id === activeTerminalId ? "bg-zinc-800 text-white" : "text-zinc-500 hover:text-white"
}`}
>
{terminal.title}
</button>
))}
</div>
)}
{isScriptModal && (
<div className="sr-only" data-connection-status={activeTerminal?.isConnected ? "connected" : "disconnected"}>
Connection Status
</div>
)}
<div
data-terminal-container
ref={(el) => {
containerRefs.current["main"] = el
}}
className={`overflow-hidden flex flex-col ${isMobile ? "flex-1 h-[60vh]" : "overflow-hidden"} w-full max-w-full`}
style={
isScriptModal
? { height: "100%", flexShrink: 0 }
: !isMobile || isTablet
? { height: `${terminalHeight}px`, flexShrink: 0 }
: undefined
}
>
{isMobile ? (
<Tabs value={activeTerminalId} onValueChange={setActiveTerminalId} className="h-full flex flex-col">
<TabsList className="w-full justify-start bg-zinc-900 rounded-none border-b border-zinc-800 overflow-x-auto">
{terminals.map((terminal) => (
<TabsTrigger key={terminal.id} value={terminal.id} className="relative">
{terminal.title}
{terminals.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation()
closeTerminal(terminal.id)
}}
className="ml-2 hover:bg-zinc-700 rounded p-0.5"
>
<X className="h-3 w-3" />
</button>
)}
</TabsTrigger>
))}
</TabsList>
{terminals.map((terminal) => (
<TabsContent
key={terminal.id}
value={terminal.id}
forceMount
className={`flex-1 h-full mt-0 ${activeTerminalId === terminal.id ? "block" : "hidden"}`}
>
<div
ref={(el) => (containerRefs.current[terminal.id] = el)}
className="w-full h-full flex-1 bg-black overflow-hidden"
/>
</TabsContent>
))}
</Tabs>
) : (
<div className={`${getLayoutClass()} h-full gap-0.5 bg-zinc-800 p-0.5 w-full overflow-hidden`}>
{terminals.map((terminal) => (
<div
key={terminal.id}
className={`relative bg-zinc-900 overflow-hidden flex flex-col min-h-0 w-full ${
terminals.length > 1 && activeTerminalId === terminal.id ? "ring-2 ring-blue-500" : ""
}`}
>
<div className="flex-shrink-0 flex items-center justify-between px-2 py-1 bg-zinc-900/95 border-b border-zinc-800">
<button
onClick={() => setActiveTerminalId(terminal.id)}
className={`text-xs font-medium ${
activeTerminalId === terminal.id ? "text-blue-400" : "text-zinc-500"
} ${isScriptModal ? "hidden" : ""}`}
>
{terminal.title}
</button>
{terminals.length > 1 && (
<button onClick={() => closeTerminal(terminal.id)} className="hover:bg-zinc-700 rounded p-0.5">
<X className="h-3 w-3" />
</button>
)}
</div>
<div
ref={(el) => (containerRefs.current[terminal.id] = el)}
onClick={() => setActiveTerminalId(terminal.id)}
className="flex-1 w-full max-w-full bg-black overflow-hidden cursor-pointer"
data-terminal-container
/>
</div>
))}
</div>
)}
</div>
{!isScriptModal && (isTablet || (!isMobile && !isTablet)) && terminals.length > 0 && (
<div
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
className="h-2 w-full cursor-row-resize bg-zinc-800 hover:bg-blue-600 transition-colors flex items-center justify-center group relative"
style={{ touchAction: "none" }}
>
<GripHorizontal className="h-4 w-4 text-zinc-600 group-hover:text-white pointer-events-none" />
</div>
)}
{(isMobile || isTablet) && (
<div className="flex flex-wrap gap-2 justify-center items-center px-2 bg-zinc-900 text-sm rounded-b-md border-t border-zinc-700 py-1.5">
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendSequence("\x1b", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
ESC
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendSequence("\t", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
TAB
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleKeyButton("UP", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleKeyButton("DOWN", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleKeyButton("LEFT", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleKeyButton("RIGHT", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendSequence("\x03", e)
}}
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
>
CTRL+C
</Button>
</div>
)}
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
/>
</div>
</DialogHeader>
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</DialogDescription>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
{isSearching && (
<div className="text-center py-4 text-zinc-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
<p className="text-sm">Searching cheat.sh...</p>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2 max-h-[50vh]">
{searchResults.length > 0 ? (
<>
{searchResults.map((result, index) => (
<div
key={index}
className="p-4 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:border-zinc-600 transition-colors"
>
{result.description && (
<p className="text-xs text-zinc-400 mb-2 leading-relaxed"># {result.description}</p>
)}
<div
onClick={() => sendToActiveTerminal(result.command)}
className="flex items-start justify-between gap-2 cursor-pointer group hover:bg-zinc-800/50 rounded p-2 -m-2"
>
<code className="text-sm text-blue-400 font-mono break-all flex-1">{result.command}</code>
<Send className="h-4 w-4 text-zinc-600 group-hover:text-blue-400 flex-shrink-0 mt-0.5 transition-colors" />
</div>
</div>
))}
<div className="text-center py-2">
<p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh
</p>
</div>
</>
) : filteredCommands.length > 0 && !useOnline ? (
filteredCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToActiveTerminal(item.cmd)}
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation()
sendToActiveTerminal(item.cmd)
}}
size="sm"
variant="ghost"
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
</Button>
</div>
</div>
))
) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToActiveTerminal(item.cmd)}
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation()
sendToActiveTerminal(item.cmd)
}}
size="sm"
variant="ghost"
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
</Button>
</div>
</div>
))
) : !isSearching ? (
<div className="text-center py-12 space-y-4">
{searchQuery ? (
<>
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
</div>
</>
) : (
<>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
<div className="text-sm text-zinc-500 space-y-1">
<p>Try searching for:</p>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
<code
key={cmd}
onClick={() => setSearchQuery(cmd)}
className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700"
>
{cmd}
</code>
))}
</div>
</div>
</div>
{useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span>
</div>
)}
</>
)}
</div>
) : null}
</div>
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
<div className="flex items-center gap-2">
<Lightbulb className="w-3 h-3" />
<span>Tip: Search for any Linux command or Proxmox commands (qm, pct, zpool)</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}