mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
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<HTMLDivElement>(null)
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: visibleLogs.get().length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => 38,
|
|
overscan: 5,
|
|
scrollPaddingEnd: 0,
|
|
scrollPaddingStart: 0,
|
|
});
|
|
|
|
const onScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
ref={parentRef}
|
|
style={{
|
|
flexGrow: 1,
|
|
overflow: 'auto', // Make it scroll!
|
|
}}
|
|
onScroll={onScroll}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
|
<div
|
|
key={virtualRow.index}
|
|
ref={virtualRow.measureElement}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
<div>
|
|
<LogRow rowIndex={virtualRow.index} model={model} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div style={{
|
|
width: "100%",
|
|
height: "1px",
|
|
background: "red",
|
|
position: "absolute",
|
|
bottom: 0,
|
|
}}></div>
|
|
</div>
|
|
</div>
|
|
)
|
|
});
|
|
|
|
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 <span>
|
|
// 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
|
|
? (
|
|
<span
|
|
className={cssNames("overlay", { active })}
|
|
dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }}
|
|
/>
|
|
)
|
|
: null;
|
|
|
|
contents.push(
|
|
<React.Fragment key={piece + index}>
|
|
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(piece) }} />
|
|
{overlay}
|
|
</React.Fragment>,
|
|
);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className={cssNames("LogRow")}>
|
|
{contents.length > 1 ? contents : (
|
|
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(log) }} />
|
|
)}
|
|
{/* For preserving copy-paste experience and keeping line breaks */}
|
|
<br />
|
|
</div>
|
|
);
|
|
} |