UNPKG

@principal-ade/industry-themed-terminal

Version:

Industry-themed terminal wrapper with integrated theming for xterm.js

733 lines (729 loc) 23.1 kB
var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __toESM = (mod, isNodeMode, target) => { target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: () => mod[key], enumerable: true }); return to; }; var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); // src/components/ThemedTerminal.tsx import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Terminal } from "@xterm/xterm"; import { ChevronDown, ExternalLink, Monitor, Terminal as TerminalIcon, X } from "lucide-react"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import"@xterm/xterm/css/xterm.css"; // src/utils/terminalTheme.ts function createTerminalTheme(theme) { return { background: theme.colors.background, foreground: theme.colors.text, cursor: theme.colors.primary, cursorAccent: theme.colors.background, selectionBackground: theme.colors.primary + "40", selectionForeground: theme.colors.text, selectionInactiveBackground: theme.colors.backgroundSecondary, black: "#000000", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c", blue: "#6272a4", magenta: "#bd93f9", cyan: "#8be9fd", white: "#bfbfbf", brightBlack: "#4d4d4d", brightRed: "#ff6e67", brightGreen: "#5af78e", brightYellow: "#f4f99d", brightBlue: "#6cadff", brightMagenta: "#ff92d0", brightCyan: "#9aedfe", brightWhite: "#e6e6e6" }; } function getTerminalCSSVariables(theme) { return { "--terminal-bg": theme.colors.background, "--terminal-fg": theme.colors.text, "--terminal-border": theme.colors.border, "--terminal-header-bg": theme.colors.backgroundSecondary || theme.colors.background, "--terminal-font-family": 'Menlo, Monaco, "Courier New", monospace', "--terminal-font-size": "14px" }; } // src/components/ThemedTerminal.tsx import { jsx, jsxs, Fragment } from "react/jsx-runtime"; var ThemedTerminal = forwardRef(({ theme, onData, onResize, onReady, onLinkClick, onScrollPositionChange, className = "", hideHeader = false, headerTitle = "Terminal", headerSubtitle, headerBadge, autoFocus = true, isVisible = true, scrollbarStyle = "overlay", onClose, onDestroy, onPopOut, overlayState, cursorBlink = true, cursorStyle = "block", scrollback = 1e4, fontFamily, fontSize = 14, enableWebGL = false, enableUnicode11 = false, enableSearch = true, enableWebLinks = true }, ref) => { const terminalRef = useRef(null); const [terminal, setTerminal] = useState(null); const fitAddonRef = useRef(null); const searchAddonRef = useRef(null); const resizeTimeoutRef = useRef(null); const isVisibleRef = useRef(isVisible); const isScrollLockedRef = useRef(true); useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]); const getScrollPosition = () => { if (!terminal) { return { isAtTop: false, isAtBottom: true, isScrollLocked: isScrollLockedRef.current }; } const scrollY = terminal.buffer.active.viewportY; const scrollback2 = terminal.buffer.active.baseY; const isAtTop = scrollY === 0; const isAtBottom = scrollY + terminal.rows >= scrollback2 + terminal.rows; return { isAtTop, isAtBottom, isScrollLocked: isScrollLockedRef.current }; }; useImperativeHandle(ref, () => ({ write: (data) => { if (terminal) { terminal.write(data, () => { if (isScrollLockedRef.current) { terminal.scrollToBottom(); } }); } else { console.warn("[ThemedTerminal] write called but terminal is null!"); } }, writeln: (data) => { if (terminal) { terminal.writeln(data); if (isScrollLockedRef.current) { terminal.scrollToBottom(); } } }, scrollToBottom: () => { if (terminal) { terminal.scrollToBottom(); isScrollLockedRef.current = true; } }, focus: () => { if (terminal) { terminal.focus(); } }, blur: () => { if (terminal) { terminal.blur(); } }, clear: () => { if (terminal) { terminal.clear(); } }, reset: () => { if (terminal) { terminal.reset(); } }, getTerminal: () => terminal, resize: (cols, rows) => { if (terminal) { terminal.resize(cols, rows); } }, selectAll: () => { if (terminal) { terminal.selectAll(); } }, clearSelection: () => { if (terminal) { terminal.clearSelection(); } }, findNext: (searchTerm, searchOptions) => { if (searchAddonRef.current) { return searchAddonRef.current.findNext(searchTerm, searchOptions); } return false; }, findPrevious: (searchTerm, searchOptions) => { if (searchAddonRef.current) { return searchAddonRef.current.findPrevious(searchTerm, searchOptions); } return false; }, clearSearch: () => { if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); } }, fit: () => { if (fitAddonRef.current && terminal) { fitAddonRef.current.fit(); if (isScrollLockedRef.current) { requestAnimationFrame(() => { terminal.scrollToBottom(); }); } } }, isScrollLocked: () => isScrollLockedRef.current, getScrollPosition }), [terminal]); useEffect(() => { if (!terminalRef.current || terminal) { return; } const term = new Terminal({ cursorBlink, cursorStyle, fontSize, fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace', theme: createTerminalTheme(theme), scrollback, allowProposedApi: true, lineHeight: 1, letterSpacing: 0, rightClickSelectsWord: true, smoothScrollDuration: 0, drawBoldTextInBrightColors: true, windowsMode: false, fontWeight: "normal", fontWeightBold: "bold" }); const fitAddon = new FitAddon; fitAddonRef.current = fitAddon; term.loadAddon(fitAddon); if (enableSearch) { const searchAddon = new SearchAddon; searchAddonRef.current = searchAddon; term.loadAddon(searchAddon); } if (enableUnicode11) { import("@xterm/addon-unicode11").then(({ Unicode11Addon }) => { const unicode11Addon = new Unicode11Addon; term.loadAddon(unicode11Addon); term.unicode.activeVersion = "11"; }).catch(() => { console.warn("[Terminal] Unicode11Addon not available"); }); } if (enableWebLinks) { const webLinksAddon = new WebLinksAddon((event, uri) => { event.preventDefault(); if (onLinkClick) { const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(uri); onLinkClick(uri, isLocalhost); } }); term.loadAddon(webLinksAddon); } term.open(terminalRef.current); const scrollDisposable = term.onScroll(() => { const scrollY = term.buffer.active.viewportY; const scrollback2 = term.buffer.active.baseY; const isAtTop = scrollY === 0; const isAtBottom = scrollY + term.rows >= scrollback2 + term.rows; if (onScrollPositionChange) { onScrollPositionChange({ isAtTop, isAtBottom, isScrollLocked: isScrollLockedRef.current }); } }); if (enableWebGL) { import("@xterm/addon-webgl").then(({ WebglAddon }) => { try { const webglAddon = new WebglAddon; webglAddon.onContextLoss(() => { webglAddon.dispose(); console.warn("[Terminal] WebGL context lost, falling back to canvas renderer"); }); term.loadAddon(webglAddon); } catch (e) { console.warn("[Terminal] WebGL renderer not supported, using canvas renderer", e); } }).catch(() => { console.warn("[Terminal] WebglAddon not available"); }); } setTerminal(term); const performFit = () => { if (!fitAddonRef.current || !terminalRef.current || !term || !isVisibleRef.current) { return; } fitAddonRef.current.fit(); if (isScrollLockedRef.current) { requestAnimationFrame(() => { term.scrollToBottom(); }); } }; const handleResize = () => { if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } resizeTimeoutRef.current = setTimeout(() => { performFit(); }, 100); }; const resizeObserver = new ResizeObserver(() => { handleResize(); }); if (terminalRef.current) { resizeObserver.observe(terminalRef.current); } performFit(); return () => { resizeObserver.disconnect(); scrollDisposable.dispose(); if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } term.dispose(); }; }, [theme, onLinkClick, cursorBlink, cursorStyle, scrollback, fontSize, fontFamily, enableSearch, enableWebLinks, enableUnicode11, enableWebGL]); useEffect(() => { if (!terminal || !onReady) { return; } const timeoutId = setTimeout(() => { onReady(terminal.cols, terminal.rows); }, 100); return () => { clearTimeout(timeoutId); }; }, [terminal, onReady]); useEffect(() => { if (!terminal || !onData) { return; } const disposable = terminal.onData((data) => { onData(data); }); return () => { disposable.dispose(); }; }, [terminal, onData]); useEffect(() => { if (!terminal || !onResize) return; const disposable = terminal.onResize((size) => { onResize(size.cols, size.rows); }); return () => { disposable.dispose(); }; }, [terminal, onResize]); useEffect(() => { const terminalElement = terminalRef.current; if (!terminalElement) return; const handleKeyDown = (e) => { if (e.key === " " || e.key === "Space") { e.stopPropagation(); } }; terminalElement.addEventListener("keydown", handleKeyDown, true); return () => { terminalElement.removeEventListener("keydown", handleKeyDown, true); }; }, []); useEffect(() => { if (terminal && autoFocus && isVisible) { setTimeout(() => { terminal.focus(); }, 50); } }, [terminal, autoFocus, isVisible]); useEffect(() => { if (terminal && isVisible && fitAddonRef.current) { setTimeout(() => { fitAddonRef.current?.fit(); if (isScrollLockedRef.current) { terminal.scrollToBottom(); } }, 50); } }, [isVisible, terminal]); const handleDestroy = () => { if (onDestroy) { const confirmed = window.confirm("Are you sure you want to close this terminal session? This will terminate any running processes."); if (confirmed) { onDestroy(); } } }; return /* @__PURE__ */ jsxs("div", { className, style: { display: "flex", flexDirection: "column", height: "100%", width: "100%", backgroundColor: theme.colors.background }, children: [ !hideHeader && /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 16px", borderBottom: `1px solid ${theme.colors.border}`, backgroundColor: theme.colors.backgroundSecondary || theme.colors.background }, children: [ /* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px" }, children: [ /* @__PURE__ */ jsx(TerminalIcon, { size: 16, color: theme.colors.text }), /* @__PURE__ */ jsx("span", { style: { fontSize: "14px", color: theme.colors.text, fontWeight: "500" }, children: headerTitle }), headerSubtitle && /* @__PURE__ */ jsx("span", { style: { fontSize: "12px", color: theme.colors.textSecondary }, children: headerSubtitle }), headerBadge && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("span", { style: { fontSize: "12px", color: theme.colors.textSecondary }, children: "•" }), /* @__PURE__ */ jsx("span", { style: { fontSize: "12px", color: headerBadge.color || theme.colors.primary }, children: headerBadge.label }) ] }) ] }), /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: "8px" }, children: [ onPopOut && /* @__PURE__ */ jsx("button", { type: "button", "aria-label": "Pop out terminal to new window", onClick: onPopOut, style: { padding: "4px", backgroundColor: "transparent", border: "none", cursor: "pointer", color: theme.colors.textSecondary, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "4px", transition: "all 0.2s" }, onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = theme.colors.backgroundTertiary; e.currentTarget.style.color = theme.colors.primary; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = "transparent"; e.currentTarget.style.color = theme.colors.textSecondary; }, title: "Open terminal in new window", children: /* @__PURE__ */ jsx(ExternalLink, { size: 16 }) }), onClose && /* @__PURE__ */ jsx("button", { type: "button", "aria-label": "Hide terminal", onClick: onClose, style: { padding: "4px", backgroundColor: "transparent", border: "none", cursor: "pointer", color: theme.colors.textSecondary, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "4px", transition: "all 0.2s" }, onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = theme.colors.backgroundTertiary; e.currentTarget.style.color = theme.colors.text; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = "transparent"; e.currentTarget.style.color = theme.colors.textSecondary; }, title: "Hide terminal (keeps session running)", children: /* @__PURE__ */ jsx(ChevronDown, { size: 16 }) }), onDestroy && /* @__PURE__ */ jsx("button", { type: "button", "aria-label": "Close terminal session", onClick: handleDestroy, style: { padding: "4px", backgroundColor: "transparent", border: "none", cursor: "pointer", color: theme.colors.textSecondary, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "4px", transition: "all 0.2s" }, onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = "#ff4444"; e.currentTarget.style.color = "#ffffff"; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = "transparent"; e.currentTarget.style.color = theme.colors.textSecondary; }, title: "Close terminal session (terminate process)", children: /* @__PURE__ */ jsx(X, { size: 16 }) }) ] }) ] }), /* @__PURE__ */ jsx("div", { ref: terminalRef, className: `terminal-container-fix ${scrollbarStyle === "hidden" ? "hide-scrollbar" : scrollbarStyle === "thin" ? "thin-scrollbar" : scrollbarStyle === "auto-hide" ? "auto-hide-scrollbar" : ""}`, style: { flex: 1, overflow: "hidden", position: "relative", width: "100%", height: "100%", minHeight: 0 }, children: overlayState && /* @__PURE__ */ jsxs("div", { style: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, opacity: overlayState.opacity ?? 1, gap: "16px", padding: "32px", zIndex: 10 }, children: [ /* @__PURE__ */ jsx(Monitor, { size: 48, color: theme.colors.textSecondary }), /* @__PURE__ */ jsx("div", { style: { fontSize: "16px", fontWeight: "500", color: theme.colors.text, textAlign: "center" }, children: overlayState.message }), overlayState.subtitle && /* @__PURE__ */ jsx("div", { style: { fontSize: "14px", color: theme.colors.textSecondary, textAlign: "center", maxWidth: "400px" }, children: overlayState.subtitle }), overlayState.actions && overlayState.actions.length > 0 && /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: "12px", marginTop: "8px" }, children: overlayState.actions.map((action) => /* @__PURE__ */ jsxs("button", { type: "button", onClick: action.onClick, style: { padding: "8px 16px", backgroundColor: action.primary ? theme.colors.primary : "transparent", color: action.primary ? "#ffffff" : theme.colors.text, border: action.primary ? "none" : `1px solid ${theme.colors.border}`, borderRadius: "6px", cursor: "pointer", fontSize: "14px", fontWeight: "500", display: "flex", alignItems: "center", gap: "8px", transition: "all 0.2s" }, onMouseEnter: (e) => { if (action.primary) { e.currentTarget.style.opacity = "0.8"; } else { e.currentTarget.style.backgroundColor = theme.colors.backgroundSecondary; } }, onMouseLeave: (e) => { if (action.primary) { e.currentTarget.style.opacity = "1"; } else { e.currentTarget.style.backgroundColor = "transparent"; } }, children: [ action.icon, action.label ] }, action.label)) }) ] }) }) ] }); }); ThemedTerminal.displayName = "ThemedTerminal"; // src/components/ThemedTerminalWithProvider.tsx import { useTheme } from "@principal-ade/industry-theme"; import { forwardRef as forwardRef2 } from "react"; import { jsx as jsx2 } from "react/jsx-runtime"; var ThemedTerminalWithProvider = forwardRef2((props, ref) => { const { theme } = useTheme(); return /* @__PURE__ */ jsx2(ThemedTerminal, { ref, theme, ...props }); }); ThemedTerminalWithProvider.displayName = "ThemedTerminalWithProvider"; // src/hooks/useThemedTerminal.ts import { useTheme as useTheme2 } from "@principal-ade/industry-theme"; import { useMemo } from "react"; function useThemedTerminal() { const { theme } = useTheme2(); const terminalOptions = useMemo(() => ({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: createTerminalTheme(theme), scrollback: 1e4, allowProposedApi: true, lineHeight: 1, letterSpacing: 0, rightClickSelectsWord: true, smoothScrollDuration: 0, drawBoldTextInBrightColors: true, windowsMode: false, fontWeight: "normal", fontWeightBold: "bold" }), [theme]); const getTerminalOptions = (overrides) => { return { ...terminalOptions, ...overrides, theme: overrides?.theme ? { ...terminalOptions.theme, ...overrides.theme } : terminalOptions.theme }; }; const getCSSVariables = () => getTerminalCSSVariables(theme); return { theme, getTerminalOptions, getCSSVariables }; } export { useThemedTerminal, getTerminalCSSVariables, createTerminalTheme, ThemedTerminalWithProvider, ThemedTerminal };