Merge pull request #29716 from beanview/bv_wrapLogViewer

feat(logviewer): add textwrapping support for long lines
This commit is contained in:
Fredrik Adelöw
2025-04-29 14:24:54 +02:00
committed by GitHub
7 changed files with 146 additions and 76 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
`LogViewer` now supports a `textWrap` prop that wraps log lines to the next line for overflowing content instead of using horizontal scroll
+1
View File
@@ -750,6 +750,7 @@ export interface LogViewerProps {
root?: string;
};
text: string;
textWrap?: boolean;
}
// Warning: (ae-forgotten-export) The symbol "Props_8" needs to be exported by the entry point index.d.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor';
import startCase from 'lodash/startCase';
import classnames from 'classnames';
@@ -159,6 +159,7 @@ export interface LogLineProps {
classes: ReturnType<typeof useStyles>;
searchText: string;
highlightResultIndex?: number;
setRowHeight?: (index: number, size: number) => void;
}
export function LogLine({
@@ -166,12 +167,20 @@ export function LogLine({
classes,
searchText,
highlightResultIndex,
setRowHeight,
}: LogLineProps) {
const lineRef = useRef<HTMLSpanElement>(null);
const chunks = useMemo(
() => calculateHighlightedChunks(line, searchText),
[line, searchText],
);
useEffect(() => {
if (lineRef.current && setRowHeight) {
setRowHeight(line.lineNumber, lineRef.current.offsetHeight);
}
}, [line.lineNumber, setRowHeight]);
const elements = useMemo(
() =>
chunks.map(({ text, modifiers, highlight }, index) => (
@@ -184,13 +193,14 @@ export function LogLine({
(highlight === highlightResultIndex
? classes.textSelectedHighlight
: classes.textHighlight),
{ [classes.textWrap]: !!setRowHeight },
)}
>
<Linkify options={{ render: renderLink }}>{text}</Linkify>
</span>
)),
[chunks, highlightResultIndex, classes],
[chunks, highlightResultIndex, classes, setRowHeight],
);
return <>{elements}</>;
return <span ref={lineRef}>{elements}</span>;
}
@@ -83,3 +83,9 @@ export const ExampleLogViewer = () => (
<LogViewer text={exampleLog} />
</div>
);
export const WithTextWrap = () => (
<div style={{ height: 240 }}>
<LogViewer text={exampleLog} textWrap />
</div>
);
@@ -33,6 +33,10 @@ export interface LogViewerProps {
* The LogViewer component is optimized for appending content at the end of the text.
*/
text: string;
/**
* Determines if the overflow text should be wrapped or shown via a single line in a horizontal scrollbar.
*/
textWrap?: boolean;
/**
* Styling overrides for classes within the LogViewer component.
*/
@@ -18,10 +18,10 @@ import Box from '@material-ui/core/Box';
import IconButton from '@material-ui/core/IconButton';
import CopyIcon from '@material-ui/icons/FileCopy';
import classnames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { VariableSizeList, FixedSizeList } from 'react-window';
import { AnsiLine, AnsiProcessor } from './AnsiProcessor';
import { LogLine } from './LogLine';
@@ -32,14 +32,17 @@ import { useLogViewerSelection } from './useLogViewerSelection';
export interface RealLogViewerProps {
text: string;
textWrap?: boolean;
classes?: { root?: string };
}
export function RealLogViewer(props: RealLogViewerProps) {
const classes = useStyles({ classes: props.classes });
const [fixedListInstance, setFixedListInstance] = useState<FixedSizeList<
AnsiLine[]
> | null>(null);
const [listInstance, setListInstance] = useState<
VariableSizeList<AnsiLine[]> | FixedSizeList<AnsiLine[]> | null
>(null);
const shouldTextWrap = props.textWrap ?? false;
const heights = useRef<{ [key: number]: number }>({});
// The processor keeps state that optimizes appending to the text
const processor = useMemo(() => new AnsiProcessor(), []);
@@ -50,21 +53,21 @@ export function RealLogViewer(props: RealLogViewerProps) {
const location = useLocation();
useEffect(() => {
if (fixedListInstance) {
fixedListInstance.scrollToItem(lines.length - 1, 'end');
if (listInstance) {
listInstance.scrollToItem(lines.length - 1, 'end');
}
}, [fixedListInstance, lines]);
}, [listInstance, lines]);
useEffect(() => {
if (!fixedListInstance) {
if (!listInstance) {
return;
}
if (search.resultLine) {
fixedListInstance.scrollToItem(search.resultLine - 1, 'center');
listInstance.scrollToItem(search.resultLine - 1, 'center');
} else {
fixedListInstance.scrollToItem(lines.length - 1, 'end');
listInstance.scrollToItem(lines.length - 1, 'end');
}
}, [fixedListInstance, search.resultLine, lines]);
}, [listInstance, search.resultLine, lines]);
useEffect(() => {
if (location.hash) {
@@ -81,70 +84,103 @@ export function RealLogViewer(props: RealLogViewerProps) {
selection.setSelection(line, event.shiftKey);
};
function setRowHeight(index: number, size: number) {
if (shouldTextWrap && listInstance) {
(listInstance as VariableSizeList<AnsiLine[]>).resetAfterIndex(0);
// lineNumber is 1-based but index is 0-based
heights.current[index - 1] = size;
}
}
function getRowHeight(index: number) {
return heights.current[index] || 20;
}
return (
<AutoSizer>
{({ height, width }: { height?: number; width?: number }) => (
<Box style={{ width, height }} className={classes.root}>
<Box className={classes.header}>
<LogViewerControls {...search} />
</Box>
<FixedSizeList
ref={(instance: FixedSizeList<AnsiLine[]>) => {
setFixedListInstance(instance);
}}
className={classes.log}
height={(height || 480) - HEADER_SIZE}
width={width || 640}
itemData={search.lines}
itemSize={20}
itemCount={search.lines.length}
>
{({ index, style, data }) => {
const line = data[index];
const { lineNumber } = line;
return (
<Box
style={{ ...style }}
className={classnames(classes.line, {
[classes.lineSelected]: selection.isSelected(lineNumber),
})}
{({ height, width }: { height?: number; width?: number }) => {
const commonProps = {
ref: setListInstance,
className: classes.log,
height: (height || 480) - HEADER_SIZE,
width: width || 640,
itemData: search.lines,
itemCount: search.lines.length,
};
const renderItem = ({
index,
style,
data,
}: {
index: number;
style: React.CSSProperties;
data: AnsiLine[];
}) => {
const line = data[index];
const { lineNumber } = line;
return (
<Box
style={{ ...style }}
className={classnames(classes.line, {
[classes.lineSelected]: selection.isSelected(lineNumber),
})}
>
{selection.shouldShowButton(lineNumber) && (
<IconButton
data-testid="copy-button"
size="small"
className={classes.lineCopyButton}
onClick={() => selection.copySelection()}
>
{selection.shouldShowButton(lineNumber) && (
<IconButton
data-testid="copy-button"
size="small"
className={classes.lineCopyButton}
onClick={() => selection.copySelection()}
>
<CopyIcon fontSize="inherit" />
</IconButton>
)}
<a
role="row"
target="_self"
href={`#line-${lineNumber}`}
className={classes.lineNumber}
onClick={event => handleSelectLine(lineNumber, event)}
onKeyPress={event => handleSelectLine(lineNumber, event)}
>
{lineNumber}
</a>
<LogLine
line={line}
classes={classes}
searchText={search.searchText}
highlightResultIndex={
search.resultLine === lineNumber
? search.resultLineIndex
: undefined
}
/>
</Box>
);
}}
</FixedSizeList>
</Box>
)}
<CopyIcon fontSize="inherit" />
</IconButton>
)}
<a
role="row"
target="_self"
href={`#line-${lineNumber}`}
className={classes.lineNumber}
onClick={event => handleSelectLine(lineNumber, event)}
onKeyPress={event => handleSelectLine(lineNumber, event)}
>
{lineNumber}
</a>
<LogLine
setRowHeight={shouldTextWrap ? setRowHeight : undefined}
line={line}
classes={classes}
searchText={search.searchText}
highlightResultIndex={
search.resultLine === lineNumber
? search.resultLineIndex
: undefined
}
/>
</Box>
);
};
return (
<Box style={{ width, height }} className={classes.root}>
<Box className={classes.header}>
<LogViewerControls {...search} />
</Box>
{shouldTextWrap ? (
<VariableSizeList<AnsiLine[]>
{...commonProps}
itemSize={getRowHeight}
>
{renderItem}
</VariableSizeList>
) : (
<FixedSizeList<AnsiLine[]> {...commonProps} itemSize={20}>
{renderItem}
</FixedSizeList>
)}
</Box>
);
}}
</AutoSizer>
);
}
@@ -66,10 +66,13 @@ export const useStyles = makeStyles(
log: {
fontFamily: '"Monaco", monospace',
fontSize: theme.typography.pxToRem(12),
lineHeight: '20px',
},
line: {
position: 'relative',
whiteSpace: 'pre',
display: 'flex',
alignItems: 'flex-start',
'&:hover': {
background: theme.palette.action.hover,
@@ -93,6 +96,7 @@ export const useStyles = makeStyles(
width: 60,
marginRight: theme.spacing(1),
cursor: 'pointer',
flexShrink: 0,
},
textHighlight: {
background: alpha(theme.palette.info.main, 0.15),
@@ -163,6 +167,10 @@ export const useStyles = makeStyles(
modifierBackgroundGrey: {
background: colors.grey[500],
},
textWrap: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
},
}),
{ name: 'BackstageLogViewer' },
);