diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts index 9ef0353eca..e957b28b7c 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.test.ts @@ -35,6 +35,7 @@ describe('AnsiProcessor', () => { modifiers: {}, }, ], + text: 'foobarbaz', lineNumber: 1, }, ]); @@ -55,6 +56,7 @@ describe('AnsiProcessor', () => { modifiers: {}, }, ], + text: 'foo bar: baz', lineNumber: 1, }, ]); @@ -68,7 +70,7 @@ a\x1b[34mb\x1b[39mc x\x1b[44my\x1b[49mz `), ).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -84,6 +86,7 @@ x\x1b[44my\x1b[49mz modifiers: {}, }, ], + text: 'abc', lineNumber: 2, }, { @@ -101,9 +104,10 @@ x\x1b[44my\x1b[49mz modifiers: {}, }, ], + text: 'xyz', lineNumber: 3, }, - { chunks: [{ text: '', modifiers: {} }], lineNumber: 4 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 4 }, ]); }); @@ -114,7 +118,7 @@ x\x1b[44my\x1b[49mz a\x1b[45mb\x1b[35mc x\x1b[39my\x1b[49mz`), ).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -130,6 +134,7 @@ x\x1b[39my\x1b[49mz`), modifiers: { foreground: 'magenta', background: 'magenta' }, }, ], + text: 'abc', lineNumber: 2, }, { @@ -147,6 +152,7 @@ x\x1b[39my\x1b[49mz`), modifiers: {}, }, ], + text: 'xyz', lineNumber: 3, }, ]); @@ -157,7 +163,7 @@ x\x1b[39my\x1b[49mz`), const out1 = processor.process(` a\x1b[36mb\x1b[3mc`); expect(out1).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -173,6 +179,7 @@ a\x1b[36mb\x1b[3mc`); modifiers: { foreground: 'cyan', italic: true }, }, ], + text: 'abc', lineNumber: 2, }, ]); @@ -181,7 +188,7 @@ a\x1b[36mb\x1b[3mc`); a\x1b[36mb\x1b[3mc x\x1b[39my\x1b[23mz`); expect(out2).toEqual([ - { chunks: [{ text: '', modifiers: {} }], lineNumber: 1 }, + { chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 }, { chunks: [ { @@ -197,6 +204,7 @@ x\x1b[39my\x1b[23mz`); modifiers: { foreground: 'cyan', italic: true }, }, ], + text: 'abc', lineNumber: 2, }, { @@ -214,6 +222,7 @@ x\x1b[39my\x1b[23mz`); modifiers: {}, }, ], + text: 'xyz', lineNumber: 3, }, ]); diff --git a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts index f3ea37615c..a33d0db450 100644 --- a/packages/core-components/src/components/LogViewer/AnsiProcessor.ts +++ b/packages/core-components/src/components/LogViewer/AnsiProcessor.ts @@ -82,14 +82,25 @@ export interface AnsiChunk { } export class AnsiLine { + text: string; + constructor( readonly lineNumber: number = 1, readonly chunks: AnsiChunk[] = [], - ) {} + ) { + this.text = chunks.map(c => c.text).join(''); + } lastChunk(): AnsiChunk | undefined { return this.chunks[this.chunks.length - 1]; } + + replaceLastChunk(newChunks?: AnsiChunk[]) { + if (newChunks) { + this.chunks.splice(this.chunks.length - 1, 1, ...newChunks); + this.text = this.chunks.map(c => c.text).join(''); + } + } } export class AnsiProcessor { @@ -115,18 +126,14 @@ export class AnsiProcessor { lastChunk?.modifiers, lastLine?.lineNumber, ); - this.text = text; - lastLine.chunks.splice( - lastLine.chunks.length - 1, - 1, - ...newLines[0]?.chunks, - ); + lastLine.replaceLastChunk(newLines[0]?.chunks); + this.lines[lastLineIndex] = lastLine; this.lines.push(...newLines.slice(1)); } else { this.lines = this.processLines(text); - this.text = text; } + this.text = text; return this.lines; } diff --git a/packages/core-components/src/components/LogViewer/LogViewer.tsx b/packages/core-components/src/components/LogViewer/LogViewer.tsx index a77990f734..1adcb6df7e 100644 --- a/packages/core-components/src/components/LogViewer/LogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/LogViewer.tsx @@ -15,12 +15,16 @@ */ import { makeStyles } from '@material-ui/core/styles'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; import { AnsiProcessor } from './AnsiProcessor'; import startCase from 'lodash/startCase'; import * as colors from '@material-ui/core/colors'; +import clsx from 'clsx'; +import TextField from '@material-ui/core/TextField'; + +const HEADER_SIZE = 40; export interface LogViewerProps { text: string; @@ -48,18 +52,38 @@ export interface ChunkModifiers { const useStyles = makeStyles(theme => ({ root: { + background: theme.palette.background.paper, + }, + header: { + height: HEADER_SIZE, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + }, + log: { fontFamily: '"Monaco", monospace', fontSize: theme.typography.fontSize, - background: theme.palette.background.paper, }, line: { whiteSpace: 'pre', + + '&:hover': { + background: theme.palette.action.hover, + }, + }, + lineSelected: { + background: theme.palette.action.selected, + + '&:hover': { + background: theme.palette.action.selected, + }, }, lineNumber: { display: 'inline-block', textAlign: 'end', width: 60, marginRight: theme.spacing(1), + cursor: 'pointer', }, modifierBold: { fontWeight: theme.typography.fontWeightBold, @@ -152,44 +176,81 @@ function getModifierClasses( )}` as keyof typeof classes; classNames.push(classes[key]); } - return classNames.join(' '); + return classNames.length > 0 ? classNames.join(' ') : undefined; } export function LogViewer(props: LogViewerProps) { const { noLineNumbers } = props; const classes = useStyles(); + const [selectedLine, setSelectedLine] = useState(); + const [filter, setFilter] = useState(''); // The processor keeps state that optimizes appending to the text const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); + const filteredLines = useMemo(() => { + if (!filter) { + return lines; + } + return lines.filter(line => line.text.includes(filter)); + }, [lines, filter]); + return ( {({ height, width }) => ( - - {({ index, style, data }) => ( -
- {!noLineNumbers && ( - {index + 1} - )} - {data[index].chunks.map(({ text, modifiers }, i) => ( - +
+ setFilter(e.target.value)} + /> +
+ + {({ index, style, data }) => { + const { chunks, lineNumber } = data[index]; + return ( +
- {text} - - ))} -
- )} -
+ {!noLineNumbers && ( + setSelectedLine(lineNumber)} + onKeyPress={() => setSelectedLine(lineNumber)} + > + {lineNumber} + + )} + {chunks.map(({ text, modifiers }, i) => ( + + {text} + + ))} +
+ ); + }} +
+ )}
);