Compare commits

...
Author SHA1 Message Date
Icedsoda 4900f8f574 Virtualize lines in <Terminal/> component 2025-06-28 15:11:07 +02:00
3 changed files with 171 additions and 1019 deletions

No files matched your search

+31 -948
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -83,6 +83,7 @@
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.5.2",
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"class-variance-authority": "^0.7.1",
@@ -98,6 +99,7 @@
"react-hook-form": "^7.57.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"react-window": "^1.8.11",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1",
"socket.io-msgpack-parser": "^3.0.2",
+138 -71
View File
@@ -1,11 +1,12 @@
import { cva } from "class-variance-authority";
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FixedSizeList as List } from "react-window";
import { Icon } from "./Icon";
type Props = {
lines: string[];
autoScroll?: boolean; // Optional prop to control if terminal should auto-scroll
className?: string; // Optional prop to control terminal height
className?: string; // Optional prop to control terminal styling
title?: string;
exportPrefix?: string; // Optional prefix for exported log files
};
@@ -43,30 +44,48 @@ const colorMap: Record<string, string> = {
"47": "bg-gray-200",
};
// Parse ANSI color codes in text
const parseColorCodes = (text: string) => {
// Split by ANSI escape sequences
// eslint-disable-next-line no-control-regex
const parts = text.split(/(\x1b\[\d+m)/g);
let currentClass = "";
const result: { text: string; className: string }[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith("\x1b[")) {
// This is a color code
const code = part.slice(2, -1); // Extract the number from \x1b[XXm
currentClass = colorMap[code] || "";
} else if (part) {
// This is text content
result.push({ text: part, className: currentClass });
// Parse ANSI color codes in text with memoization
const parseColorCodes = (() => {
const cache = new Map<string, { text: string; className: string }[]>();
return (text: string) => {
// Check cache first
if (cache.has(text)) {
return cache.get(text)!;
}
}
return result;
};
// Split by ANSI escape sequences
// eslint-disable-next-line no-control-regex
const parts = text.split(/(\x1b\[\d+m)/g);
let currentClass = "";
const result: { text: string; className: string }[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith("\x1b[")) {
// This is a color code
const code = part.slice(2, -1); // Extract the number from \x1b[XXm
currentClass = colorMap[code] || "";
} else if (part) {
// This is text content
result.push({ text: part, className: currentClass });
}
}
// Cache the result (limit cache size to prevent memory leaks)
if (cache.size >= 1000) {
const firstKey = cache.keys().next().value;
if (firstKey) {
cache.delete(firstKey);
}
}
cache.set(text, result);
return result;
};
})();
// Function to strip ANSI color codes for plain text copy
const stripColorCodes = (text: string): string => {
@@ -74,6 +93,34 @@ const stripColorCodes = (text: string): string => {
return text.replace(/\x1b\[\d+m/g, "");
};
// Memoized row component for virtualization
const TerminalRow = React.memo(({
index,
style,
data
}: {
index: number;
style: React.CSSProperties;
data: string[];
}) => {
const line = data[index];
const colorParts = useMemo(() => parseColorCodes(line), [line]);
return (
<div style={style} className="whitespace-pre-wrap px-4 py-0.5">
{colorParts.length > 0
? colorParts.map((part, partIndex) => (
<span key={`${index}-${partIndex}`} className={part.className}>
{part.text || " "}
</span>
))
: line || " "}
</div>
);
});
TerminalRow.displayName = 'TerminalRow';
export function Terminal({
lines,
autoScroll = true,
@@ -81,37 +128,63 @@ export function Terminal({
title = "Terminal",
exportPrefix,
}: Props) {
const terminalRef = useRef<HTMLDivElement>(null);
const listRef = useRef<List>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
const [copySuccess, setCopySuccess] = useState(false);
const [exportSuccess, setExportSuccess] = useState(false);
const [containerHeight, setContainerHeight] = useState(400);
// Handle scrolling
// Fixed configuration values
const itemHeight = 20;
const overscan = 5;
// Track container height changes
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
const container = containerRef.current;
if (!container) return;
// If auto-scroll is enabled and user was at bottom, scroll to bottom when lines change
if (autoScroll && isScrolledToBottom) {
terminal.scrollTop = terminal.scrollHeight;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerHeight(entry.contentRect.height);
}
});
resizeObserver.observe(container);
// Set initial height
setContainerHeight(container.clientHeight);
return () => {
resizeObserver.disconnect();
};
}, []);
// Handle scrolling to bottom when lines change
useEffect(() => {
if (autoScroll && isScrolledToBottom && listRef.current && lines.length > 0) {
listRef.current.scrollToItem(lines.length - 1, "end");
}
}, [lines, autoScroll, isScrolledToBottom]);
// Handle scroll events to detect if user is at bottom
const handleScroll = () => {
const terminal = terminalRef.current;
if (!terminal) return;
const isAtBottom =
Math.abs(
terminal.scrollHeight - terminal.clientHeight - terminal.scrollTop,
) < 10; // Small threshold to account for rounding errors
const handleScroll = useCallback(({ scrollOffset, scrollUpdateWasRequested }: {
scrollOffset: number;
scrollUpdateWasRequested: boolean;
}) => {
// Skip manual scroll position updates (like auto-scroll)
if (scrollUpdateWasRequested) return;
const totalHeight = lines.length * itemHeight;
const maxScrollOffset = Math.max(0, totalHeight - containerHeight);
// Check if scrolled to bottom (with small threshold for rounding errors)
const isAtBottom = scrollOffset >= maxScrollOffset - 10;
setIsScrolledToBottom(isAtBottom);
};
}, [lines.length, itemHeight, containerHeight]);
// Handle copy to clipboard
const handleCopy = async () => {
const handleCopy = useCallback(async () => {
// Strip ANSI color codes and join lines
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
@@ -126,10 +199,10 @@ export function Terminal({
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
}, [lines]);
// Handle export to file
const handleExport = () => {
const handleExport = useCallback(() => {
if (!exportPrefix) return;
// Strip ANSI color codes and join lines
@@ -162,7 +235,7 @@ export function Terminal({
setTimeout(() => {
setExportSuccess(false);
}, 2000);
};
}, [exportPrefix, lines]);
return (
<div className={terminalStyle({ className })}>
@@ -209,18 +282,27 @@ export function Terminal({
</div>
</div>
{/* Terminal content with custom scrollbar styling */}
<div
ref={terminalRef}
onScroll={handleScroll}
className={`scrollbar-thin scrollbar-thumb-neutral-600 scrollbar-track-transparent flex-grow overflow-y-auto bg-neutral-900 p-4 text-neutral-300`}
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgb(82 82 91) transparent",
}}
>
{/* Virtualized Terminal content */}
<div ref={containerRef} className="flex-grow bg-neutral-900 text-neutral-300">
<List
ref={listRef}
height={containerHeight}
width="100%"
itemCount={lines.length}
itemSize={itemHeight}
itemData={lines}
onScroll={handleScroll}
overscanCount={overscan}
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgb(82 82 91) transparent",
}}
>
{TerminalRow}
</List>
{/* Custom scrollbar styles */}
<style>{`
Add commentMore actions
/* For Webkit browsers (Chrome, Safari) */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
@@ -241,21 +323,6 @@ export function Terminal({
scrollbar-color: rgb(82 82 91) transparent;
}
`}</style>
{lines.map((line, index) => {
const colorParts = parseColorCodes(line);
return (
<div key={index} className="whitespace-pre-wrap">
{colorParts.length > 0
? colorParts.map((part, partIndex) => (
<span key={partIndex} className={part.className}>
{part.text || " "}
</span>
))
: line || " "}
</div>
);
})}
</div>
{/* Status bar */}