88 :open.sync =" isModalOpen"
99 :current-entry.sync =" currentRow"
1010 :log-entries =" sortedRows" />
11- <table class =" log-table__table" >
12- <thead >
11+ <table ref = " tableRoot " class =" log-table__table" >
12+ <thead role = " rowgroup " class = " log-table__header " >
1313 <tr >
1414 <LogTableHeader :name =" t('logreader', 'Level')"
1515 :sorted.sync =" sortedByLevel" />
2323 <th ><span class =" hidden-visually" >{{ t('logreader', 'Log entry actions') }}</span ></th >
2424 </tr >
2525 </thead >
26- <tbody ref =" tableBody" >
27- <tr v-if =" sortedByTime === 'ascending'" >
28- <td colspan = " 5 " class = " log-table__load-more " >
26+ <tbody ref =" tableBody" :style = " tbodyStyle " class = " log-table__body " >
27+ <tr v-if =" sortedByTime === 'ascending'" class = " log-table__load-more " >
28+ <td >
2929 <IntersectionObserver v-if =" logStore.hasRemainingEntries" @intersection =" loadMore" >
3030 {{ t('logreader', 'Loading older log entries') }}
3131 </IntersectionObserver >
3535 </td >
3636 </tr >
3737
38- <LogTableRow v-for =" row, rowNumber in sortedRows "
39- :key =" rowNumber "
38+ <LogTableRow v-for =" row in renderedItems "
39+ :key =" row.id "
4040 :row =" row"
41+ class =" log-table__row"
4142 @show-details =" showDetailsForRow" />
4243 </tbody >
43- <tfoot >
44- <tr v-if =" sortedByTime !== 'ascending'" >
45- <td colspan = " 5 " class = " log-table__load-more " >
44+ <tfoot role = " rowgroup " class = " log-table__footer " >
45+ <tr v-if =" sortedByTime !== 'ascending'" class = " log-table__load-more " >
46+ <td >
4647 <IntersectionObserver v-if =" logStore.hasRemainingEntries" @intersection =" loadMore" >
4748 {{ t('logreader', 'Loading older log entries') }}
4849 </IntersectionObserver >
5960<script setup lang="ts">
6061import type { ILogEntry , ISortingOptions } from ' ../../interfaces'
6162
62- import { computed , nextTick , ref } from ' vue'
63+ import { computed , nextTick , onMounted , onBeforeUnmount , ref } from ' vue'
6364import { translate as t } from ' @nextcloud/l10n'
6465import { useSettingsStore } from ' ../../store/settings'
6566import { useLogStore } from ' ../../store/logging'
67+ import { debounce } from ' ../../utils/debounce'
68+ import { logger } from ' ../../utils/logger'
6669
6770import IntersectionObserver from ' ../IntersectionObserver.vue'
6871import LogDetailsModal from ' ../LogDetailsModal.vue'
6972import LogTableHeader from ' ./LogTableHeader.vue'
7073import LogTableRow from ' ./LogTableRow.vue'
7174import LogSearch from ' ../LogSearch.vue'
7275
76+ // Items to render before and after the visible area
77+ const bufferItems = 3
78+
7379const settingsStore = useSettingsStore ()
7480const logStore = useLogStore ()
7581
@@ -110,8 +116,9 @@ const showDetailsForRow = (row: ILogEntry) => {
110116}
111117
112118/**
113- * Reference to the table body , used for keeping scroll position on loading more entries
119+ * Reference to the table elements , used for keeping scroll position on loading more entries
114120 */
121+ const tableRoot = ref <HTMLElement >()
115122const tableBody = ref <HTMLElement >()
116123
117124/**
@@ -146,49 +153,131 @@ const sortedRows = computed(() => {
146153 sorted .sort ((a , b ) => order (byLevel , sortedByLevel .value , a , b ) || order (byApp , sortedByApp .value , a , b ) || order (byTime , sortedByTime .value , a , b ))
147154 return sorted
148155})
156+
157+ /**
158+ * Virtual scrolling logic
159+ */
160+ const resizeObserver = ref <ResizeObserver | null >(null )
161+
162+ const firstVisibleRowIndex = ref (0 )
163+ const startIndex = computed (() => Math .max (0 , firstVisibleRowIndex .value - bufferItems ))
164+
165+ const tableRootHeight = ref (0 )
166+ const tableHeadHeight = ref (44 )
167+ const tableRowHeight = ref (42 )
168+ const itemsInViewport = computed (() => Math .ceil ((tableRootHeight .value - tableHeadHeight .value ) / tableRowHeight .value ) + bufferItems * 2 )
169+
170+ const renderedItems = computed (() => sortedRows .value .slice (startIndex .value , startIndex .value + itemsInViewport .value ))
171+
172+ const tbodyStyle = computed (() => {
173+ const isOverScrolled = startIndex .value + itemsInViewport .value > sortedRows .value .length
174+ const lastIndex = sortedRows .value .length - startIndex .value - itemsInViewport .value
175+ const hiddenAfterItems = Math .min (sortedRows .value .length - startIndex .value , lastIndex )
176+
177+ return {
178+ paddingTop: ` ${startIndex .value * tableRowHeight .value }px ` ,
179+ paddingBottom: isOverScrolled ? 0 : ` ${hiddenAfterItems * tableRowHeight .value }px ` ,
180+ }
181+ })
182+
183+ onMounted (() => {
184+ resizeObserver .value = new ResizeObserver (debounce (() => {
185+ tableRootHeight .value = tableRoot .value ?.clientHeight ?? 0
186+ tableHeadHeight .value = tableRoot .value ?.querySelector (' thead.log-table__header' )?.clientHeight ?? 44
187+ tableRowHeight .value = tableRoot .value ?.querySelector (' tr.log-table__row:not(.expanded)' )?.clientHeight ?? 42
188+ logger .debug (' ResizeObserver for virtual list updated' , { rendered: renderedItems .value .length , total: filteredRows .value .length })
189+ onScroll ()
190+ }, 100 ))
191+
192+ resizeObserver .value .observe (tableRoot .value ! )
193+ tableRoot .value ! .addEventListener (' scroll' , onScroll )
194+ })
195+
196+ onBeforeUnmount (() => {
197+ if (resizeObserver .value ) {
198+ resizeObserver .value .disconnect ()
199+ }
200+ })
201+
202+ /**
203+ * Update the first visible row index on scroll (max 0 to prevent negative index)
204+ */
205+ function onScroll() {
206+ firstVisibleRowIndex .value = Math .max (0 , Math .round (tableRoot .value ! .scrollTop / tableRowHeight .value ))
207+ }
149208 </script >
150209
151210<style lang="scss" scoped>
152211.log-table {
153212 width : 100% ;
154213 height : 100% ;
155- overflow : scroll ;
214+ overflow : hidden ;
156215
157216 & __table {
158217 width : calc (100% - 12px );
159218 margin-inline : 6px ;
160219 table-layout : fixed ;
220+
221+ // Necessary for virtual scroll optimized rendering
222+ display : block ;
223+ overflow : auto ;
224+ height : 100% ;
225+ will-change : scroll-position ;
161226 }
162227
163228 & __load-more {
164- text-align : center ;
165- padding-block : 4px ;
166- }
229+ display : flex ;
167230
168- th , td {
169- // level column
170- & :nth-child (1 ) {
171- width : 108px ;
172- }
173- // app column
174- & :nth-child (2 ) {
175- width : 168px ;
231+ :deep (td ) {
232+ flex-basis : 100% ;
233+ text-align : center ;
234+ padding-block : 4px ;
176235 }
177- // message column
178- & :nth-child (3 ) {
179- width : 418px ;
180- }
181- // time column
182- & :nth-child (4 ) {
183- width : 168px ;
236+ }
237+
238+ & __header ,
239+ & __body ,
240+ & __footer {
241+ display : flex ;
242+ flex-direction : column ;
243+ width : 100% ;
244+
245+ :deep (tr ) {
246+ display : flex ;
184247 }
185- // actions column
186- & :last-child {
187- width : 62px ; // 44px button + 18px padding
248+
249+ :deep(th ),
250+ :deep(td ) {
251+ flex-shrink : 0 ;
252+
253+ // level column
254+ & :nth-child (1 ) {
255+ width : 108px ;
256+ }
257+ // app column
258+ & :nth-child (2 ) {
259+ width : 168px ;
260+ }
261+ // message column
262+ & :nth-child (3 ) {
263+ width : 418px ;
264+ flex-grow : 1 ;
265+ }
266+ // time column
267+ & :nth-child (4 ) {
268+ width : 25ch ; // "Mar 10, 2025, 12:00:00 PM" length
269+ }
270+ // actions column
271+ & :last-child {
272+ width : 62px ; // 44px button + 18px padding
273+ }
188274 }
189275 }
190276
191- thead {
277+ & __header {
278+ position : sticky ;
279+ top : 0 ;
280+ z-index : 1 ;
192281 min-height : 44px ;
193282
194283 :deep (th ) {
@@ -200,7 +289,7 @@ const sortedRows = computed(() => {
200289 }
201290 }
202291
203- tbody {
292+ & __body {
204293 // Some spacing for first row
205294 & :before {
206295 content : ' \200c ' ;
@@ -210,5 +299,8 @@ const sortedRows = computed(() => {
210299 }
211300 }
212301
302+ & __row {
303+ min-height : 42px ;
304+ }
213305}
214306 </style >
0 commit comments