core-components: refactor out LogViewer search

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-12-05 18:38:44 +01:00
committed by blam
parent ed042d2056
commit e708a0109f
3 changed files with 121 additions and 81 deletions
@@ -17,102 +17,49 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { AnsiLine, AnsiProcessor } from './AnsiProcessor';
import { AnsiProcessor } from './AnsiProcessor';
import { HEADER_SIZE, useStyles } from './styles';
import clsx from 'clsx';
import { LogLine } from './LogLine';
import { LogViewerControls } from './LogViewerControls';
import { useToggle } from 'react-use';
import { useLogViewerSearch } from './useLogViewerSearch';
export interface LogViewerProps {
text: string;
}
function applySearchFilter(lines: AnsiLine[], searchText: string) {
if (!searchText) {
return { lines };
}
const matchingLines = [];
const searchResults = [];
for (const line of lines) {
if (line.text.includes(searchText)) {
matchingLines.push(line);
let offset = 0;
let lineResultIndex = 0;
for (;;) {
const start = line.text.indexOf(searchText, offset);
if (start === -1) {
break;
}
searchResults.push({
lineNumber: line.lineNumber,
lineIndex: lineResultIndex++,
});
offset = start + searchText.length;
}
}
}
return {
lines: matchingLines,
results: searchResults,
};
}
export function LogViewer(props: LogViewerProps) {
const classes = useStyles();
const listRef = useRef<FixedSizeList | null>(null);
const [selectedLine, setSelectedLine] = useState<number>();
const [resultIndex, setResultIndex] = useState<number | undefined>();
const [shouldFilter, toggleShouldFilter] = useToggle(false);
const [searchInput, setSearchInput] = useState('');
const searchText = searchInput.toLocaleLowerCase('en-US');
// The processor keeps state that optimizes appending to the text
const processor = useMemo(() => new AnsiProcessor(), []);
const lines = processor.process(props.text);
const filter = useMemo(
() => applySearchFilter(lines, searchText),
[lines, searchText],
);
const searchResult = filter.results?.[resultIndex ?? 0];
const searchResultLine = searchResult?.lineNumber;
const displayLines = shouldFilter ? filter.lines : lines;
const search = useLogViewerSearch(lines);
useEffect(() => {
if (searchResultLine !== undefined && listRef.current) {
listRef.current.scrollToItem(searchResultLine - 1, 'center');
if (search.resultLine !== undefined && listRef.current) {
listRef.current.scrollToItem(search.resultLine - 1, 'center');
}
}, [searchResultLine]);
}, [search.resultLine]);
return (
<AutoSizer>
{({ height, width }) => (
<div style={{ width, height }} className={classes.root}>
<div className={classes.header}>
<LogViewerControls
search={searchInput}
onSearchChange={setSearchInput}
resultIndex={resultIndex}
resultCount={filter.results?.length}
onResultIndexChange={setResultIndex}
shouldFilter={shouldFilter}
onToggleShouldFilter={toggleShouldFilter}
/>
<LogViewerControls {...search} />
</div>
<FixedSizeList
ref={listRef}
className={classes.log}
height={height - HEADER_SIZE}
width={width}
itemData={displayLines}
itemData={search.lines}
itemSize={20}
itemCount={displayLines.length}
itemCount={search.lines.length}
>
{({ index, style, data }) => {
const line = data[index];
@@ -137,10 +84,10 @@ export function LogViewer(props: LogViewerProps) {
<LogLine
line={line}
classes={classes}
searchText={searchText}
searchText={search.searchText}
highlightResultIndex={
searchResultLine === lineNumber
? searchResult!.lineIndex
search.resultLine === lineNumber
? search.resultLineIndex
: undefined
}
/>
@@ -21,39 +21,32 @@ import Typography from '@material-ui/core/Typography';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import FilterListIcon from '@material-ui/icons/FilterList';
import { LogViewerSearch } from './useLogViewerSearch';
export interface LogViewerControlsProps {
search: string;
onSearchChange: (search: string) => void;
resultIndex: number | undefined;
resultCount: number | undefined;
onResultIndexChange: (index: number) => void;
shouldFilter: boolean;
onToggleShouldFilter: () => void;
}
export interface LogViewerControlsProps extends LogViewerSearch {}
export function LogViewerControls(props: LogViewerControlsProps) {
const { resultCount, onResultIndexChange, onToggleShouldFilter } = props;
const { resultCount, setResultIndex, toggleShouldFilter } = props;
const resultIndex = props.resultIndex ?? 0;
const increment = () => {
if (resultCount !== undefined) {
const next = resultIndex + 1;
onResultIndexChange(next >= resultCount ? 0 : next);
setResultIndex(next >= resultCount ? 0 : next);
}
};
const decrement = () => {
if (resultCount !== undefined) {
const next = resultIndex - 1;
onResultIndexChange(next < 0 ? resultCount - 1 : next);
setResultIndex(next < 0 ? resultCount - 1 : next);
}
};
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
if (event.metaKey || event.ctrlKey || event.altKey) {
onToggleShouldFilter();
toggleShouldFilter();
} else if (event.shiftKey) {
decrement();
} else {
@@ -81,11 +74,11 @@ export function LogViewerControls(props: LogViewerControlsProps) {
size="small"
variant="standard"
placeholder="Search"
value={props.search}
value={props.searchInput}
onKeyPress={handleKeyPress}
onChange={e => props.onSearchChange(e.target.value)}
onChange={e => props.setSearchInput(e.target.value)}
/>
<IconButton size="small" onClick={onToggleShouldFilter}>
<IconButton size="small" onClick={toggleShouldFilter}>
{props.shouldFilter ? (
<FilterListIcon color="primary" />
) : (
@@ -0,0 +1,100 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, useState } from 'react';
import { useToggle } from 'react-use';
import { AnsiLine } from './AnsiProcessor';
export function applySearchFilter(lines: AnsiLine[], searchText: string) {
if (!searchText) {
return { lines };
}
const matchingLines = [];
const searchResults = [];
for (const line of lines) {
if (line.text.includes(searchText)) {
matchingLines.push(line);
let offset = 0;
let lineResultIndex = 0;
for (;;) {
const start = line.text.indexOf(searchText, offset);
if (start === -1) {
break;
}
searchResults.push({
lineNumber: line.lineNumber,
lineIndex: lineResultIndex++,
});
offset = start + searchText.length;
}
}
}
return {
lines: matchingLines,
results: searchResults,
};
}
export interface LogViewerSearch {
lines: AnsiLine[];
searchText: string;
searchInput: string;
setSearchInput: (searchInput: string) => void;
shouldFilter: boolean;
toggleShouldFilter: () => void;
resultIndex: number | undefined;
resultCount: number | undefined;
setResultIndex: (number: number) => void;
resultLine: number | undefined;
resultLineIndex: number | undefined;
}
export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch {
const [searchInput, setSearchInput] = useState('');
const searchText = searchInput.toLocaleLowerCase('en-US');
const [resultIndex, setResultIndex] = useState<number | undefined>();
const [shouldFilter, toggleShouldFilter] = useToggle(false);
const filter = useMemo(
() => applySearchFilter(lines, searchText),
[lines, searchText],
);
const searchResult = filter.results?.[resultIndex ?? 0];
return {
lines: shouldFilter ? filter.lines : lines,
searchText,
searchInput,
setSearchInput,
shouldFilter,
toggleShouldFilter,
resultIndex,
resultCount: filter.results?.length,
setResultIndex,
resultLine: searchResult?.lineNumber,
resultLineIndex: searchResult?.lineIndex,
};
}