From b08f8a450dc2ad5583a16132db24f9d1daa57240 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 22 Nov 2025 11:43:17 +0100 Subject: [PATCH] Update terminal-panel.tsx --- AppImage/components/terminal-panel.tsx | 939 +++++++++++++++---------- 1 file changed, 550 insertions(+), 389 deletions(-) diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index b0af09e..f8f97af 100644 --- a/AppImage/components/terminal-panel.tsx +++ b/AppImage/components/terminal-panel.tsx @@ -1,19 +1,44 @@ "use client" import type React from "react" -import { useEffect, useRef, useState } from "react" +import { useEffect, useRef, useState, useCallback } from "react" import { API_PORT } from "@/lib/api-config" -import { Trash2, X, Send, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react" +import { + Activity, + Trash2, + X, + Search, + Send, + Wifi, + WifiOff, + Lightbulb, + Terminal, + Plus, + LayoutGrid, + Columns, + Rows, +} from "lucide-react" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here type TerminalPanelProps = { websocketUrl?: string onClose?: () => void } +interface TerminalInstance { + id: string + title: string + term: any + ws: WebSocket | null + isConnected: boolean + // containerRef: React.RefObject // This is no longer needed as we use callback refs +} + function getWebSocketUrl(): string { if (typeof window === "undefined") { return "ws://localhost:8008/ws/terminal" @@ -31,12 +56,6 @@ function getWebSocketUrl(): string { } } -interface CheatSheetResult { - command: string - description: string - examples: string[] -} - const proxmoxCommands = [ { cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" }, { cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" }, @@ -106,23 +125,28 @@ const proxmoxCommands = [ ] export const TerminalPanel: React.FC = ({ websocketUrl, onClose }) => { - const containerRef = useRef(null) - const termRef = useRef(null) - const fitAddonRef = useRef(null) - const wsRef = useRef(null) - const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null) + const [terminals, setTerminals] = useState([]) + const [activeTerminalId, setActiveTerminalId] = useState("") + const [layout, setLayout] = useState<"single" | "vertical" | "horizontal" | "grid">("single") + const [isMobile, setIsMobile] = useState(false) - const [xtermLoaded, setXtermLoaded] = useState(false) - const [isConnected, setIsConnected] = useState(false) const [searchModalOpen, setSearchModalOpen] = useState(false) const [searchQuery, setSearchQuery] = useState("") const [filteredCommands, setFilteredCommands] = useState>(proxmoxCommands) const [lastKeyPressed, setLastKeyPressed] = useState(null) - const [isMobile, setIsMobile] = useState(false) const [isSearching, setIsSearching] = useState(false) const [searchResults, setSearchResults] = useState([]) const [useOnline, setUseOnline] = useState(true) + const containerRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}) + + const setContainerRef = useCallback( + (id: string) => (el: HTMLDivElement | null) => { + containerRefs.current[id] = el + }, + [], + ) + useEffect(() => { setIsMobile(window.innerWidth < 768) const handleResize = () => setIsMobile(window.innerWidth < 768) @@ -131,326 +155,377 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl }, []) useEffect(() => { - if (!searchQuery.trim()) { - setFilteredCommands(proxmoxCommands) - setSearchResults([]) - return + if (terminals.length === 0) { + addNewTerminal() } - - const query = searchQuery.toLowerCase().trim() - - if (useOnline && query.length > 2) { - setIsSearching(true) - - const searchCheatSh = async () => { - try { - const response = await fetch(`https://cheat.sh/${encodeURIComponent(query)}?T`, { - signal: AbortSignal.timeout(5000), - }) - - if (response.ok) { - const text = await response.text() - - const lines = text.split("\n").filter((line) => line.trim()) - const results: CheatSheetResult[] = [] - - let currentCommand = "" - let currentDesc = "" - let currentExamples: string[] = [] - - for (const line of lines) { - if (line.startsWith("#")) { - if (currentCommand) { - results.push({ - command: currentCommand, - description: currentDesc, - examples: currentExamples, - }) - } - currentCommand = line.replace(/^#\s*/, "").trim() - currentDesc = "" - currentExamples = [] - } else if (line.trim().startsWith("-")) { - currentDesc += line.trim().replace(/^-\s*/, "") + " " - } else if (line.trim() && !line.includes("cheat.sh")) { - currentExamples.push(line.trim()) - } - } - - if (currentCommand) { - results.push({ - command: currentCommand, - description: currentDesc, - examples: currentExamples, - }) - } - - setSearchResults(results) - setFilteredCommands([]) // Clear local results when using online - } else { - throw new Error("API request failed") - } - } catch (error) { - console.log("[v0] Cheat.sh unavailable, using offline mode") - setUseOnline(false) - const filtered = proxmoxCommands.filter( - (item) => item.cmd.toLowerCase().includes(query) || item.desc.toLowerCase().includes(query), - ) - setFilteredCommands(filtered) - setSearchResults([]) - } finally { - setIsSearching(false) - } - } - - const timer = setTimeout(searchCheatSh, 500) - return () => clearTimeout(timer) - } else { - const filtered = proxmoxCommands.filter( - (item) => item.cmd.toLowerCase().includes(query) || item.desc.toLowerCase().includes(query), - ) - setFilteredCommands(filtered) - setSearchResults([]) - setIsSearching(false) - } - }, [searchQuery, useOnline]) + }, []) useEffect(() => { - if (typeof window === "undefined") return + const searchCheatSh = async (query: string) => { + if (!query.trim()) { + setSearchResults([]) + setFilteredCommands(proxmoxCommands) + return + } - Promise.all([ + try { + setIsSearching(true) + setUseOnline(true) + + const response = await fetch(`https://cht.sh/${encodeURIComponent(query)}?T`, { + signal: AbortSignal.timeout(8000), + }) + + if (!response.ok) throw new Error("API request failed") + + const text = await response.text() + + const lines = text.split("\n").filter((line) => line.trim()) + const examples: string[] = [] + let description = "" + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith("#") || trimmed.startsWith("//")) { + if (!description) description = trimmed.replace(/^[#/]+\s*/, "") + } else if (trimmed && !trimmed.includes("cheat.sh") && !trimmed.includes("http")) { + if (trimmed.length < 200) { + examples.push(trimmed) + } + } + } + + if (examples.length > 0) { + setSearchResults([ + { + command: query, + description: description || `Command examples for ${query}`, + examples: examples.slice(0, 5), + }, + ]) + } else { + throw new Error("No examples found") + } + } catch (error) { + console.log("[v0] Falling back to offline mode:", error) + setUseOnline(false) + const filtered = proxmoxCommands.filter( + (item) => + item.cmd.toLowerCase().includes(query.toLowerCase()) || + item.desc.toLowerCase().includes(query.toLowerCase()), + ) + setFilteredCommands(filtered) + setSearchResults([]) + } finally { + setIsSearching(false) + } + } + + const debounce = setTimeout(() => { + if (searchQuery) { + searchCheatSh(searchQuery) + } else { + setSearchResults([]) + setFilteredCommands(proxmoxCommands) + } + }, 500) + + return () => clearTimeout(debounce) + }, [searchQuery]) + + const addNewTerminal = () => { + if (terminals.length >= 4) return + + const newId = `terminal-${Date.now()}` + // containerRefs.current[newId] = useRef(null) // No longer needed + + setTerminals((prev) => [ + ...prev, + { + id: newId, + title: `Terminal ${prev.length + 1}`, + term: null, + ws: null, + isConnected: false, + // containerRef: containerRefs.current[newId], // No longer needed + }, + ]) + 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] // Clean up the ref + } + + useEffect(() => { + terminals.forEach((terminal) => { + const container = containerRefs.current[terminal.id] + if (!terminal.term && container) { + initializeTerminal(terminal, container) + } + }) + }, [terminals, isMobile]) + + const initializeTerminal = async (terminal: TerminalInstance, container: HTMLDivElement) => { + const [Terminal, FitAddon] = 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]) => { - if (!containerRef.current) return + ]).then(([Terminal, FitAddon]) => [Terminal, FitAddon]) - const term = new Terminal({ - fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace", - fontSize: isMobile ? 11 : 13, - cursorBlink: true, - scrollback: 2000, - disableStdin: false, - cols: 150, - rows: 30, - 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 term = new Terminal({ + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace", + fontSize: isMobile ? 11 : 13, + cursorBlink: true, + scrollback: 2000, + disableStdin: false, + cols: isMobile ? 40 : layout === "grid" ? 60 : 120, + rows: isMobile ? 20 : layout === "grid" ? 15 : 30, + 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 FitAddon() - term.loadAddon(fitAddon) + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) - term.open(containerRef.current) + term.open(container) + fitAddon.fit() + + const wsUrl = websocketUrl || getWebSocketUrl() + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws } : t))) + term.writeln("\x1b[32mConnected to ProxMenux terminal.\x1b[0m") + } + + ws.onmessage = (event) => { + term.write(event.data) + } + + ws.onerror = (error) => { + console.error("[v0] TerminalPanel: WebSocket error:", error) + 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) + } + }) + + const handleResize = () => { + try { fitAddon.fit() + } catch { + // Ignore resize errors + } + } - termRef.current = term - fitAddonRef.current = fitAddon - setXtermLoaded(true) + window.addEventListener("resize", handleResize) - const wsUrl = websocketUrl || getWebSocketUrl() - - const ws = new WebSocket(wsUrl) - wsRef.current = ws - - ws.onopen = () => { - setIsConnected(true) - term.writeln("\x1b[32mConnected to ProxMenux terminal.\x1b[0m") - } - - ws.onmessage = (event) => { - term.write(event.data) - } - - ws.onerror = (error) => { - console.error("[v0] TerminalPanel: WebSocket error:", error) - setIsConnected(false) - term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m") - } - - ws.onclose = () => { - setIsConnected(false) - term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m") - } - - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data) - } - }) - - const handleResize = () => { - try { - fitAddon.fit() - } catch { - // Ignore resize errors - } - } - window.addEventListener("resize", handleResize) - - return () => { - window.removeEventListener("resize", handleResize) - ws.close() - term.dispose() - } - }) - .catch((error) => { - console.error("[v0] TerminalPanel: Failed to load xterm:", error) - }) - }, [websocketUrl, isMobile]) - - const sendSequence = (seq: string, keyName?: string) => { - const term = termRef.current - const ws = wsRef.current - if (!term || !ws || ws.readyState !== WebSocket.OPEN) return - ws.send(seq) - if (keyName) { - setLastKeyPressed(keyName) - setTimeout(() => setLastKeyPressed(null), 2000) + return () => { + window.removeEventListener("resize", handleResize) + ws.close() + term.dispose() } } const handleKeyButton = (key: string) => { + const activeTerminal = terminals.find((t) => t.id === activeTerminalId) + if (!activeTerminal || !activeTerminal.ws || activeTerminal.ws.readyState !== WebSocket.OPEN) return + let seq = "" switch (key) { case "UP": - sendSequence("\x1b[A", "↑") + seq = "\x1b[A" break case "DOWN": - sendSequence("\x1b[B", "↓") + seq = "\x1b[B" break case "RIGHT": - sendSequence("\x1b[C", "→") + seq = "\x1b[C" break case "LEFT": - sendSequence("\x1b[D", "←") + seq = "\x1b[D" break case "ESC": - sendSequence("\x1b", "ESC") + seq = "\x1b" break case "TAB": - sendSequence("\t", "TAB") + seq = "\t" break case "CTRL_C": - sendSequence("\x03", "CTRL+C") + seq = "\x03" break default: break } - } - - const handleTouchStart = (e: React.TouchEvent) => { - const touch = e.touches[0] - touchStartRef.current = { - x: touch.clientX, - y: touch.clientY, - time: Date.now(), - } - } - - const handleTouchEnd = (e: React.TouchEvent) => { - const start = touchStartRef.current - if (!start) return - - const touch = e.changedTouches[0] - const dx = touch.clientX - start.x - const dy = touch.clientY - start.y - const dt = Date.now() - start.time - - const minDistance = 30 - const maxTime = 1000 - - touchStartRef.current = null - - if (dt > maxTime) return - - if (Math.abs(dx) < minDistance && Math.abs(dy) < minDistance) { - return - } - - if (Math.abs(dx) > Math.abs(dy)) { - if (dx > 0) { - handleKeyButton("RIGHT") - } else { - handleKeyButton("LEFT") - } - } else { - if (dy > 0) { - handleKeyButton("DOWN") - } else { - handleKeyButton("UP") - } + activeTerminal.ws.send(seq) + if (key) { + setLastKeyPressed(key) + setTimeout(() => setLastKeyPressed(null), 2000) } } const handleClear = () => { - const term = termRef.current - if (!term) return - term.clear() + const activeTerminal = terminals.find((t) => t.id === activeTerminalId) + if (activeTerminal?.term) { + activeTerminal.term.clear() + } } const handleClose = () => { - const ws = wsRef.current - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close() - } - if (onClose) { - onClose() + 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 + "\n") + setSearchModalOpen(false) // Close the search modal after sending a command } } - const handleSendCommand = (command: string) => { - const ws = wsRef.current - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(command + "\r") - setLastKeyPressed(command) - setTimeout(() => setLastKeyPressed(null), 3000) + const sendSequence = (seq: string, keyName?: string) => { + const activeTerminal = terminals.find((t) => t.id === activeTerminalId) + if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) { + activeTerminal.ws.send(seq) + if (keyName) { + setLastKeyPressed(keyName) + setTimeout(() => setLastKeyPressed(null), 2000) + } } - setSearchModalOpen(false) - setSearchQuery("") - setSearchResults([]) } + const getLayoutClass = () => { + const count = terminals.length + if (isMobile || count === 1) return "grid grid-cols-1" + if (layout === "vertical" || count === 2) return "grid grid-cols-2" + if (layout === "horizontal") return "grid grid-rows-2" + if (layout === "grid" || count >= 3) return "grid grid-cols-2 grid-rows-2" + return "grid grid-cols-1" + } + + const activeTerminal = terminals.find((t) => t.id === activeTerminalId) + return ( -
-
- -
- {isConnected ? "Connected" : "Disconnected"} -
+
+
+
+ + +
+ {activeTerminal?.isConnected ? "Connected" : "Disconnected"} +
+ {terminals.length} / 4 terminals +
+ {!isMobile && terminals.length > 1 && ( + <> + + + {terminals.length >= 3 && ( + + )} + + )} +
-
- {!xtermLoaded && ( -
Initializing terminal...
+
+ {isMobile ? ( + + + {terminals.map((terminal) => ( + + {terminal.title} + {terminals.length > 1 && ( + + )} + + ))} + + {terminals.map((terminal) => ( + +
+ + ))} + + ) : ( +
+ {terminals.map((terminal) => ( +
+
+ + {terminals.length > 1 && ( + + )} +
+
+
+ ))} +
)}
-
- {lastKeyPressed && ( - - Sent: {lastKeyPressed} - - )} - - - - - - - -
+ {isMobile && ( +
+ {lastKeyPressed && ( + + Sent: {lastKeyPressed} + + )} + + + + + + + +
+ )} - - - Search Commands - - {useOnline ? "🌐 Online (cheat.sh)" : "📦 Offline Mode"} - - + + Search Commands + + {useOnline ? ( + <> + + Online Mode + + ) : ( + <> + + Offline Mode + + )} + -
- setSearchQuery(e.target.value)} - className="w-full bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" - autoFocus - /> + + Search for Linux and Proxmox commands + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
{isSearching && (
@@ -587,12 +680,12 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl
)} -
+
{searchResults.length > 0 ? ( searchResults.map((result, index) => (
@@ -609,15 +702,15 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl key={idx} onClick={(e) => { e.stopPropagation() - handleSendCommand(example) + sendToActiveTerminal(example) }} - className="flex items-center justify-between p-2 rounded bg-zinc-900/50 hover:bg-zinc-900 group" + className="flex items-center justify-between p-2 rounded bg-zinc-900/50 hover:bg-zinc-900 group cursor-pointer transition-colors" > {example}
)) - ) : filteredCommands.length > 0 ? ( + ) : filteredCommands.length > 0 && !useOnline ? ( filteredCommands.map((item, index) => (
handleSendCommand(item.cmd)} + 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" >
@@ -647,7 +740,34 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl +
+
+ )) + ) : !isSearching && !searchQuery && !useOnline ? ( + proxmoxCommands.map((item, index) => ( +
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" + > +
+
+ {item.cmd} +

{item.desc}

+
+
)) ) : !isSearching ? ( -
- {searchQuery ? `No commands found for "${searchQuery}"` : "Type to search commands..."} +
+ {searchQuery ? ( + <> + +
+

No results found for "{searchQuery}"

+

Try a different command or check your spelling

+
+ + ) : ( + <> + +
+

Search for any command

+
+

Try searching for:

+
+ {["tar", "grep", "docker ps", "qm list", "systemctl"].map((cmd) => ( + setSearchQuery(cmd)} + className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700" + > + {cmd} + + ))} +
+
+
+ {useOnline && ( +
+ + Powered by cheat.sh +
+ )} + + )}
) : null}
-
- 💡 Tip: Search for any Linux command (tar, grep, docker, etc.) or Proxmox commands (qm, pct, pvesh) +
+
+ + + Tip: Search for any Linux command (tar, grep, docker, etc.) or Proxmox commands (qm, pct, pvesh) + +
+ {useOnline && searchResults.length > 0 && Powered by cheat.sh}