@principal-ade/industry-themed-terminal
Version:
Industry-themed terminal wrapper with integrated theming for xterm.js
733 lines (729 loc) • 23.1 kB
JavaScript
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
};