mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
fix: virtualize job queue history list
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user