-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Log Output modifications (#976)
* feat: extract logs streaming logic to hook * chore: extract useClientRect hook / extract common props * chore: extract AnimatedFullscreenLogOutput * feat: add option to hide action buttons in Log Output * fix: simplify LogOutput DOM structure * feat: add option to wrap log output * feat: add option to modify line in log output - wrap each line with this component - get rid of ansi-to-react - add some optimizations * chore: extract LogOutput logic to hook * feat: expose more powerful references from LogOutput/Console * feat: use basic virtual rendering for the log output * fix: detect proper virtual width of Console * fix: wrapping lines * fix: line number stickiness * fix: scrolling to beginning of line * fix: virtual rendering while watching * fix: wrapping lines * feat: expose option to get line rect in Console * chore: clean LogOutputPure a little * feat: extract log parsing logic to the LogProcessor * feat: optimize console, split logprocessor * fix: OSS log output * feat: avoid full screen animation in oss * chore: extract logic for calculating console dimensions * chore: clean up Console code * chore: use `debounce` from lodash * chore: delete fullscreen animation code for log output * fix: allow empty last callback * feat: extract Toolbar from LogOutput to separate components * chore: extract the scrollbar out of the Log Output * feat: extract keyword scanner / fix: memory leak * feat: add basic search functionality to Log Output * fix: some detected bugs
- Loading branch information
Showing
39 changed files
with
1,521 additions
and
345 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
packages/web/src/components/molecules/Console/Console.styled.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import styled, {css} from 'styled-components'; | ||
|
||
import AnsiClassesMapping from '@atoms/TestkubeTheme/AnsiClassesMapping'; | ||
|
||
import Colors from '@styles/Colors'; | ||
import {invisibleScroll} from '@styles/globalStyles'; | ||
|
||
export const Container = styled.code<{$wrap?: boolean}>` | ||
display: block; | ||
width: 100%; | ||
height: 100%; | ||
overflow: auto; | ||
${({$wrap}) => | ||
$wrap | ||
? ` | ||
word-break: break-all; | ||
white-space: break-spaces;` | ||
: ''} | ||
${AnsiClassesMapping} | ||
${invisibleScroll} | ||
`; | ||
|
||
export const Content = styled.div` | ||
width: min-content; | ||
min-width: 100%; | ||
`; | ||
|
||
export const Space = styled.div` | ||
width: 100%; | ||
`; | ||
|
||
export const Line = styled.div<{$highlighted: boolean}>` | ||
position: relative; | ||
width: 100%; | ||
${({$highlighted}) => ($highlighted ? `background: rgb(255 255 255 / 5%)` : '')} | ||
`; | ||
|
||
export const Keyword = styled.span` | ||
box-shadow: inset 0 -2px 0 0 ${Colors.indigo400}; | ||
`; | ||
|
||
const hiddenCss = css` | ||
visibility: hidden; | ||
opacity: 0; | ||
user-select: none; | ||
pointer-events: none; | ||
overflow: hidden; | ||
`; | ||
|
||
export const PlaceholderContainer = styled.div` | ||
display: none; | ||
${hiddenCss} | ||
`; | ||
|
||
export const Monitor = styled.iframe` | ||
border: 0; | ||
width: 100%; | ||
height: 100%; | ||
`; | ||
|
||
export const HeightMonitor = styled.div` | ||
position: absolute; | ||
left: 0; | ||
top: 0; | ||
bottom: 0; | ||
width: 0; | ||
${hiddenCss} | ||
`; | ||
|
||
export const WidthMonitor = styled.div` | ||
position: absolute; | ||
left: 0; | ||
top: 0; | ||
right: 0; | ||
height: 0; | ||
${hiddenCss} | ||
`; |
183 changes: 183 additions & 0 deletions
183
packages/web/src/components/molecules/Console/Console.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
import { | ||
FC, | ||
PropsWithChildren, | ||
forwardRef, | ||
useEffect, | ||
useImperativeHandle, | ||
useLayoutEffect, | ||
useMemo, | ||
useRef, | ||
useState, | ||
} from 'react'; | ||
import {usePrevious, useUpdate} from 'react-use'; | ||
|
||
import {escapeCarriageReturn} from 'escape-carriage'; | ||
import {debounce} from 'lodash'; | ||
|
||
import {useEventCallback} from '@hooks/useEventCallback'; | ||
import {useLastCallback} from '@hooks/useLastCallback'; | ||
|
||
import * as S from './Console.styled'; | ||
import {ConsoleLine} from './ConsoleLine'; | ||
import {ConsoleLineDimensions, ConsoleLineMonitor} from './ConsoleLineMonitor'; | ||
import {ConsoleLines} from './ConsoleLines'; | ||
import {LogProcessor} from './LogProcessor'; | ||
import {useLogLinesPosition} from './useLogLinesPosition'; | ||
|
||
export interface ConsoleProps { | ||
wrap?: boolean; | ||
content: string; | ||
start?: number; | ||
LineComponent?: FC<PropsWithChildren<{number: number; maxDigits: number}>>; | ||
} | ||
|
||
export interface ConsoleRef { | ||
container: HTMLDivElement | null; | ||
getVisualLinesCount: () => number; | ||
getLineRect: (line: number) => {top: number; height: number}; | ||
scrollToStart: () => void; | ||
scrollToEnd: () => void; | ||
scrollToLine: (line: number) => void; | ||
isScrolledToStart: () => boolean; | ||
isScrolledToEnd: () => boolean; | ||
} | ||
|
||
// TODO: Optimize to process only newly added content | ||
export const Console = forwardRef<ConsoleRef, ConsoleProps>(({content, wrap, LineComponent = ConsoleLine}, ref) => { | ||
const processor = useMemo(() => LogProcessor.from(escapeCarriageReturn(content)), [content]); | ||
const containerRef = useRef<HTMLDivElement | null>(null); | ||
const maxDigits = processor.maxDigits; | ||
|
||
const rerender = useUpdate(); | ||
|
||
const [{baseWidth, characterWidth, maxCharacters, lineHeight}, setDimensions] = useState<ConsoleLineDimensions>({ | ||
baseWidth: 0, | ||
characterWidth: 1000, | ||
maxCharacters: Infinity, | ||
lineHeight: 1000, | ||
lines: 0, | ||
}); | ||
|
||
const {getTop, getSize, getVisualLine, total} = useLogLinesPosition(processor, maxCharacters); | ||
const getTopPx = (line: number) => lineHeight * getTop(line); | ||
const getCenterPx = (line: number) => getTopPx(line) + (getSize(line) * lineHeight) / 2; | ||
|
||
const getViewportTop = useLastCallback(() => Math.floor((containerRef.current?.scrollTop || 0) / lineHeight)); | ||
const getViewportHeight = useLastCallback(() => Math.ceil((containerRef.current?.clientHeight || 0) / lineHeight)); | ||
const getViewport = () => { | ||
const prerender = Math.max(Math.round(getViewportHeight() / 2), 30); | ||
const viewportStart = Math.max(getViewportTop() - prerender, 0); | ||
const viewportEnd = Math.min(viewportStart + getViewportHeight() + 2 * prerender, total - 1); | ||
return {start: viewportStart, end: viewportEnd}; | ||
}; | ||
const getViewportLast = () => Math.ceil(Math.min(1 + getViewportTop() + getViewportHeight(), total)); | ||
|
||
// Keep information about line width | ||
const maxCharactersCount = useMemo(() => processor.getMaxLineLength(), [processor]); | ||
const minWidth = wrap ? 0 : baseWidth + characterWidth * maxCharactersCount; | ||
|
||
// Compute current position | ||
const {start: viewportStart, end: viewportEnd} = getViewport(); | ||
const {index: start, start: visualStart} = useMemo( | ||
() => getVisualLine(viewportStart + 1), | ||
[processor, maxCharacters, viewportStart] | ||
); | ||
const {index: end, end: visualEnd} = useMemo( | ||
() => getVisualLine(viewportEnd + 1), | ||
[processor, maxCharacters, viewportEnd] | ||
); | ||
|
||
const displayed = useMemo(() => processor.getProcessedLines(start, end + 1), [processor, start, end]); | ||
|
||
const beforeCount = visualStart; | ||
const afterCount = total - visualEnd; | ||
const beforePx = Math.max(0, Math.floor(beforeCount * lineHeight)); | ||
const afterPx = Math.max(0, Math.floor(afterCount * lineHeight)); | ||
const styleTop = useMemo(() => ({height: `${beforePx}px`, width: `${minWidth}px`}), [beforePx, minWidth]); | ||
const styleBottom = useMemo(() => ({height: `${afterPx}px`}), [afterPx]); | ||
|
||
const scrollToLine = useLastCallback((line: number) => { | ||
containerRef.current?.scrollTo(0, getCenterPx(line) - containerRef.current?.clientHeight / 2); | ||
}); | ||
const getLineRect = useLastCallback((line: number) => ({ | ||
top: getTop(line), | ||
height: getSize(line), | ||
})); | ||
const isScrolledToEnd = useLastCallback(() => { | ||
const last = getViewportLast(); | ||
return last === 1 || last === total; | ||
}); | ||
const scrollToEnd = () => { | ||
if (containerRef.current) { | ||
containerRef.current.scrollTo(0, containerRef.current?.scrollHeight); | ||
} | ||
}; | ||
const getVisualLinesCount = useLastCallback(() => total); | ||
|
||
useImperativeHandle( | ||
ref, | ||
() => ({ | ||
get container() { | ||
return containerRef.current; | ||
}, | ||
isScrolledToStart() { | ||
return containerRef.current?.scrollTop === 0; | ||
}, | ||
scrollToStart: () => { | ||
if (containerRef.current) { | ||
containerRef.current.scrollTo(0, 0); | ||
} | ||
}, | ||
scrollToEnd, | ||
isScrolledToEnd, | ||
scrollToLine, | ||
getLineRect, | ||
getVisualLinesCount, | ||
}), | ||
[] | ||
); | ||
|
||
// Keep the scroll position | ||
const clientHeight = containerRef.current?.clientHeight || 0; | ||
const domScrollTop = containerRef.current?.scrollTop || 0; | ||
const scrollTop = Math.min(domScrollTop, total * lineHeight - clientHeight); | ||
useLayoutEffect(() => { | ||
containerRef.current!.scrollTop = scrollTop; | ||
}, [scrollTop]); | ||
|
||
// Scroll to bottom after logs change | ||
const scrolledToEnd = getViewportLast() >= (usePrevious(total) || 0); | ||
useEffect(() => { | ||
if (scrolledToEnd) { | ||
scrollToEnd(); | ||
} | ||
}, [content]); | ||
|
||
// Inform about position change | ||
// FIXME | ||
useEffect(() => { | ||
const t = setTimeout(() => containerRef.current?.dispatchEvent(new Event('reposition')), 1); | ||
return () => clearTimeout(t); | ||
}, [viewportStart, viewportEnd, scrollTop, clientHeight, total]); | ||
|
||
// Re-render on scroll | ||
const rerenderDebounce = useMemo(() => debounce(rerender, 5), []); | ||
const onScroll = () => { | ||
const viewport = getViewport(); | ||
if (viewport.start !== viewportStart || viewport.end !== viewportEnd) { | ||
rerenderDebounce(); | ||
} | ||
}; | ||
useEventCallback('scroll', onScroll, containerRef?.current); | ||
|
||
return ( | ||
<S.Container $wrap={wrap} ref={containerRef}> | ||
<S.Content> | ||
<ConsoleLineMonitor Component={LineComponent} maxDigits={maxDigits} wrap={wrap} onChange={setDimensions} /> | ||
<S.Space style={styleTop} /> | ||
<ConsoleLines lines={displayed} start={start} maxDigits={maxDigits} LineComponent={LineComponent} /> | ||
<S.Space style={styleBottom} /> | ||
</S.Content> | ||
</S.Container> | ||
); | ||
}); |
23 changes: 23 additions & 0 deletions
23
packages/web/src/components/molecules/Console/ConsoleLine.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import {FC, PropsWithChildren, memo} from 'react'; | ||
|
||
import {SearchResult, useLogOutput} from '@store/logOutput'; | ||
|
||
import * as S from './Console.styled'; | ||
import {Highlight} from './Highlight'; | ||
|
||
const EMPTY: SearchResult[] = []; | ||
|
||
export const ConsoleLine: FC<PropsWithChildren<{number: number}>> = memo(({number, children}) => { | ||
const {queryLength, results, selected} = useLogOutput(x => ({ | ||
queryLength: x.searchQuery.length, | ||
results: x.searchLinesMap[number] || EMPTY, | ||
selected: x.searchResults[x.searchIndex]?.line === number, | ||
})); | ||
|
||
return ( | ||
<S.Line $highlighted={selected}> | ||
<Highlight highlights={queryLength === 0 ? undefined : results}>{children}</Highlight> | ||
{'\n'} | ||
</S.Line> | ||
); | ||
}); |
Oops, something went wrong.