fix: virtualize job queue history list

This commit is contained in:
Benjamin Lu
2026-03-26 18:47:13 -07:00
parent 2e86cb2759
commit 3709d56825
5 changed files with 251 additions and 91 deletions

View File

@@ -20,8 +20,9 @@
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="min-h-0 flex-1">
<JobAssetsList
class="h-full"
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"

View File

@@ -8,6 +8,30 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
const vue = await import('vue')
return {
...actual,
useVirtualList: (rows: unknown) => ({
list: vue.computed(() =>
vue.unref(rows as readonly unknown[]).map((data: unknown, index) => ({
data,
index
}))
),
scrollTo: vi.fn(),
containerProps: {
ref: vue.ref(null),
onScroll: vi.fn(),
style: {}
},
wrapperProps: vue.computed(() => ({ style: {} }))
})
}
})
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -122,6 +146,36 @@ afterEach(() => {
})
describe('JobAssetsList', () => {
it('renders grouped headers alongside job rows', () => {
const wrapper = mount(JobAssetsList, {
props: {
displayedJobGroups: [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
}
]
},
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
expect(wrapper.text()).toContain('Today')
expect(wrapper.text()).toContain('Yesterday')
expect(wrapper.find('[data-job-id="job-1"]').exists()).toBe(true)
expect(wrapper.find('[data-job-id="job-2"]').exists()).toBe(true)
})
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])

View File

@@ -1,79 +1,91 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<div
v-for="job in group.items"
:key="job.id"
:data-job-id="job.id"
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
<div
:ref="containerProps.ref"
:style="containerProps.style"
class="h-full overflow-y-auto"
@scroll="onListScroll"
>
<div :style="virtualWrapperStyle">
<template v-for="{ data: row } in virtualRows" :key="row.key">
<div
v-if="row.type === 'header'"
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
:style="{ height: `${row.height}px` }"
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
{{ row.label }}
</div>
<div
v-else-if="row.type === 'job'"
class="box-border px-3"
:style="{ height: `${row.height}px` }"
>
<div
:data-job-id="row.job.id"
class="h-12"
@mouseenter="onJobEnter(row.job, $event)"
@mouseleave="onJobLeave(row.job.id)"
>
<AssetsListItem
:class="
cn(
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
row.job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(row.job)"
:is-video-preview="isVideoPreviewJob(row.job)"
:preview-alt="row.job.title"
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
:icon-class="getJobIconClass(row.job)"
:primary-text="row.job.title"
:secondary-text="row.job.meta"
:progress-total-percent="row.job.progressTotalPercent"
:progress-current-percent="row.job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
@dblclick.stop="emitViewItem(row.job)"
@preview-click="emitViewItem(row.job)"
@click.stop
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<template v-if="hoveredJobId === row.job.id" #actions>
<Button
v-if="isCancelable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(row.job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(row.job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="row.job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', row.job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
</div>
</div>
@@ -97,8 +109,9 @@
</template>
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
@@ -110,6 +123,24 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
const HEADER_ROW_HEIGHT = 20
const GROUP_ROW_GAP = 16
const JOB_ROW_HEIGHT = 48
type JobListRow =
| {
key: string
type: 'header'
label: string
height: number
}
| {
key: string
type: 'job'
job: JobListItem
height: number
}
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
@@ -123,6 +154,47 @@ const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const flatRows = computed<JobListRow[]>(() => {
const rows: JobListRow[] = []
const lastGroupIndex = displayedJobGroups.length - 1
displayedJobGroups.forEach((group, groupIndex) => {
rows.push({
key: `header-${group.key}`,
type: 'header',
label: group.label,
height: HEADER_ROW_HEIGHT
})
group.items.forEach((job, jobIndex) => {
const isLastJobInGroup = jobIndex === group.items.length - 1
const isLastGroup = groupIndex === lastGroupIndex
rows.push({
key: `job-${job.id}`,
type: 'job',
job,
height:
JOB_ROW_HEIGHT +
(isLastJobInGroup && !isLastGroup ? GROUP_ROW_GAP : 0)
})
})
})
return rows
})
const {
list: virtualRows,
containerProps,
wrapperProps
} = useVirtualList(flatRows, {
itemHeight: (index) => flatRows.value[index]?.height ?? JOB_ROW_HEIGHT,
overscan: 12
})
const virtualWrapperStyle = computed(() => ({
...wrapperProps.value.style,
width: '100%',
paddingBottom: flatRows.value.length > 0 ? '16px' : undefined
}))
const {
activeDetails,
clearHoverTimers,
@@ -135,6 +207,12 @@ const {
onReset: clearPopoverAnchor
})
function onListScroll() {
containerProps.onScroll()
hoveredJobId.value = null
resetActiveDetails()
}
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null

View File

@@ -5,6 +5,30 @@ import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
const vue = await import('vue')
return {
...actual,
useVirtualList: (rows: unknown) => ({
list: vue.computed(() =>
vue.unref(rows as readonly unknown[]).map((data: unknown, index) => ({
data,
index
}))
),
scrollTo: vi.fn(),
containerProps: {
ref: vue.ref(null),
onScroll: vi.fn(),
style: {}
},
wrapperProps: vue.computed(() => ({ style: {} }))
})
}
})
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {

View File

@@ -46,22 +46,25 @@
</div>
</template>
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<div class="flex h-full min-h-0 flex-col">
<JobAssetsList
class="min-h-0 flex-1"
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</div>
</template>
</SidebarTabTemplate>
</template>