16
16
* specific language governing permissions and limitations
17
17
* under the License.
18
18
*/
19
- import { Box , Code , VStack , useToken } from "@chakra-ui/react" ;
19
+ import { Box , Code , VStack , IconButton } from "@chakra-ui/react" ;
20
20
import { useVirtualizer } from "@tanstack/react-virtual" ;
21
- import { useLayoutEffect , useRef } from "react" ;
21
+ import { useLayoutEffect , useMemo , useRef } from "react" ;
22
+ import { useHotkeys } from "react-hotkeys-hook" ;
23
+ import { useTranslation } from "react-i18next" ;
24
+ import { FiChevronDown , FiChevronUp } from "react-icons/fi" ;
22
25
23
26
import { ErrorAlert } from "src/components/ErrorAlert" ;
24
- import { ProgressBar } from "src/components/ui" ;
27
+ import { ProgressBar , Tooltip } from "src/components/ui" ;
28
+ import { getMetaKey } from "src/utils" ;
25
29
26
30
type Props = {
27
31
readonly error : unknown ;
@@ -31,8 +35,50 @@ type Props = {
31
35
readonly wrap : boolean ;
32
36
} ;
33
37
38
+ const ScrollToButton = ( {
39
+ direction,
40
+ onClick,
41
+ } : {
42
+ readonly direction : "bottom" | "top" ;
43
+ readonly onClick : ( ) => void ;
44
+ } ) => {
45
+ const { t : translate } = useTranslation ( "common" ) ;
46
+
47
+ return (
48
+ < Tooltip
49
+ closeDelay = { 100 }
50
+ content = { translate ( "scroll.tooltip" , {
51
+ direction : translate ( `scroll.direction.${ direction } ` ) ,
52
+ hotkey : `${ getMetaKey ( ) } +${ direction === "bottom" ? "↓" : "↑" } ` ,
53
+ } ) }
54
+ openDelay = { 100 }
55
+ >
56
+ < IconButton
57
+ _ltr = { {
58
+ left : "auto" ,
59
+ right : 4 ,
60
+ } }
61
+ _rtl = { {
62
+ left : 4 ,
63
+ right : "auto" ,
64
+ } }
65
+ aria-label = { translate ( `scroll.direction.${ direction } ` ) }
66
+ bg = "bg.panel"
67
+ bottom = { direction === "bottom" ? 4 : 14 }
68
+ onClick = { onClick }
69
+ position = "absolute"
70
+ rounded = "full"
71
+ size = "xs"
72
+ variant = "outline"
73
+ >
74
+ { direction === "bottom" ? < FiChevronDown /> : < FiChevronUp /> }
75
+ </ IconButton >
76
+ </ Tooltip >
77
+ ) ;
78
+ } ;
79
+
34
80
export const TaskLogContent = ( { error, isLoading, logError, parsedLogs, wrap } : Props ) => {
35
- const [ bgLine ] = useToken ( "colors ", [ "blue.emphasized" ] ) ;
81
+ const hash = location . hash . replace ( "# ", "" ) ;
36
82
const parentRef = useRef ( null ) ;
37
83
const rowVirtualizer = useVirtualizer ( {
38
84
count : parsedLogs . length ,
@@ -41,26 +87,30 @@ export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }:
41
87
overscan : 10 ,
42
88
} ) ;
43
89
44
- useLayoutEffect ( ( ) => {
45
- if ( location . hash ) {
46
- const hash = location . hash . replace ( "#" , "" ) ;
90
+ const showScrollButtons = useMemo ( ( ) => {
91
+ const contentHeight = rowVirtualizer . getTotalSize ( ) ;
92
+ const containerHeight = rowVirtualizer . scrollElement ?. clientHeight ?? 0 ;
47
93
48
- setTimeout ( ( ) => {
49
- const element = document . querySelector < HTMLElement > ( `[id=' ${ hash } ']` ) ;
94
+ return parsedLogs . length > 0 && contentHeight > containerHeight ;
95
+ } , [ rowVirtualizer , parsedLogs ] ) ;
50
96
51
- if ( element !== null ) {
52
- element . style . background = bgLine as string ;
53
- }
54
- element ?. scrollIntoView ( {
55
- behavior : "smooth" ,
56
- block : "center" ,
57
- } ) ;
58
- } , 100 ) ;
97
+ useLayoutEffect ( ( ) => {
98
+ if ( location . hash && ! isLoading ) {
99
+ rowVirtualizer . scrollToIndex ( Math . min ( Number ( hash ) + 5 , parsedLogs . length - 1 ) ) ;
59
100
}
60
- } , [ isLoading , bgLine ] ) ;
101
+ } , [ isLoading , rowVirtualizer , hash , parsedLogs ] ) ;
102
+
103
+ const handleScrollTo = ( to : "bottom" | "top" ) => {
104
+ if ( parsedLogs . length > 0 ) {
105
+ rowVirtualizer . scrollToIndex ( to === "bottom" ? parsedLogs . length - 1 : 0 ) ;
106
+ }
107
+ } ;
108
+
109
+ useHotkeys ( "mod+ArrowDown" , ( ) => handleScrollTo ( "bottom" ) , { enabled : ! isLoading } ) ;
110
+ useHotkeys ( "mod+ArrowUp" , ( ) => handleScrollTo ( "top" ) , { enabled : ! isLoading } ) ;
61
111
62
112
return (
63
- < Box display = "flex" flexDirection = "column" flexGrow = { 1 } h = "100%" minHeight = { 0 } >
113
+ < Box display = "flex" flexDirection = "column" flexGrow = { 1 } h = "100%" minHeight = { 0 } position = "relative" >
64
114
< ErrorAlert error = { error ?? logError } />
65
115
< ProgressBar size = "xs" visibility = { isLoading ? "visible" : "hidden" } />
66
116
< Code
@@ -90,6 +140,7 @@ export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }:
90
140
left : "auto" ,
91
141
right : 0 ,
92
142
} }
143
+ bgColor = { virtualRow . index === Number ( hash ) ? "blue.emphasized" : "transparent" }
93
144
data-index = { virtualRow . index }
94
145
data-testid = { `virtualized-item-${ virtualRow . index } ` }
95
146
key = { virtualRow . key }
@@ -104,6 +155,13 @@ export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap }:
104
155
) ) }
105
156
</ VStack >
106
157
</ Code >
158
+
159
+ { showScrollButtons ? (
160
+ < >
161
+ < ScrollToButton direction = "top" onClick = { ( ) => handleScrollTo ( "top" ) } />
162
+ < ScrollToButton direction = "bottom" onClick = { ( ) => handleScrollTo ( "bottom" ) } />
163
+ </ >
164
+ ) : undefined }
107
165
</ Box >
108
166
) ;
109
167
} ;
0 commit comments