-
Notifications
You must be signed in to change notification settings - Fork 36
feat(KFLUXUI-874): support url hash navigation for log viewer #692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
testcara marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import { useVirtualizedScroll } from './useVirtualizedScroll'; | ||
|
|
||
| export interface SearchedWord { | ||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
|
@@ -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(() => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. curious question: wouldn't it be possible to use
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice catch! Thank you for testing this :-). fixed
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]); | ||
testcara marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const virtualItems = virtualizer.getVirtualItems(); | ||
|
|
||
| return ( | ||
|
|
@@ -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, | ||
|
|
@@ -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> | ||
| </> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.