mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
735 lines
25 KiB
TypeScript
735 lines
25 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
|
import { Host, SSHKey, Snippet, TerminalSession, TerminalTheme } from '../types';
|
|
import { Zap, FolderInput, Loader2, AlertCircle, ShieldCheck, Clock, Play, X } from 'lucide-react';
|
|
import { Button } from './ui/button';
|
|
import { cn } from '../lib/utils';
|
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
|
import { ScrollArea } from './ui/scroll-area';
|
|
import SFTPPanel from './SFTPPanel';
|
|
|
|
interface TerminalProps {
|
|
host: Host;
|
|
keys: SSHKey[];
|
|
snippets: Snippet[];
|
|
isVisible: boolean;
|
|
inWorkspace?: boolean;
|
|
fontSize: number;
|
|
terminalTheme: TerminalTheme;
|
|
sessionId: string;
|
|
onStatusChange?: (status: TerminalSession['status']) => void;
|
|
onSessionExit?: (sessionId: string) => void;
|
|
onOsDetected?: (hostId: string, distro: string) => void;
|
|
onCloseSession?: () => void;
|
|
}
|
|
|
|
let ghosttyPromise: Promise<Ghostty> | null = null;
|
|
const ensureGhostty = () => {
|
|
if (!ghosttyPromise) {
|
|
ghosttyPromise = Ghostty.load('ghostty-vt.wasm');
|
|
}
|
|
return ghosttyPromise;
|
|
};
|
|
|
|
const TerminalComponent: React.FC<TerminalProps> = ({
|
|
host,
|
|
keys,
|
|
snippets,
|
|
isVisible,
|
|
inWorkspace,
|
|
fontSize,
|
|
terminalTheme,
|
|
sessionId,
|
|
onStatusChange,
|
|
onSessionExit,
|
|
onOsDetected,
|
|
onCloseSession,
|
|
}) => {
|
|
const CONNECTION_TIMEOUT = 12000;
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<GhosttyTerminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const disposeDataRef = useRef<(() => void) | null>(null);
|
|
const disposeExitRef = useRef<(() => void) | null>(null);
|
|
const sessionRef = useRef<string | null>(null);
|
|
const hasConnectedRef = useRef(false);
|
|
|
|
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
|
const [status, setStatus] = useState<TerminalSession['status']>('connecting');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
const [progressLogs, setProgressLogs] = useState<string[]>([]);
|
|
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
const [showSFTP, setShowSFTP] = useState(false);
|
|
const [progressValue, setProgressValue] = useState(15);
|
|
|
|
const updateStatus = (next: TerminalSession['status']) => {
|
|
setStatus(next);
|
|
hasConnectedRef.current = next === 'connected';
|
|
onStatusChange?.(next);
|
|
};
|
|
|
|
const cleanupSession = () => {
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
|
|
if (sessionRef.current && window.nebula?.closeSession) {
|
|
try {
|
|
window.nebula.closeSession(sessionRef.current);
|
|
} catch (err) {
|
|
console.warn("Failed to close SSH session", err);
|
|
}
|
|
}
|
|
sessionRef.current = null;
|
|
};
|
|
|
|
const teardown = () => {
|
|
cleanupSession();
|
|
termRef.current?.dispose();
|
|
termRef.current = null;
|
|
fitAddonRef.current?.dispose();
|
|
fitAddonRef.current = null;
|
|
};
|
|
|
|
const runDistroDetection = async (key?: SSHKey) => {
|
|
if (!window.nebula?.execCommand) return;
|
|
try {
|
|
const res = await window.nebula.execCommand({
|
|
hostname: host.hostname,
|
|
username: host.username || 'root',
|
|
port: host.port || 22,
|
|
password: host.authMethod !== 'key' ? host.password : undefined,
|
|
privateKey: key?.privateKey,
|
|
command: 'cat /etc/os-release 2>/dev/null || uname -a',
|
|
timeout: 8000,
|
|
});
|
|
const data = `${res.stdout || ''}\n${res.stderr || ''}`;
|
|
const idMatch = data.match(/ID=([\\w\\-]+)/i);
|
|
const distro = idMatch ? idMatch[1].replace(/"/g, '') : (data.split(/\\s+/)[0] || '').toLowerCase();
|
|
if (distro) onOsDetected?.(host.id, distro);
|
|
} catch (err) {
|
|
console.warn("OS probe failed", err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
setStatus('connecting');
|
|
setError(null);
|
|
hasConnectedRef.current = false;
|
|
setProgressLogs(['Initializing secure channel...']);
|
|
setShowLogs(false);
|
|
setIsCancelling(false);
|
|
|
|
const boot = async () => {
|
|
try {
|
|
const ghostty = await ensureGhostty();
|
|
if (disposed || !containerRef.current) return;
|
|
|
|
const term = new GhosttyTerminal({
|
|
cursorBlink: true,
|
|
fontSize,
|
|
fontFamily: '"JetBrains Mono", monospace',
|
|
theme: {
|
|
...terminalTheme.colors,
|
|
selectionBackground: terminalTheme.colors.selection,
|
|
},
|
|
ghostty,
|
|
});
|
|
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
termRef.current = term;
|
|
fitAddonRef.current = fitAddon;
|
|
|
|
term.open(containerRef.current);
|
|
fitAddon.fit();
|
|
term.focus();
|
|
|
|
term.onData((data) => {
|
|
const id = sessionRef.current;
|
|
if (id && window.nebula?.writeToSession) {
|
|
window.nebula.writeToSession(id, data);
|
|
}
|
|
});
|
|
|
|
term.onResize(({ cols, rows }) => {
|
|
const id = sessionRef.current;
|
|
if (id && window.nebula?.resizeSession) {
|
|
window.nebula.resizeSession(id, cols, rows);
|
|
}
|
|
});
|
|
|
|
if (host.protocol === 'local' || host.hostname === 'localhost') {
|
|
await startLocal(term);
|
|
} else {
|
|
await startSSH(term);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to initialize terminal", err);
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
updateStatus('disconnected');
|
|
}
|
|
};
|
|
|
|
boot();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
teardown();
|
|
};
|
|
}, [host.id, sessionId]);
|
|
|
|
// Connection timeline and timeout visuals
|
|
useEffect(() => {
|
|
if (status !== 'connecting') return;
|
|
const scripted = [
|
|
'Resolving host and keys...',
|
|
'Negotiating ciphers...',
|
|
'Exchanging keys...',
|
|
'Authenticating user...',
|
|
'Waiting for server greeting...',
|
|
];
|
|
let idx = 0;
|
|
const stepTimer = setInterval(() => {
|
|
setProgressLogs((prev) => {
|
|
if (idx >= scripted.length) return prev;
|
|
const next = scripted[idx++];
|
|
return prev.includes(next) ? prev : [...prev, next];
|
|
});
|
|
}, 900);
|
|
|
|
setTimeLeft(CONNECTION_TIMEOUT / 1000);
|
|
const countdown = setInterval(() => {
|
|
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
|
|
}, 1000);
|
|
|
|
const timeout = setTimeout(() => {
|
|
setError('Connection timed out. Please try again.');
|
|
updateStatus('disconnected');
|
|
setProgressLogs((prev) => [...prev, 'Connection timed out.']);
|
|
}, CONNECTION_TIMEOUT);
|
|
|
|
setProgressValue(15);
|
|
const prog = setInterval(() => {
|
|
setProgressValue((prev) => {
|
|
if (prev >= 92) return 35;
|
|
return prev + Math.random() * 8 + 4;
|
|
});
|
|
}, 450);
|
|
|
|
return () => {
|
|
clearInterval(stepTimer);
|
|
clearInterval(countdown);
|
|
clearTimeout(timeout);
|
|
clearInterval(prog);
|
|
};
|
|
}, [status]);
|
|
|
|
const safeFit = () => {
|
|
if (!fitAddonRef.current) return;
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch (err) {
|
|
console.warn("Fit failed", err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (termRef.current) {
|
|
termRef.current.options.fontSize = fontSize;
|
|
termRef.current.options.theme = {
|
|
...terminalTheme.colors,
|
|
selectionBackground: terminalTheme.colors.selection,
|
|
};
|
|
}
|
|
if (isVisible) {
|
|
safeFit();
|
|
}
|
|
}, [fontSize, terminalTheme, isVisible]);
|
|
|
|
// Debounced fit for resize operations - wait until resize ends
|
|
useEffect(() => {
|
|
if (!containerRef.current || !fitAddonRef.current) return;
|
|
|
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
// Clear previous timeout
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
// Wait 150ms after last resize event before fitting
|
|
resizeTimeout = setTimeout(() => {
|
|
safeFit();
|
|
}, 150);
|
|
});
|
|
|
|
observer.observe(containerRef.current);
|
|
return () => {
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
observer.disconnect();
|
|
};
|
|
}, [isVisible]);
|
|
|
|
useEffect(() => {
|
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const handler = () => {
|
|
// Clear previous timeout
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
// Wait 150ms after last resize event before fitting
|
|
resizeTimeout = setTimeout(() => {
|
|
safeFit();
|
|
}, 150);
|
|
};
|
|
|
|
window.addEventListener('resize', handler);
|
|
return () => {
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
window.removeEventListener('resize', handler);
|
|
};
|
|
}, []);
|
|
|
|
const startSSH = async (term: GhosttyTerminal) => {
|
|
try {
|
|
term.clear?.();
|
|
} catch (err) {
|
|
console.warn("Failed to clear terminal before connect", err);
|
|
}
|
|
|
|
if (!window.nebula?.startSSHSession) {
|
|
setError("Native SSH bridge unavailable. Launch via Electron app.");
|
|
term.writeln(
|
|
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]"
|
|
);
|
|
updateStatus('disconnected');
|
|
return;
|
|
}
|
|
|
|
const key = host.identityFileId
|
|
? keys.find((k) => k.id === host.identityFileId)
|
|
: undefined;
|
|
|
|
try {
|
|
const id = await window.nebula.startSSHSession({
|
|
sessionId,
|
|
hostname: host.hostname,
|
|
username: host.username || 'root',
|
|
port: host.port || 22,
|
|
password: host.authMethod !== 'key' ? host.password : undefined,
|
|
privateKey: key?.privateKey,
|
|
keyId: key?.id,
|
|
agentForwarding: host.agentForwarding,
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
charset: host.charset,
|
|
});
|
|
|
|
sessionRef.current = id;
|
|
|
|
disposeDataRef.current = window.nebula.onSessionData(id, (chunk) => {
|
|
term.write(chunk);
|
|
if (!hasConnectedRef.current) {
|
|
updateStatus('connected');
|
|
// Trigger fit after connection to ensure proper terminal size
|
|
setTimeout(() => {
|
|
if (fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
// Send updated size to remote
|
|
if (sessionRef.current && window.nebula?.resizeSession) {
|
|
window.nebula.resizeSession(sessionRef.current, term.cols, term.rows);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Post-connect fit failed", err);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
disposeExitRef.current = window.nebula.onSessionExit(id, (evt) => {
|
|
updateStatus('disconnected');
|
|
term.writeln(
|
|
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`
|
|
);
|
|
onSessionExit?.(sessionId);
|
|
});
|
|
|
|
if (host.startupCommand) {
|
|
setTimeout(() => {
|
|
if (sessionRef.current) {
|
|
window.nebula?.writeToSession(
|
|
sessionRef.current,
|
|
`${host.startupCommand}\r`
|
|
);
|
|
}
|
|
}, 600);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
term.writeln(`\r\n[Failed to start SSH: ${message}]`);
|
|
updateStatus('disconnected');
|
|
}
|
|
|
|
// Trigger distro detection once connected (hidden exec, no terminal output)
|
|
setTimeout(() => runDistroDetection(key), 600);
|
|
};
|
|
|
|
const startLocal = async (term: GhosttyTerminal) => {
|
|
try {
|
|
term.clear?.();
|
|
} catch (err) {
|
|
console.warn("Failed to clear terminal before connect", err);
|
|
}
|
|
|
|
const startLocalSession = window.nebula?.startLocalSession;
|
|
if (!startLocalSession) {
|
|
setError("Local shell bridge unavailable. Please run the desktop build.");
|
|
term.writeln("\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]");
|
|
updateStatus('disconnected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const id = await startLocalSession({ sessionId, cols: term.cols, rows: term.rows });
|
|
sessionRef.current = id;
|
|
disposeDataRef.current = window.nebula?.onSessionData(id, (chunk) => {
|
|
term.write(chunk);
|
|
if (!hasConnectedRef.current) {
|
|
updateStatus('connected');
|
|
// Trigger fit after connection to ensure proper terminal size
|
|
setTimeout(() => {
|
|
if (fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
// Send updated size to remote
|
|
if (sessionRef.current && window.nebula?.resizeSession) {
|
|
window.nebula.resizeSession(sessionRef.current, term.cols, term.rows);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Post-connect fit failed", err);
|
|
}
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
disposeExitRef.current = window.nebula?.onSessionExit(id, (evt) => {
|
|
updateStatus('disconnected');
|
|
term.writeln(
|
|
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`
|
|
);
|
|
onSessionExit?.(sessionId);
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
term.writeln(`\r\n[Failed to start local shell: ${message}]`);
|
|
updateStatus('disconnected');
|
|
}
|
|
};
|
|
|
|
const handleSnippetClick = (cmd: string) => {
|
|
if (sessionRef.current && window.nebula?.writeToSession) {
|
|
window.nebula.writeToSession(sessionRef.current, `${cmd}\r`);
|
|
setIsScriptsOpen(false);
|
|
termRef.current?.focus();
|
|
return;
|
|
}
|
|
termRef.current?.writeln("\r\n[No active SSH session]");
|
|
};
|
|
|
|
const handleCancelConnect = () => {
|
|
setIsCancelling(true);
|
|
setError('Connection cancelled');
|
|
setProgressLogs((prev) => [...prev, 'Cancelled by user.']);
|
|
cleanupSession();
|
|
updateStatus('disconnected');
|
|
setTimeout(() => setIsCancelling(false), 600);
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
if (!termRef.current) return;
|
|
cleanupSession();
|
|
setStatus('connecting');
|
|
setError(null);
|
|
setProgressLogs(['Retrying secure channel...']);
|
|
setShowLogs(true);
|
|
if (host.protocol === 'local' || host.hostname === 'localhost') {
|
|
startLocal(termRef.current);
|
|
} else {
|
|
startSSH(termRef.current);
|
|
}
|
|
};
|
|
|
|
const renderControls = (variant: 'default' | 'compact', opts?: { showClose?: boolean }) => {
|
|
const isCompact = variant === 'compact';
|
|
const buttonBase = isCompact
|
|
? "h-7 px-2 text-[11px] bg-white/5 hover:bg-white/10 text-white shadow-none border-none"
|
|
: "h-8 px-3 text-xs backdrop-blur-md border border-white/10 shadow-lg";
|
|
const scriptsButtonBase = isCompact
|
|
? "h-7 px-2 text-[11px] bg-white/5 hover:bg-white/10 text-white shadow-none border-none"
|
|
: "h-8 px-3 text-xs bg-muted/20 hover:bg-muted/80 backdrop-blur-md border border-white/10 text-white shadow-lg";
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className={buttonBase}
|
|
disabled={status !== 'connected'}
|
|
title={status === 'connected' ? "Open SFTP" : "Available after connect"}
|
|
onClick={() => setShowSFTP((v) => !v)}
|
|
>
|
|
<FolderInput size={12} className="mr-2" /> SFTP
|
|
</Button>
|
|
|
|
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className={scriptsButtonBase}
|
|
>
|
|
<Zap size={12} className="mr-2" /> Scripts
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align={isCompact ? "start" : "end"}>
|
|
<div className="px-3 py-2 text-[10px] uppercase text-muted-foreground font-semibold bg-muted/30 border-b">
|
|
Library
|
|
</div>
|
|
<ScrollArea className="h-64">
|
|
<div className="py-1">
|
|
{snippets.length === 0 ? (
|
|
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
|
No snippets available
|
|
</div>
|
|
) : (
|
|
snippets.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => handleSnippetClick(s.command)}
|
|
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex flex-col gap-0.5"
|
|
>
|
|
<span className="font-medium">{s.label}</span>
|
|
<span className="text-muted-foreground truncate font-mono text-[10px]">
|
|
{s.command}
|
|
</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{opts?.showClose && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-white/80 hover:text-white hover:bg-white/10"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCloseSession?.();
|
|
}}
|
|
title="Close session"
|
|
>
|
|
<X size={12} />
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const statusDotTone = status === 'connected'
|
|
? "bg-emerald-400"
|
|
: status === 'connecting'
|
|
? "bg-amber-400"
|
|
: "bg-rose-500";
|
|
|
|
return (
|
|
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220] border-l border-border/70">
|
|
{!inWorkspace && (
|
|
<div className="absolute top-4 right-6 z-10 flex gap-2">
|
|
{renderControls('default')}
|
|
</div>
|
|
)}
|
|
|
|
{inWorkspace && (
|
|
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
|
<div className="flex items-center gap-2 px-2 py-1 bg-black/55 text-white backdrop-blur-md pointer-events-auto">
|
|
<div className="flex-1 min-w-0 flex items-center gap-2 text-[11px] font-semibold truncate">
|
|
<span className="truncate">{host.label}</span>
|
|
<span className={cn("inline-block h-2 w-2 rounded-full", statusDotTone)} />
|
|
<span className="text-white/80 font-mono text-[10px] font-normal truncate">
|
|
{host.username}@{host.hostname}:{host.port || 22}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{renderControls('compact', { showClose: true })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"h-full flex-1 min-w-0 transition-all duration-300 relative",
|
|
inWorkspace ? "pt-9 px-2 pb-2" : "p-2"
|
|
)}
|
|
style={{ backgroundColor: terminalTheme.colors.background }}
|
|
>
|
|
<div ref={containerRef} className="h-full w-full" />
|
|
{error && (
|
|
<div className="absolute bottom-3 left-3 text-xs text-destructive bg-background/80 border border-destructive/40 rounded px-3 py-2 shadow-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{status !== 'connected' && (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30">
|
|
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-2xl shadow-xl p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-full bg-primary/20 text-primary flex items-center justify-center text-sm font-semibold">
|
|
{host.label.slice(0, 1).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-semibold">{host.label}</div>
|
|
<div className="text-[11px] text-muted-foreground font-mono">
|
|
{host.username}@{host.hostname}:{host.port || 22}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 text-xs"
|
|
onClick={() => setShowLogs((v) => !v)}
|
|
>
|
|
{showLogs ? 'Hide logs' : 'Show logs'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-full bg-primary/15 text-primary flex items-center justify-center shadow-inner">
|
|
{status === 'connecting' ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<AlertCircle className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
|
|
<div
|
|
className={cn(
|
|
"absolute inset-y-0 left-0 bg-gradient-to-r from-primary via-primary/60 to-primary/80 transition-all duration-400 ease-out"
|
|
)}
|
|
style={{ width: status === 'connecting' ? `${progressValue}%` : '100%' }}
|
|
/>
|
|
<div
|
|
className="absolute inset-y-0 left-0 w-full opacity-20 bg-[radial-gradient(circle,_rgba(255,255,255,0.6)_0%,_rgba(255,255,255,0)_60%)] animate-[shimmer_1.6s_linear_infinite]"
|
|
style={{
|
|
backgroundSize: '120px 120px',
|
|
maskImage: 'linear-gradient(90deg, transparent 0%, black 15%, black 85%, transparent 100%)'
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="h-8 w-8 rounded-full border border-border/70 flex items-center justify-center">
|
|
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-3 w-3" />
|
|
<span>
|
|
{status === 'connecting'
|
|
? `Timeout in ${timeLeft}s`
|
|
: error || 'Disconnected'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{status === 'connecting' ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={handleCancelConnect}
|
|
disabled={isCancelling}
|
|
>
|
|
{isCancelling ? 'Cancelling...' : 'Cancel'}
|
|
</Button>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" size="sm" className="h-8" onClick={handleCancelConnect}>
|
|
Close
|
|
</Button>
|
|
<Button size="sm" className="h-8" onClick={handleRetry}>
|
|
<Play className="h-3 w-3 mr-2" /> Start over
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showLogs && (
|
|
<div className="rounded-xl border border-border/60 bg-background/70 shadow-inner">
|
|
<ScrollArea className="max-h-52 p-3">
|
|
<div className="space-y-2 text-sm text-foreground/90">
|
|
{progressLogs.map((line, idx) => (
|
|
<div key={idx} className="flex items-start gap-2">
|
|
<div className="mt-0.5">
|
|
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
|
|
</div>
|
|
<div>{line}</div>
|
|
</div>
|
|
))}
|
|
{error && (
|
|
<div className="flex items-start gap-2 text-destructive">
|
|
<AlertCircle className="h-3.5 w-3.5 mt-0.5" />
|
|
<div>{error}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
"absolute inset-y-0 right-0 w-[360px] z-30 border-l border-border/60 bg-background/95 shadow-2xl transform transition-transform duration-200 ease-out",
|
|
showSFTP && status === 'connected' ? "translate-x-0 opacity-100" : "translate-x-full opacity-0 pointer-events-none"
|
|
)}
|
|
>
|
|
<SFTPPanel
|
|
host={host}
|
|
credentials={{
|
|
username: host.username,
|
|
hostname: host.hostname,
|
|
port: host.port,
|
|
password: host.authMethod !== 'key' ? host.password : undefined,
|
|
privateKey: host.authMethod === 'key'
|
|
? keys.find(k => k.id === host.identityFileId)?.privateKey
|
|
: undefined,
|
|
}}
|
|
isVisible={showSFTP && status === 'connected'}
|
|
onClose={() => setShowSFTP(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TerminalComponent;
|