feat: add xterm search and serialize addons, enhance terminal component with search functionality and download logs feature

This commit is contained in:
SaelixCode
2026-02-25 23:38:11 -05:00
parent 36335ee5ab
commit 7ce0409ee3
4 changed files with 182 additions and 6 deletions
+45 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+16 -2
View File
@@ -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 (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
{/* Left Sidebar (Stacks) */}
@@ -696,8 +710,8 @@ export default function EditorLayout() {
<div key={container?.Id || Math.random()} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge variant={container?.State === 'running' ? 'default' : 'destructive'} className="text-xs">
{container?.State || 'unknown'}
<Badge variant={getContainerBadge(container).variant} className="text-xs">
{getContainerBadge(container).text || 'unknown'}
</Badge>
<span className="text-xs text-muted-foreground">
CPU: {containerStats[container?.Id]?.cpu || 'N/A'} | RAM: {containerStats[container?.Id]?.ram || 'N/A'}
+116 -2
View File
@@ -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<HTMLDivElement>(null);
const terminalInstance = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(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 <div ref={terminalRef} className="h-full w-full" />;
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<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) handleSearchPrev();
else handleSearchNext();
} else if (e.key === 'Escape') {
setIsSearchVisible(false);
}
};
return (
<div className="flex flex-col h-full w-full relative group">
{/* Floating Action Bar / Search Bar Panel */}
<div className={`absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 ${isSearchVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}`}>
{!isSearchVisible ? (
<Button variant="outline" size="sm" onClick={() => {
setIsSearchVisible(true);
setTimeout(() => searchInputRef.current?.focus(), 50);
}} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" title="Search (Ctrl+F)">
<Search className="w-4 h-4 mr-2" />
Search
</Button>
) : (
<div className="flex flex-row items-center p-1 pr-1 bg-background/95 backdrop-blur-sm border border-border shadow-md rounded-md">
<Input
ref={searchInputRef}
value={searchText}
onChange={(e) => 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"
/>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleSearchPrev} title="Previous (Shift+Enter)">
<ChevronUp className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleSearchNext} title="Next (Enter)">
<ChevronDown className="w-4 h-4" />
</Button>
<div className="w-px h-4 bg-border mx-1" />
<Button variant="ghost" size="icon" className="h-6 w-6 hover:bg-destructive/10 hover:text-destructive" onClick={() => setIsSearchVisible(false)} title="Close (Esc)">
<X className="w-4 h-4" />
</Button>
</div>
)}
<Button variant="outline" size="sm" onClick={handleDownload} className="h-8 bg-background/80 backdrop-blur-sm shadow-sm" title="Download Logs">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
<div ref={terminalRef} className="flex-1 w-full overflow-hidden" />
</div>
);
}