diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2921d77a..b2d815f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -28,7 +30,9 @@ "react-dom": "^19.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "xterm-addon-search": "^0.13.0", + "xterm-addon-serialize": "^0.11.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -2964,6 +2968,18 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -5050,6 +5066,34 @@ "node": ">=0.10.0" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT", + "peer": true + }, + "node_modules/xterm-addon-search": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz", + "integrity": "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, + "node_modules/xterm-addon-serialize": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz", + "integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 75297d01..eb91a28f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -30,7 +32,9 @@ "react-dom": "^19.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "xterm-addon-search": "^0.13.0", + "xterm-addon-serialize": "^0.11.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 2289c551..debef327 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -26,6 +26,7 @@ interface ContainerInfo { Id: string; Names: string[]; State: string; + Status?: string; Ports?: { PrivatePort: number, PublicPort: number }[]; } @@ -445,6 +446,19 @@ export default function EditorLayout() { return stackName; }; + const getContainerBadge = (container: ContainerInfo) => { + const status = (container.Status || '').toLowerCase(); + const state = (container.State || '').toLowerCase(); + + if (status.includes('(unhealthy)') || state === 'exited' || state === 'dead') { + return { variant: 'destructive' as const, text: container.State }; + } + if (status.includes('(starting)')) { + return { variant: 'secondary' as const, text: container.State }; + } + return { variant: 'default' as const, text: container.State }; + }; + return (
{/* Left Sidebar (Stacks) */} @@ -696,8 +710,8 @@ export default function EditorLayout() {
- - {container?.State || 'unknown'} + + {getContainerBadge(container).text || 'unknown'} CPU: {containerStats[container?.Id]?.cpu || 'N/A'} | RAM: {containerStats[container?.Id]?.ram || 'N/A'} diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index 4f20c491..97098135 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -1,6 +1,11 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; -import { useEffect, useRef } from 'react'; +import { SearchAddon } from '@xterm/addon-search'; +import { SerializeAddon } from '@xterm/addon-serialize'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Download, Search, ChevronUp, ChevronDown, X } from 'lucide-react'; import '@xterm/xterm/css/xterm.css'; interface TerminalComponentProps { @@ -11,8 +16,14 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps) const terminalRef = useRef(null); const terminalInstance = useRef(null); const fitAddonRef = useRef(null); + const searchAddonRef = useRef(null); + const serializeAddonRef = useRef(null); + const searchInputRef = useRef(null); const wsRef = useRef(null); + const [isSearchVisible, setIsSearchVisible] = useState(false); + const [searchText, setSearchText] = useState(''); + useEffect(() => { if (!terminalRef.current) { console.error('Terminal ref not ready'); @@ -72,12 +83,36 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps) }); const fitAddon = new FitAddon(); + const searchAddon = new SearchAddon(); + const serializeAddon = new SerializeAddon(); + term.loadAddon(fitAddon); + term.loadAddon(searchAddon); + term.loadAddon(serializeAddon); + fitAddonRef.current = fitAddon; + searchAddonRef.current = searchAddon; + serializeAddonRef.current = serializeAddon; term.open(terminalRef.current); terminalInstance.current = term; + // Custom key handler for Ctrl+F + term.attachCustomKeyEventHandler((event) => { + if (event.ctrlKey && event.key === 'f' && event.type === 'keydown') { + event.preventDefault(); + setIsSearchVisible(prev => { + const next = !prev; + if (next) { + setTimeout(() => searchInputRef.current?.focus(), 50); + } + return next; + }); + return false; + } + return true; + }); + // Fit after DOM paint using requestAnimationFrame requestAnimationFrame(() => { if (!mounted || !fitAddonRef.current || !terminalRef.current) return; @@ -169,8 +204,87 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps) terminalInstance.current = null; } fitAddonRef.current = null; + searchAddonRef.current = null; + serializeAddonRef.current = null; }; }, [stackName]); - return
; + const handleDownload = () => { + if (!serializeAddonRef.current) return; + const content = serializeAddonRef.current.serialize(); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + a.download = `logs-${stackName || 'terminal'}-${timestamp}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleSearchNext = () => { + if (!searchAddonRef.current || !searchText) return; + searchAddonRef.current.findNext(searchText); + }; + + const handleSearchPrev = () => { + if (!searchAddonRef.current || !searchText) return; + searchAddonRef.current.findPrevious(searchText); + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (e.shiftKey) handleSearchPrev(); + else handleSearchNext(); + } else if (e.key === 'Escape') { + setIsSearchVisible(false); + } + }; + + return ( +
+ {/* Floating Action Bar / Search Bar Panel */} +
+ {!isSearchVisible ? ( + + ) : ( +
+ setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Find in terminal..." + className="h-7 w-48 border-none focus-visible:ring-0 bg-transparent text-sm min-h-0" + /> + + +
+ +
+ )} + + +
+ +
+
+ ); } \ No newline at end of file