import { Terminal as XTerm } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { FitAddon } from '@xterm/addon-fit'; import { useEffect, useRef } from 'react'; import { error as notifyError } from '@/portainer/services/notifications'; export type ShellState = 'idle' | 'connecting' | 'connected' | 'disconnected'; export interface TerminalDimensions { rows: number; cols: number; } export const LINUX_SHELL_INIT_COMMANDS = [ 'export LANG=C.UTF-8\n', 'export LC_ALL=C.UTF-8\n', 'export TERM="xterm-256color"\n', 'alias ls="ls --color=auto"\n', 'clear\n', ]; export function isLinuxTerminalCommand(command: string): boolean { const LINUX_SHELLS = [ 'bash', 'sh', 'zsh', 'ash', 'dash', 'fish', 'csh', 'ksh', ]; const basename = command.split('/').pop() ?? command; return LINUX_SHELLS.includes(basename); } interface Props { url: string; connect: boolean; onStateChange?: (state: ShellState) => void; onResize?: ((dimensions: TerminalDimensions) => void) | null; initialCommands?: string[]; } export function Terminal({ url, connect, onStateChange = () => {}, onResize = () => {}, initialCommands, }: Props) { const terminalRef = useRef(null); const socketRef = useRef(null); const termRef = useRef(null); useEffect(() => { if (!connect) { return () => {}; } let fitAddon: FitAddon | null = null; let cleaned = false; onStateChange('connecting'); const socket = new WebSocket(url); socketRef.current = socket; const resizeObserver = new ResizeObserver(() => { handleResize(); }); socket.addEventListener('open', onOpen); socket.addEventListener('message', onMessage); socket.addEventListener('close', onClose); socket.addEventListener('error', onError); return cleanup; function onOpen() { if (!terminalRef.current) { return; } const term = new XTerm(); termRef.current = term; fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(terminalRef.current); term.options.cursorBlink = true; term.focus(); setTimeout(() => { handleResize(); }, 0); term.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data); } }); term.onKey(({ domEvent }) => { if (domEvent.ctrlKey && domEvent.key === 'd') { cleanup(); } }); resizeObserver.observe(terminalRef.current); initialCommands?.forEach((cmd) => socket.send(cmd)); onStateChange('connected'); } function onMessage(e: MessageEvent) { const encoded = new TextEncoder().encode(e.data); termRef.current?.write(encoded); } function onClose() { cleanup(); } function onError(e: Event) { if (socket.readyState !== WebSocket.CLOSED) { notifyError('Failure', e, 'Websocket connection error'); } cleanup(); } function cleanup() { if (cleaned) return; cleaned = true; socket.removeEventListener('open', onOpen); socket.removeEventListener('message', onMessage); socket.removeEventListener('close', onClose); socket.removeEventListener('error', onError); resizeObserver.disconnect(); socket.close(); termRef.current?.dispose(); termRef.current = null; socketRef.current = null; fitAddon = null; onStateChange('disconnected'); } function handleResize() { fitAddon?.fit(); if (termRef.current) { onResize?.({ rows: termRef.current.rows, cols: termRef.current.cols }); } } // onStateChange, onResize, and initialCommands intentionally excluded — callers pass stable refs // eslint-disable-next-line react-hooks/exhaustive-deps }, [connect, url]); return
; }