mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 05:49:54 +00:00
[Manager] Auto scroll logs terminal to bottom (#3141)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
>
|
||||
<div v-for="(panel, index) in taskPanels" :key="index">
|
||||
<Panel
|
||||
:expanded="expandedPanels[index] || false"
|
||||
:expanded="collapsedPanels[index] || false"
|
||||
toggleable
|
||||
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
|
||||
>
|
||||
@@ -28,11 +28,11 @@
|
||||
<div class="flex items-center justify-between w-full py-2">
|
||||
<div class="flex flex-col text-sm font-medium leading-normal">
|
||||
<span>{{ panel.taskName }}</span>
|
||||
<span v-show="expandedPanels[index]" class="text-muted">
|
||||
<span class="text-muted">
|
||||
{{
|
||||
index === taskPanels.length - 1
|
||||
? 'In progress'
|
||||
: 'Completed ✓'
|
||||
isInProgress(index)
|
||||
? $t('g.inProgress')
|
||||
: $t('g.completed') + ' ✓'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -41,9 +41,9 @@
|
||||
<template #toggleicon>
|
||||
<Button
|
||||
:icon="
|
||||
expandedPanels[index]
|
||||
? 'pi pi-chevron-down'
|
||||
: 'pi pi-chevron-right'
|
||||
collapsedPanels[index]
|
||||
? 'pi pi-chevron-right'
|
||||
: 'pi pi-chevron-down'
|
||||
"
|
||||
text
|
||||
class="text-neutral-300"
|
||||
@@ -51,11 +51,17 @@
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
:ref="
|
||||
index === taskPanels.length - 1
|
||||
? (el) => (lastPanelRef = el as HTMLElement)
|
||||
: undefined
|
||||
"
|
||||
class="overflow-y-auto h-64 rounded-lg bg-black"
|
||||
:class="{
|
||||
'h-64': index !== taskPanels.length - 1,
|
||||
'flex-grow': index === taskPanels.length - 1
|
||||
}"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="h-full">
|
||||
<div
|
||||
@@ -86,26 +92,68 @@ import {
|
||||
|
||||
const { taskLogs } = useComfyManagerStore()
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isInProgress = (index: number) =>
|
||||
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
|
||||
|
||||
const taskPanels = computed(() => taskLogs)
|
||||
const isExpanded = computed(() => progressDialogContent.isExpanded)
|
||||
const isCollapsed = computed(() => !isExpanded.value)
|
||||
|
||||
const expandedPanels = ref<Record<number, boolean>>({})
|
||||
const collapsedPanels = ref<Record<number, boolean>>({})
|
||||
const togglePanel = (index: number) => {
|
||||
expandedPanels.value[index] = !expandedPanels.value[index]
|
||||
collapsedPanels.value[index] = !collapsedPanels.value[index]
|
||||
}
|
||||
|
||||
const sectionsContainerRef = ref<HTMLElement | null>(null)
|
||||
const { y: scrollY } = useScroll(sectionsContainerRef)
|
||||
const { y: scrollY } = useScroll(sectionsContainerRef, {
|
||||
eventListenerOptions: {
|
||||
passive: true
|
||||
}
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const lastPanelRef = ref<HTMLElement | null>(null)
|
||||
const isUserScrolling = ref(false)
|
||||
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
|
||||
|
||||
const isAtBottom = (el: HTMLElement | null) => {
|
||||
if (!el) return false
|
||||
const threshold = 20
|
||||
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
|
||||
}
|
||||
|
||||
const scrollLastPanelToBottom = () => {
|
||||
if (!lastPanelRef.value || isUserScrolling.value) return
|
||||
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
|
||||
}
|
||||
const scrollContentToBottom = () => {
|
||||
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
|
||||
}
|
||||
|
||||
whenever(() => isExpanded.value, scrollToBottom)
|
||||
const resetUserScrolling = () => {
|
||||
isUserScrolling.value = false
|
||||
}
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target !== lastPanelRef.value) return
|
||||
|
||||
isUserScrolling.value = !isAtBottom(target)
|
||||
}
|
||||
|
||||
const onLogsAdded = () => {
|
||||
// If user is scrolling manually, don't automatically scroll to bottom
|
||||
if (isUserScrolling.value) return
|
||||
|
||||
scrollLastPanelToBottom()
|
||||
}
|
||||
|
||||
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
|
||||
whenever(() => isExpanded.value, scrollContentToBottom)
|
||||
whenever(isCollapsed, resetUserScrolling)
|
||||
|
||||
onMounted(() => {
|
||||
expandedPanels.value = {}
|
||||
scrollToBottom()
|
||||
scrollContentToBottom()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Panel from 'primevue/panel'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
|
||||
lastPanelRef: HTMLElement | null
|
||||
onLogsAdded: () => void
|
||||
handleScroll: (e: { target: HTMLElement }) => void
|
||||
isUserScrolling: boolean
|
||||
resetUserScrolling: () => void
|
||||
collapsedPanels: Record<number, boolean>
|
||||
togglePanel: (index: number) => void
|
||||
}
|
||||
|
||||
const mockCollapse = vi.fn()
|
||||
|
||||
const defaultMockTaskLogs = [
|
||||
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
|
||||
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
|
||||
]
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
taskLogs: [...defaultMockTaskLogs]
|
||||
})),
|
||||
useManagerProgressDialogStore: vi.fn(() => ({
|
||||
isExpanded: true,
|
||||
collapse: mockCollapse
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('ManagerProgressDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCollapse.mockReset()
|
||||
})
|
||||
|
||||
const mountComponent = ({
|
||||
props = {}
|
||||
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(ManagerProgressDialogContent, {
|
||||
props: {
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
components: {
|
||||
Panel,
|
||||
Button
|
||||
}
|
||||
}
|
||||
}) as VueWrapper<ComponentInstance>
|
||||
}
|
||||
|
||||
it('renders the correct number of panels', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
expect(wrapper.findAllComponents(Panel).length).toBe(2)
|
||||
})
|
||||
|
||||
it('expands the last panel by default', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
|
||||
})
|
||||
|
||||
it('toggles panel expansion when toggle method is called', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
// Initial state - first panel should be collapsed
|
||||
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
|
||||
|
||||
wrapper.vm.togglePanel(0)
|
||||
await nextTick()
|
||||
|
||||
// After toggle - first panel should be expanded
|
||||
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
|
||||
|
||||
wrapper.vm.togglePanel(0)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
|
||||
})
|
||||
|
||||
it('displays the correct status for each panel', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
// Expand all panels to see status text
|
||||
const panels = wrapper.findAllComponents(Panel)
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (!wrapper.vm.collapsedPanels[i]) {
|
||||
wrapper.vm.togglePanel(i)
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
const panelsText = wrapper
|
||||
.findAllComponents(Panel)
|
||||
.map((panel) => panel.text())
|
||||
|
||||
expect(panelsText[0]).toContain('Completed ✓')
|
||||
expect(panelsText[1]).toContain('Completed ✓')
|
||||
})
|
||||
|
||||
it('auto-scrolls to bottom when new logs are added', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
const mockScrollElement = document.createElement('div')
|
||||
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
|
||||
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
|
||||
Object.defineProperty(mockScrollElement, 'scrollTop', {
|
||||
value: 0,
|
||||
writable: true
|
||||
})
|
||||
|
||||
wrapper.vm.lastPanelRef = mockScrollElement
|
||||
|
||||
wrapper.vm.onLogsAdded()
|
||||
await nextTick()
|
||||
|
||||
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
|
||||
expect(mockScrollElement.scrollTop).toBe(200)
|
||||
})
|
||||
|
||||
it('does not auto-scroll when user is manually scrolling', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
|
||||
const mockScrollElement = document.createElement('div')
|
||||
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
|
||||
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
|
||||
Object.defineProperty(mockScrollElement, 'scrollTop', {
|
||||
value: 50,
|
||||
writable: true
|
||||
})
|
||||
|
||||
wrapper.vm.lastPanelRef = mockScrollElement
|
||||
|
||||
wrapper.vm.handleScroll({ target: mockScrollElement })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.isUserScrolling).toBe(true)
|
||||
|
||||
// Now trigger the log update
|
||||
wrapper.vm.onLogsAdded()
|
||||
await nextTick()
|
||||
|
||||
// Check that scrollTop is not changed (should still be 50)
|
||||
expect(mockScrollElement.scrollTop).toBe(50)
|
||||
})
|
||||
|
||||
it('calls collapse method when component is unmounted', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await nextTick()
|
||||
wrapper.unmount()
|
||||
expect(mockCollapse).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -96,7 +96,9 @@
|
||||
"enabled": "Enabled",
|
||||
"installed": "Installed",
|
||||
"restart": "Restart",
|
||||
"missing": "Missing"
|
||||
"missing": "Missing",
|
||||
"inProgress": "In progress",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"comingSoon": "Bientôt disponible",
|
||||
"command": "Commande",
|
||||
"community": "Communauté",
|
||||
"completed": "Terminé",
|
||||
"confirm": "Confirmer",
|
||||
"continue": "Continuer",
|
||||
"control_after_generate": "contrôle après génération",
|
||||
@@ -157,6 +158,7 @@
|
||||
"icon": "Icône",
|
||||
"imageFailedToLoad": "Échec du chargement de l'image",
|
||||
"import": "Importer",
|
||||
"inProgress": "En cours",
|
||||
"insert": "Insérer",
|
||||
"install": "Installer",
|
||||
"installed": "Installé",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"comingSoon": "近日公開",
|
||||
"command": "コマンド",
|
||||
"community": "コミュニティ",
|
||||
"completed": "完了",
|
||||
"confirm": "確認",
|
||||
"continue": "続ける",
|
||||
"control_after_generate": "生成後の制御",
|
||||
@@ -157,6 +158,7 @@
|
||||
"icon": "アイコン",
|
||||
"imageFailedToLoad": "画像の読み込みに失敗しました",
|
||||
"import": "インポート",
|
||||
"inProgress": "進行中",
|
||||
"insert": "挿入",
|
||||
"install": "インストール",
|
||||
"installed": "インストール済み",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"comingSoon": "곧 출시 예정",
|
||||
"command": "명령",
|
||||
"community": "커뮤니티",
|
||||
"completed": "완료됨",
|
||||
"confirm": "확인",
|
||||
"continue": "계속",
|
||||
"control_after_generate": "생성 후 제어",
|
||||
@@ -157,6 +158,7 @@
|
||||
"icon": "아이콘",
|
||||
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
|
||||
"import": "가져오기",
|
||||
"inProgress": "진행 중",
|
||||
"insert": "삽입",
|
||||
"install": "설치",
|
||||
"installed": "설치됨",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"comingSoon": "Скоро будет",
|
||||
"command": "Команда",
|
||||
"community": "Сообщество",
|
||||
"completed": "Завершено",
|
||||
"confirm": "Подтвердить",
|
||||
"continue": "Продолжить",
|
||||
"control_after_generate": "управление после генерации",
|
||||
@@ -157,6 +158,7 @@
|
||||
"icon": "Иконка",
|
||||
"imageFailedToLoad": "Не удалось загрузить изображение",
|
||||
"import": "Импорт",
|
||||
"inProgress": "В процессе",
|
||||
"insert": "Вставить",
|
||||
"install": "Установить",
|
||||
"installed": "Установлено",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"comingSoon": "即将推出",
|
||||
"command": "指令",
|
||||
"community": "社区",
|
||||
"completed": "已完成",
|
||||
"confirm": "确认",
|
||||
"continue": "继续",
|
||||
"control_after_generate": "生成后控制",
|
||||
@@ -157,6 +158,7 @@
|
||||
"icon": "图标",
|
||||
"imageFailedToLoad": "图像加载失败",
|
||||
"import": "导入",
|
||||
"inProgress": "进行中",
|
||||
"insert": "插入",
|
||||
"install": "安装",
|
||||
"installed": "已安装",
|
||||
|
||||
Reference in New Issue
Block a user