import { useVirtualizer } from '@tanstack/react-virtual'; import AnsiUp from 'ansi_up'; import DOMPurify from 'dompurify'; import { observer } from 'mobx-react'; import React, { useEffect, useRef } from 'react'; import { SearchStore } from '../../../search-store/search-store'; import { cssNames } from '../../../utils'; import type { LogTabViewModel } from './logs-view-model'; export interface LogListProps { model: LogTabViewModel; } export const LogList = observer(({ model }: LogListProps) => { const [toBottomVisible, setToBottomVisible] = React.useState(false); const [lastLineVisible, setLastLineVisible] = React.useState(true); const { visibleLogs } = model; const parentRef = useRef(null) const rowVirtualizer = useVirtualizer({ count: visibleLogs.get().length, getScrollElement: () => parentRef.current, estimateSize: () => 38, overscan: 5, scrollPaddingEnd: 0, scrollPaddingStart: 0, }); const onScroll = (event: React.UIEvent) => { if (!parentRef.current) return; setToBottomVisibility(); setLastLineVisibility(); onScrollToTop(); } // TODO: Move to its own hook const setToBottomVisibility = () => { const { scrollTop, scrollHeight } = parentRef.current as HTMLDivElement; // console.log("scrolling", scrollHeight, scrollTop, rowVirtualizer.getTotalSize()) if (scrollHeight - scrollTop > 4000) { setToBottomVisible(true); } else { setToBottomVisible(false); } } const setLastLineVisibility = () => { const { scrollTop, scrollHeight } = parentRef.current as HTMLDivElement; if (scrollHeight - scrollTop < 4000) { setLastLineVisible(true); } else { setLastLineVisible(false); } } /** * Check if user scrolled to top and new logs should be loaded */ const onScrollToTop = async () => { const { scrollTop } = parentRef.current as HTMLDivElement; if (scrollTop === 0) { const logs = model.logs.get(); const firstLog = logs[0]; await model.loadLogs(); const scrollToIndex = model.logs.get().findIndex(log => log === firstLog); rowVirtualizer.scrollToIndex(scrollToIndex, { align: 'start', smoothScroll: false }); } }; useEffect(() => { setTimeout(() => { // Initial scroll to bottom rowVirtualizer.scrollToIndex(visibleLogs.get().length - 1, { align: 'end', smoothScroll: false }); }, 200) }, []) return (
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
))}
) }); const colorConverter = new AnsiUp(); function LogRow({ rowIndex, model }: { rowIndex: number; model: LogTabViewModel }) { const { searchQuery, isActiveOverlay } = model.searchStore; const log = model.visibleLogs.get()[rowIndex]; const contents: React.ReactElement[] = []; const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); if (searchQuery) { // If search is enabled, replace keyword with backgrounded // Case-insensitive search (lowercasing query and keywords in line) const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi"); const matches = log.matchAll(regex); const modified = log.replace(regex, match => match.toLowerCase()); // Splitting text line by keyword const pieces = modified.split(searchQuery.toLowerCase()); pieces.forEach((piece, index) => { const active = isActiveOverlay(rowIndex, index); const lastItem = index === pieces.length - 1; const overlayValue = matches.next().value; const overlay = !lastItem ? ( ) : null; contents.push( {overlay} , ); }); } return (
{contents.length > 1 ? contents : ( )} {/* For preserving copy-paste experience and keeping line breaks */}
); }