Merge pull request #29716 from beanview/bv_wrapLogViewer
feat(logviewer): add textwrapping support for long lines
This commit is contained in:
@@ -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
|
||||
@@ -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' },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user