Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { saveAs } from 'file-saver';
Expand Down Expand Up @@ -77,18 +77,41 @@ describe('LogViewer Integration Tests', () => {
value: 800,
});

mockUseFullscreen.mockReturnValue([
false,
{ current: document.createElement('div') },
jest.fn(),
true,
]);
mockUseFullscreen.mockReturnValue([false, jest.fn(), jest.fn(), true]);
mockUseTheme.mockReturnValue({
preference: 'system',
effectiveTheme: 'light',
systemPreference: 'light',
setThemePreference: jest.fn(),
});

// Suppress known harmless warnings and errors in test environment
// eslint-disable-next-line no-console
const originalConsoleError = console.error;
// eslint-disable-next-line no-console
const originalConsoleWarn = console.warn;

jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
const message = typeof args[0] === 'string' ? args[0] : String(args[0] ?? '');
if (message.includes('requestAnimationFrame') || message.includes('act(...)')) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
originalConsoleError(...(args as any[]));
});

jest.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
const message = typeof args[0] === 'string' ? args[0] : String(args[0] ?? '');
if (message.includes('mobx-react-lite')) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
originalConsoleWarn(...(args as any[]));
});
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('Full component rendering', () => {
Expand Down Expand Up @@ -209,8 +232,10 @@ describe('LogViewer Integration Tests', () => {

// Simulate user scroll
if (scrollContainer) {
scrollContainer.scrollTop = 100;
scrollContainer.dispatchEvent(new Event('scroll'));
act(() => {
scrollContainer.scrollTop = 100;
scrollContainer.dispatchEvent(new Event('scroll'));
});
}

// Component should handle scroll events
Expand Down Expand Up @@ -558,14 +583,15 @@ describe('LogViewer Integration Tests', () => {
});

describe('Scroll callback integration', () => {
it('should call onScroll callback with scroll information', async () => {
it('should call onScroll callback with scroll information', () => {
const onScroll = jest.fn();

render(<LogViewer {...defaultProps} onScroll={onScroll} />);

// onScroll is called through useVirtualizedScroll hook
await waitFor(() => {
expect(onScroll).toHaveBeenCalled();
expect(onScroll).toHaveBeenCalledWith({
scrollDirection: 'forward',
scrollOffset: 0,
scrollUpdateWasRequested: true,
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { Flex } from '@patternfly/react-core';
import type { VirtualItem } from '@tanstack/react-virtual';

export interface LineNumberGutterProps {
/** Virtual items from the virtualizer */
virtualItems: VirtualItem[];
/** Height of each line in pixels */
itemSize: number;
/** Callback when a line number is clicked */
onLineClick: (lineNumber: number, event: React.MouseEvent) => void;
/** Function to check if a line is highlighted */
isLineHighlighted: (lineNumber: number) => boolean;
}

/**
* Line number gutter component for log viewer
*
* Displays clickable line numbers in a sticky left column.
* Supports single line and range selection via shift-click.
*/
export const LineNumberGutter: React.FC<LineNumberGutterProps> = ({
virtualItems,
itemSize,
onLineClick,
isLineHighlighted,
}) => {
return (
<Flex className="log-viewer__gutter" direction={{ default: 'column' }}>
{virtualItems.map((virtualItem) => {
const lineNumber = virtualItem.index + 1;
const isHighlighted = isLineHighlighted(lineNumber);
return (
<div
key={`gutter-${virtualItem.key}`}
className={`log-viewer__gutter-cell ${isHighlighted ? 'log-viewer__gutter-cell--highlighted' : ''}`}
style={{
position: 'absolute',
top: 0,
left: 0,
height: `${itemSize}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<a
href={`#L${lineNumber}`}
className="log-viewer__line-number"
aria-label={`Jump to line ${lineNumber}`}
onClick={(e) => {
e.preventDefault();
onLineClick(lineNumber, e);
}}
data-line-number={lineNumber}
>
{lineNumber}
</a>
</div>
);
})}
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { LineNumberGutter } from './LineNumberGutter';
import { useLineNumberNavigation } from './useLineNumberNavigation';
import { useVirtualizedScroll } from './useVirtualizedScroll';

export interface SearchedWord {
Expand Down Expand Up @@ -79,7 +81,7 @@ export const VirtualizedLogContent: React.FC<VirtualizedLogContentProps> = ({
}, []);

// Initialize virtualizer
const virtualizer = useVirtualizer({
const virtualizer = useVirtualizer<HTMLDivElement, Element>({
count: lines.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemSize,
Expand All @@ -93,7 +95,7 @@ export const VirtualizedLogContent: React.FC<VirtualizedLogContentProps> = ({
onScroll,
});

// Render a single line with search highlighting
// Render a single line with search highlighting (line numbers are in the gutter)
const renderLine = (line: string, index: number) => {
// Preserve empty lines by using a non-breaking space
// This ensures empty lines maintain their height and are visible
Expand Down Expand Up @@ -131,6 +133,43 @@ export const VirtualizedLogContent: React.FC<VirtualizedLogContentProps> = ({
);
};

// Use line number navigation hook
const { highlightedLines, handleLineClick, isLineHighlighted } = useLineNumberNavigation();

// Scroll to highlighted lines when hash changes or on initial load
React.useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave it a try, but it's not navigating to the selected start line on initial load 🤔

Screen.Recording.2026-02-09.at.9.33.02.mov

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious question: wouldn't it be possible to use useVirtualizedScroll to handle this scroll to index logic? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! Thank you for testing this :-). fixed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's working fine now \o/

if (highlightedLines && highlightedLines.start > 0 && lines.length > 0) {
// Scroll to the start of the highlighted range
// Convert 1-based line number to 0-based index
const targetIndex = highlightedLines.start - 1;

// If target line is out of range, scroll to the last line instead
// This provides better UX by showing the user where the log ends
const scrollIndex = targetIndex < lines.length ? targetIndex : lines.length - 1;

// Wait for next frame to ensure virtualizer is ready after state updates
let rafId2: number;

const rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
virtualizer.scrollToIndex(scrollIndex, {
align: 'center',
behavior: 'smooth',
});
});
});

// Cleanup: cancel pending animation frames on unmount or dependency change
return () => {
cancelAnimationFrame(rafId1);
cancelAnimationFrame(rafId2);
};
}
// Depend on both highlightedLines and lines.length to handle initial data load
// virtualizer reference is stable from useVirtualizer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlightedLines, lines.length]);

const virtualItems = virtualizer.getVirtualItems();

return (
Expand All @@ -144,10 +183,10 @@ export const VirtualizedLogContent: React.FC<VirtualizedLogContentProps> = ({
<span className="pf-v5-c-log-viewer__text">M</span>
</div>

{/* Scrollable container */}
{/* Scrollable container with gutter */}
<div
ref={parentRef}
className="pf-v5-c-log-viewer__list"
className="pf-v5-c-log-viewer__list log-viewer__with-gutter"
style={{
height: `${height}px`,
width: typeof width === 'number' ? `${width}px` : width,
Expand All @@ -160,29 +199,42 @@ export const VirtualizedLogContent: React.FC<VirtualizedLogContentProps> = ({
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
display: 'flex',
}}
>
{/* Visible items */}
{virtualItems.map((virtualItem) => {
const line = lines[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="pf-v5-c-log-viewer__list-item"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderLine(line, virtualItem.index)}
</div>
);
})}
{/* Line number gutter */}
<LineNumberGutter
virtualItems={virtualItems}
itemSize={itemSize}
onLineClick={handleLineClick}
isLineHighlighted={isLineHighlighted}
/>

{/* Log content */}
<div className="log-viewer__content-column">
{virtualItems.map((virtualItem) => {
const line = lines[virtualItem.index];
const lineNumber: number = virtualItem.index + 1;
const isHighlighted = isLineHighlighted(lineNumber);
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className={`pf-v5-c-log-viewer__list-item ${isHighlighted ? 'log-viewer__line--highlighted' : ''}`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderLine(line, lineNumber - 1)}
</div>
);
})}
</div>
</div>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,79 @@

.pf-v5-c-log-viewer__text {
white-space: pre-wrap; // Allow long lines to wrap while preserving spaces and line breaks
word-break: break-word; // Break long words/URLs to prevent horizontal overflow
}

// Line number gutter styles (GitHub-inspired)
// Using specific selector to override PatternFly's default display
.pf-v5-c-log-viewer__list.log-viewer__with-gutter {
display: flex;
}

.log-viewer__gutter {
position: sticky;
left: 0;
width: var(--pf-v5-global--spacer--3xl);
flex-shrink: 0;
background-color: var(--pf-v5-global--BackgroundColor--200);
border-right: var(--pf-v5-global--BorderWidth--sm) solid var(--pf-v5-global--BorderColor--100);
user-select: none;
overflow: hidden;
}

.pf-m-dark .log-viewer__gutter {
background-color: var(--pf-v5-global--BackgroundColor--dark-100);
border-right-color: var(--pf-v5-global--BorderColor--dark-100);
}

.log-viewer__gutter-cell {
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--pf-v5-global--spacer--sm);
width: 100%;
box-sizing: border-box;
}

.log-viewer__gutter-cell--highlighted {
background-color: var(--pf-v5-global--palette--gold-100);
}

.pf-m-dark .log-viewer__gutter-cell--highlighted {
background-color: var(--pf-v5-global--palette--gold-400);
}

.log-viewer__line-number {
font-family: var(--pf-v5-global--FontFamily--monospace);
font-size: var(--pf-v5-global--FontSize--sm);
color: var(--pf-v5-global--Color--200);
text-decoration: none;
cursor: pointer;

&:hover {
color: var(--pf-v5-global--Color--100);
text-decoration: underline;
}
}

.pf-m-dark .log-viewer__line-number {
color: var(--pf-v5-global--Color--dark-200);

&:hover {
color: var(--pf-v5-global--palette--white-300);
text-decoration: underline;
}
}

.log-viewer__content-column {
flex: 1;
min-width: 0;
position: relative;
}

.log-viewer__line--highlighted {
background-color: var(--pf-v5-global--palette--gold-50);
}

.pf-m-dark .log-viewer__line--highlighted {
background-color: var(--pf-v5-global--palette--gold-700);
}
Loading
Loading