mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
[backport cloud/1.36] feat: add HoneyToast component for persistent progress notifications (#7918)
Backport of #7902 to cloud/1.36
Original PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7902
Cherry-picked merge commit e26e1f0c9e.
## Conflicts resolved
- **pnpm-lock.yaml**: Regenerated with `pnpm install`
-
**tests-ui/tests/components/dialog/footer/ManagerProgressFooter.test.ts**:
Removed (PR deletes this file along with the component it tested)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7918-backport-cloud-1-36-feat-add-HoneyToast-component-for-persistent-progress-notification-2e36d73d3650811a9f57f26c56b84c97)
by [Unito](https://www.unito.io)
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
committed by
GitHub
parent
e912b42fff
commit
b9e6f3d9fa
@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
|
|||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||||
addons: ['@storybook/addon-docs'],
|
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
|
||||||
framework: {
|
framework: {
|
||||||
name: '@storybook/vue3-vite',
|
name: '@storybook/vue3-vite',
|
||||||
options: {}
|
options: {}
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ The project uses **Nx** for build orchestration and task management
|
|||||||
- Prefer reactive props destructuring to `const props = defineProps<...>`
|
- Prefer reactive props destructuring to `const props = defineProps<...>`
|
||||||
- Do not use `withDefaults` or runtime props declaration
|
- Do not use `withDefaults` or runtime props declaration
|
||||||
- Do not import Vue macros unnecessarily
|
- Do not import Vue macros unnecessarily
|
||||||
- Prefer `useModel` to separately defining a prop and emit
|
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
|
||||||
|
- Define slots via template usage, not `defineSlots`
|
||||||
|
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
|
||||||
|
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
|
||||||
- Be judicious with addition of new refs or other state
|
- Be judicious with addition of new refs or other state
|
||||||
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||||
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||||
@@ -271,6 +274,8 @@ When referencing Comfy-Org repos:
|
|||||||
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
||||||
- NEVER use `!important` or the `!` important prefix for tailwind classes
|
- NEVER use `!important` or the `!` important prefix for tailwind classes
|
||||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||||
|
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||||
|
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||||
|
|
||||||
## Agent-only rules
|
## Agent-only rules
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"@prettier/plugin-oxc": "catalog:",
|
"@prettier/plugin-oxc": "catalog:",
|
||||||
"@sentry/vite-plugin": "catalog:",
|
"@sentry/vite-plugin": "catalog:",
|
||||||
"@storybook/addon-docs": "catalog:",
|
"@storybook/addon-docs": "catalog:",
|
||||||
|
"@storybook/addon-mcp": "catalog:",
|
||||||
"@storybook/vue3": "catalog:",
|
"@storybook/vue3": "catalog:",
|
||||||
"@storybook/vue3-vite": "catalog:",
|
"@storybook/vue3-vite": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
|
|||||||
443
pnpm-lock.yaml
generated
443
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ catalog:
|
|||||||
'@sentry/vue': ^8.48.0
|
'@sentry/vue': ^8.48.0
|
||||||
'@sparkjsdev/spark': ^0.1.10
|
'@sparkjsdev/spark': ^0.1.10
|
||||||
'@storybook/addon-docs': ^10.1.9
|
'@storybook/addon-docs': ^10.1.9
|
||||||
|
'@storybook/addon-mcp': 0.1.6
|
||||||
'@storybook/vue3': ^10.1.9
|
'@storybook/vue3': ^10.1.9
|
||||||
'@storybook/vue3-vite': ^10.1.9
|
'@storybook/vue3-vite': ^10.1.9
|
||||||
'@tailwindcss/vite': ^4.1.12
|
'@tailwindcss/vite': ^4.1.12
|
||||||
|
|||||||
292
src/components/honeyToast/HoneyToast.stories.ts
Normal file
292
src/components/honeyToast/HoneyToast.stories.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import HoneyToast from './HoneyToast.vue'
|
||||||
|
|
||||||
|
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||||
|
return {
|
||||||
|
taskId: 'task-1',
|
||||||
|
assetId: 'asset-1',
|
||||||
|
assetName: 'model-v1.safetensors',
|
||||||
|
bytesTotal: 1000000,
|
||||||
|
bytesDownloaded: 0,
|
||||||
|
progress: 0,
|
||||||
|
status: 'created',
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof HoneyToast> = {
|
||||||
|
title: 'Toast/HoneyToast',
|
||||||
|
component: HoneyToast,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen'
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { HoneyToast, Button, ProgressToastItem },
|
||||||
|
setup() {
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const jobs = [
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-1',
|
||||||
|
assetName: 'model-v1.safetensors',
|
||||||
|
status: 'completed',
|
||||||
|
progress: 1
|
||||||
|
}),
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-2',
|
||||||
|
assetName: 'lora-style.safetensors',
|
||||||
|
status: 'running',
|
||||||
|
progress: 0.45
|
||||||
|
}),
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-3',
|
||||||
|
assetName: 'vae-decoder.safetensors',
|
||||||
|
status: 'created'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
return { isExpanded, cn, jobs }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||||
|
<template #default>
|
||||||
|
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||||
|
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ toggle }">
|
||||||
|
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||||
|
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||||
|
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Expanded: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { HoneyToast, Button, ProgressToastItem },
|
||||||
|
setup() {
|
||||||
|
const isExpanded = ref(true)
|
||||||
|
const jobs = [
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-1',
|
||||||
|
assetName: 'model-v1.safetensors',
|
||||||
|
status: 'completed',
|
||||||
|
progress: 1
|
||||||
|
}),
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-2',
|
||||||
|
assetName: 'lora-style.safetensors',
|
||||||
|
status: 'running',
|
||||||
|
progress: 0.45
|
||||||
|
}),
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-3',
|
||||||
|
assetName: 'vae-decoder.safetensors',
|
||||||
|
status: 'created'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
return { isExpanded, cn, jobs }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||||
|
<template #default>
|
||||||
|
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||||
|
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ toggle }">
|
||||||
|
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||||
|
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||||
|
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Completed: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { HoneyToast, Button, ProgressToastItem },
|
||||||
|
setup() {
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const jobs = [
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-1',
|
||||||
|
assetName: 'model-v1.safetensors',
|
||||||
|
bytesDownloaded: 1000000,
|
||||||
|
progress: 1,
|
||||||
|
status: 'completed'
|
||||||
|
}),
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-2',
|
||||||
|
assetId: 'asset-2',
|
||||||
|
assetName: 'lora-style.safetensors',
|
||||||
|
bytesTotal: 500000,
|
||||||
|
bytesDownloaded: 500000,
|
||||||
|
progress: 1,
|
||||||
|
status: 'completed'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
return { isExpanded, cn, jobs }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||||
|
<template #default>
|
||||||
|
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||||
|
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ toggle }">
|
||||||
|
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
|
||||||
|
<span class="font-bold text-base-foreground">All downloads completed</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||||
|
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="muted-textonly" size="icon">
|
||||||
|
<i class="icon-[lucide--x] size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { HoneyToast, Button, ProgressToastItem },
|
||||||
|
setup() {
|
||||||
|
const isExpanded = ref(true)
|
||||||
|
const jobs = [
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-1',
|
||||||
|
assetName: 'model-v1.safetensors',
|
||||||
|
status: 'failed',
|
||||||
|
progress: 0.23
|
||||||
|
}),
|
||||||
|
createMockJob({
|
||||||
|
taskId: 'task-2',
|
||||||
|
assetName: 'lora-style.safetensors',
|
||||||
|
status: 'completed',
|
||||||
|
progress: 1
|
||||||
|
})
|
||||||
|
]
|
||||||
|
return { isExpanded, cn, jobs }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||||
|
<template #default>
|
||||||
|
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||||
|
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ toggle }">
|
||||||
|
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
|
||||||
|
<span class="font-bold text-base-foreground">1 download failed</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||||
|
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="muted-textonly" size="icon">
|
||||||
|
<i class="icon-[lucide--x] size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Hidden: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { HoneyToast },
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
|
||||||
|
|
||||||
|
<HoneyToast :visible="false">
|
||||||
|
<template #default>
|
||||||
|
<div class="px-4 py-4">Content</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="h-12 px-4">Footer</div>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import HoneyToast from './HoneyToast.vue'
|
||||||
|
|
||||||
|
describe('HoneyToast', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountComponent(
|
||||||
|
props: { visible: boolean; expanded?: boolean } = { visible: true }
|
||||||
|
): VueWrapper {
|
||||||
|
return mount(HoneyToast, {
|
||||||
|
props,
|
||||||
|
slots: {
|
||||||
|
default: (slotProps: { isExpanded: boolean }) =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ 'data-testid': 'content' },
|
||||||
|
slotProps.isExpanded ? 'expanded' : 'collapsed'
|
||||||
|
),
|
||||||
|
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
'data-testid': 'toggle-btn',
|
||||||
|
onClick: slotProps.toggle
|
||||||
|
},
|
||||||
|
slotProps.isExpanded ? 'Collapse' : 'Expand'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
attachTo: document.body
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders when visible is true', async () => {
|
||||||
|
const wrapper = mountComponent({ visible: true })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const toast = document.body.querySelector('[role="status"]')
|
||||||
|
expect(toast).toBeTruthy()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when visible is false', async () => {
|
||||||
|
const wrapper = mountComponent({ visible: false })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const toast = document.body.querySelector('[role="status"]')
|
||||||
|
expect(toast).toBeFalsy()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes is-expanded=false to slots by default', async () => {
|
||||||
|
const wrapper = mountComponent({ visible: true })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const content = document.body.querySelector('[data-testid="content"]')
|
||||||
|
expect(content?.textContent).toBe('collapsed')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies collapsed max-height class when collapsed', async () => {
|
||||||
|
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const expandableArea = document.body.querySelector(
|
||||||
|
'[role="status"] > div:first-child'
|
||||||
|
)
|
||||||
|
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has aria-live="polite" for accessibility', async () => {
|
||||||
|
const wrapper = mountComponent({ visible: true })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const toast = document.body.querySelector('[role="status"]')
|
||||||
|
expect(toast?.getAttribute('aria-live')).toBe('polite')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports v-model:expanded with reactive parent state', async () => {
|
||||||
|
const TestWrapper = defineComponent({
|
||||||
|
components: { HoneyToast },
|
||||||
|
setup() {
|
||||||
|
const expanded = ref(false)
|
||||||
|
return { expanded }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<HoneyToast :visible="true" v-model:expanded="expanded">
|
||||||
|
<template #default="slotProps">
|
||||||
|
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
|
||||||
|
</template>
|
||||||
|
<template #footer="slotProps">
|
||||||
|
<button data-testid="toggle-btn" @click="slotProps.toggle">
|
||||||
|
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(TestWrapper, { attachTo: document.body })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const content = document.body.querySelector('[data-testid="content"]')
|
||||||
|
expect(content?.textContent).toBe('collapsed')
|
||||||
|
|
||||||
|
const toggleBtn = document.body.querySelector(
|
||||||
|
'[data-testid="toggle-btn"]'
|
||||||
|
) as HTMLButtonElement
|
||||||
|
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
|
||||||
|
|
||||||
|
toggleBtn?.click()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(content?.textContent).toBe('expanded')
|
||||||
|
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||||
|
|
||||||
|
const expandableArea = document.body.querySelector(
|
||||||
|
'[role="status"] > div:first-child'
|
||||||
|
)
|
||||||
|
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
46
src/components/honeyToast/HoneyToast.vue
Normal file
46
src/components/honeyToast/HoneyToast.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { visible } = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isExpanded = defineModel<boolean>('expanded', { default: false })
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isExpanded.value = !isExpanded.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="translate-y-full opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-full opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'overflow-hidden transition-all duration-300',
|
||||||
|
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot :is-expanded />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="footer" :is-expanded :toggle />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
94
src/components/toast/ProgressToastItem.stories.ts
Normal file
94
src/components/toast/ProgressToastItem.stories.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||||
|
|
||||||
|
import ProgressToastItem from './ProgressToastItem.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof ProgressToastItem> = {
|
||||||
|
title: 'Toast/ProgressToastItem',
|
||||||
|
component: ProgressToastItem,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded'
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
() => ({
|
||||||
|
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||||
|
return {
|
||||||
|
taskId: 'task-1',
|
||||||
|
assetId: 'asset-1',
|
||||||
|
assetName: 'model-v1.safetensors',
|
||||||
|
bytesTotal: 1000000,
|
||||||
|
bytesDownloaded: 0,
|
||||||
|
progress: 0,
|
||||||
|
status: 'created',
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Pending: Story = {
|
||||||
|
args: {
|
||||||
|
job: createMockJob({
|
||||||
|
status: 'created',
|
||||||
|
assetName: 'sd-xl-base-1.0.safetensors'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Running: Story = {
|
||||||
|
args: {
|
||||||
|
job: createMockJob({
|
||||||
|
status: 'running',
|
||||||
|
progress: 0.45,
|
||||||
|
assetName: 'lora-detail-enhancer.safetensors'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RunningAlmostComplete: Story = {
|
||||||
|
args: {
|
||||||
|
job: createMockJob({
|
||||||
|
status: 'running',
|
||||||
|
progress: 0.92,
|
||||||
|
assetName: 'vae-ft-mse-840000.safetensors'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Completed: Story = {
|
||||||
|
args: {
|
||||||
|
job: createMockJob({
|
||||||
|
status: 'completed',
|
||||||
|
progress: 1,
|
||||||
|
assetName: 'controlnet-canny.safetensors'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Failed: Story = {
|
||||||
|
args: {
|
||||||
|
job: createMockJob({
|
||||||
|
status: 'failed',
|
||||||
|
progress: 0.23,
|
||||||
|
assetName: 'unreachable-model.safetensors'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LongFileName: Story = {
|
||||||
|
args: {
|
||||||
|
job: createMockJob({
|
||||||
|
status: 'running',
|
||||||
|
progress: 0.67,
|
||||||
|
assetName:
|
||||||
|
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,9 +30,7 @@ const isPending = computed(() => job.status === 'created')
|
|||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm text-base-foreground">{{ job.assetName }}</span>
|
<span class="text-sm text-base-foreground">{{ job.assetName }}</span>
|
||||||
<span v-if="isRunning" class="text-xs text-muted-foreground">
|
<span v-if="isRunning" class="text-xs text-muted-foreground"> </span>
|
||||||
{{ progressPercent }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -49,9 +47,9 @@ const isPending = computed(() => job.status === 'created')
|
|||||||
|
|
||||||
<template v-else-if="isRunning">
|
<template v-else-if="isRunning">
|
||||||
<i
|
<i
|
||||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-primary-background"
|
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-primary-background">
|
<span class="text-xs text-base-foreground">
|
||||||
{{ progressPercent }}%
|
{{ progressPercent }}%
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -881,15 +881,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
|
||||||
icon: 'pi pi-spinner',
|
|
||||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
|
||||||
versionAdded: '1.13.9',
|
|
||||||
function: () => {
|
|
||||||
dialogService.toggleManagerProgressDialog()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'Comfy.User.OpenSignInDialog',
|
id: 'Comfy.User.OpenSignInDialog',
|
||||||
icon: 'pi pi-user',
|
icon: 'pi pi-user',
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { whenever } from '@vueuse/core'
|
||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||||
@@ -17,12 +19,10 @@ const isExpanded = ref(false)
|
|||||||
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
|
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
|
||||||
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
|
||||||
function toggle() {
|
whenever(
|
||||||
isExpanded.value = !isExpanded.value
|
() => !isExpanded.value,
|
||||||
if (!isExpanded.value) {
|
() => filterPopoverRef.value?.hide()
|
||||||
filterPopoverRef.value?.hide()
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ value: 'all', label: 'all' },
|
{ value: 'all', label: 'all' },
|
||||||
@@ -83,189 +83,168 @@ function closeDialog() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<HoneyToast v-model:expanded="isExpanded" :visible>
|
||||||
<Transition
|
<template #default>
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="translate-y-full opacity-0"
|
|
||||||
enter-to-class="translate-y-0 opacity-100"
|
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
|
||||||
leave-from-class="translate-y-0 opacity-100"
|
|
||||||
leave-to-class="translate-y-full opacity-0"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="visible"
|
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||||
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-[80%] max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
|
||||||
>
|
>
|
||||||
<div
|
<h3 class="text-sm font-bold text-base-foreground">
|
||||||
:class="
|
{{ t('progressToast.importingModels') }}
|
||||||
cn(
|
</h3>
|
||||||
'overflow-hidden transition-all duration-300',
|
<div class="flex items-center gap-2">
|
||||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
<Button
|
||||||
)
|
variant="secondary"
|
||||||
"
|
size="md"
|
||||||
>
|
class="gap-1.5 px-2"
|
||||||
<div
|
@click="onFilterClick"
|
||||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
>
|
||||||
|
<i class="icon-[lucide--list-filter] size-4" />
|
||||||
|
<span>{{ activeFilterLabel }}</span>
|
||||||
|
<i class="icon-[lucide--chevron-down] size-3" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
ref="filterPopoverRef"
|
||||||
|
append-to="body"
|
||||||
|
:dismissable="true"
|
||||||
|
:close-on-escape="true"
|
||||||
|
unstyled
|
||||||
|
:pt="{
|
||||||
|
root: { class: 'absolute z-50' },
|
||||||
|
content: {
|
||||||
|
class:
|
||||||
|
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
|
||||||
|
}
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<h3 class="text-sm font-bold text-base-foreground">
|
|
||||||
{{ t('progressToast.importingModels') }}
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
class="gap-1.5 px-2"
|
|
||||||
@click="onFilterClick"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--list-filter] size-4" />
|
|
||||||
<span>{{ activeFilterLabel }}</span>
|
|
||||||
<i class="icon-[lucide--chevron-down] size-3" />
|
|
||||||
</Button>
|
|
||||||
<Popover
|
|
||||||
ref="filterPopoverRef"
|
|
||||||
append-to="body"
|
|
||||||
:dismissable="true"
|
|
||||||
:close-on-escape="true"
|
|
||||||
unstyled
|
|
||||||
:pt="{
|
|
||||||
root: { class: 'absolute z-50' },
|
|
||||||
content: {
|
|
||||||
class:
|
|
||||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex min-w-[120px] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
v-for="option in filterOptions"
|
|
||||||
:key="option.value"
|
|
||||||
variant="textonly"
|
|
||||||
size="sm"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'w-full justify-start bg-transparent',
|
|
||||||
activeFilter === option.value &&
|
|
||||||
'bg-secondary-background-selected'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click="setFilter(option.value)"
|
|
||||||
>
|
|
||||||
{{ t(`progressToast.filter.${option.label}`) }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
|
||||||
<div
|
<div
|
||||||
v-if="filteredJobs.length > 3"
|
class="flex min-w-30 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||||
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<ProgressToastItem
|
|
||||||
v-for="job in filteredJobs"
|
|
||||||
:key="job.taskId"
|
|
||||||
:job="job"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="filteredJobs.length === 0"
|
|
||||||
class="flex flex-col items-center justify-center py-6 text-center"
|
|
||||||
>
|
>
|
||||||
<span class="text-sm text-muted-foreground">
|
<Button
|
||||||
{{
|
v-for="option in filterOptions"
|
||||||
t('progressToast.noImportsInQueue', {
|
:key="option.value"
|
||||||
filter: activeFilterLabel
|
variant="textonly"
|
||||||
})
|
size="sm"
|
||||||
}}
|
:class="
|
||||||
</span>
|
cn(
|
||||||
|
'w-full justify-start bg-transparent',
|
||||||
|
activeFilter === option.value &&
|
||||||
|
'bg-secondary-background-selected'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="setFilter(option.value)"
|
||||||
|
>
|
||||||
|
{{ t(`progressToast.filter.${option.label}`) }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
|
||||||
|
<div
|
||||||
|
v-if="filteredJobs.length > 3"
|
||||||
|
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ProgressToastItem
|
||||||
|
v-for="job in filteredJobs"
|
||||||
|
:key="job.taskId"
|
||||||
|
:job="job"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex h-12 items-center justify-between border-t border-border-default px-4"
|
v-if="filteredJobs.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-6 text-center"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<span class="text-sm text-muted-foreground">
|
||||||
<template v-if="isInProgress">
|
{{
|
||||||
<i
|
t('progressToast.noImportsInQueue', {
|
||||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
filter: activeFilterLabel
|
||||||
/>
|
})
|
||||||
<span class="font-bold text-base-foreground">{{
|
}}
|
||||||
currentJobName
|
</span>
|
||||||
}}</span>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<template v-else-if="failedJobs.length > 0">
|
</template>
|
||||||
<i
|
|
||||||
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
|
|
||||||
/>
|
|
||||||
<span class="font-bold text-base-foreground">
|
|
||||||
{{
|
|
||||||
t('progressToast.downloadsFailed', {
|
|
||||||
count: failedJobs.length
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
|
|
||||||
<span class="font-bold text-base-foreground">
|
|
||||||
{{ t('progressToast.allDownloadsCompleted') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<template #footer="{ toggle }">
|
||||||
<span v-if="isInProgress" class="text-sm text-muted-foreground">
|
<div
|
||||||
|
class="flex h-12 items-center justify-between border-t border-border-default px-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<template v-if="isInProgress">
|
||||||
|
<i
|
||||||
|
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="font-bold text-base-foreground">{{
|
||||||
|
currentJobName
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="failedJobs.length > 0">
|
||||||
|
<i
|
||||||
|
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
|
||||||
|
/>
|
||||||
|
<span class="font-bold text-base-foreground">
|
||||||
{{
|
{{
|
||||||
t('progressToast.progressCount', {
|
t('progressToast.downloadsFailed', {
|
||||||
completed: completedCount,
|
count: failedJobs.length
|
||||||
total: totalCount
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
|
||||||
|
<span class="font-bold text-base-foreground">
|
||||||
|
{{ t('progressToast.allDownloadsCompleted') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<span v-if="isInProgress" class="text-sm text-muted-foreground">
|
||||||
variant="muted-textonly"
|
{{
|
||||||
size="icon"
|
t('progressToast.progressCount', {
|
||||||
:aria-label="
|
completed: completedCount,
|
||||||
isExpanded
|
total: totalCount
|
||||||
? t('contextMenu.Collapse')
|
})
|
||||||
: t('contextMenu.Expand')
|
}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="muted-textonly"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
|
||||||
|
"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'size-4',
|
||||||
|
isExpanded
|
||||||
|
? 'icon-[lucide--chevron-down]'
|
||||||
|
: 'icon-[lucide--chevron-up]'
|
||||||
|
)
|
||||||
"
|
"
|
||||||
@click.stop="toggle"
|
/>
|
||||||
>
|
</Button>
|
||||||
<i
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'size-4',
|
|
||||||
isExpanded
|
|
||||||
? 'icon-[lucide--chevron-down]'
|
|
||||||
: 'icon-[lucide--chevron-up]'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
v-if="!isInProgress"
|
v-if="!isInProgress"
|
||||||
variant="muted-textonly"
|
variant="muted-textonly"
|
||||||
size="icon"
|
size="icon"
|
||||||
:aria-label="t('g.close')"
|
:aria-label="t('g.close')"
|
||||||
@click.stop="closeDialog"
|
@click.stop="closeDialog"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--x] size-4" />
|
<i class="icon-[lucide--x] size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</template>
|
||||||
</Teleport>
|
</HoneyToast>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import type {
|
|||||||
DialogComponentProps,
|
DialogComponentProps,
|
||||||
ShowDialogOptions
|
ShowDialogOptions
|
||||||
} from '@/stores/dialogStore'
|
} from '@/stores/dialogStore'
|
||||||
import ManagerProgressDialogContent from '@/workbench/extensions/manager/components/ManagerProgressDialogContent.vue'
|
|
||||||
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
|
|
||||||
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
|
|
||||||
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
|
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
|
||||||
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
|
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
|
||||||
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
|
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
|
||||||
@@ -229,30 +227,6 @@ export const useDialogService = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showManagerProgressDialog(options?: {
|
|
||||||
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
|
|
||||||
}) {
|
|
||||||
return dialogStore.showDialog({
|
|
||||||
key: 'global-manager-progress-dialog',
|
|
||||||
component: ManagerProgressDialogContent,
|
|
||||||
headerComponent: ManagerProgressHeader,
|
|
||||||
footerComponent: ManagerProgressFooter,
|
|
||||||
props: options?.props,
|
|
||||||
priority: 2,
|
|
||||||
dialogComponentProps: {
|
|
||||||
closable: false,
|
|
||||||
modal: false,
|
|
||||||
position: 'bottom',
|
|
||||||
pt: {
|
|
||||||
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
|
|
||||||
content: { class: 'p-0!' },
|
|
||||||
header: { class: 'p-0! border-none' },
|
|
||||||
footer: { class: 'p-0! border-none' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a dialog requiring sign in for API nodes
|
* Shows a dialog requiring sign in for API nodes
|
||||||
* @returns Promise that resolves to true if user clicks login, false if cancelled
|
* @returns Promise that resolves to true if user clicks login, false if cancelled
|
||||||
@@ -440,16 +414,6 @@ export const useDialogService = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleManagerProgressDialog(
|
|
||||||
props?: ComponentAttrs<typeof ManagerProgressDialogContent>
|
|
||||||
) {
|
|
||||||
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
|
|
||||||
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
|
|
||||||
} else {
|
|
||||||
showManagerProgressDialog({ props })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLayoutDialog(options: {
|
function showLayoutDialog(options: {
|
||||||
key: string
|
key: string
|
||||||
component: Component
|
component: Component
|
||||||
@@ -548,7 +512,6 @@ export const useDialogService = () => {
|
|||||||
showAboutDialog,
|
showAboutDialog,
|
||||||
showExecutionErrorDialog,
|
showExecutionErrorDialog,
|
||||||
showManagerDialog,
|
showManagerDialog,
|
||||||
showManagerProgressDialog,
|
|
||||||
showApiNodesSignInDialog,
|
showApiNodesSignInDialog,
|
||||||
showSignInDialog,
|
showSignInDialog,
|
||||||
showSubscriptionRequiredDialog,
|
showSubscriptionRequiredDialog,
|
||||||
@@ -559,7 +522,6 @@ export const useDialogService = () => {
|
|||||||
showErrorDialog,
|
showErrorDialog,
|
||||||
confirm,
|
confirm,
|
||||||
toggleManagerDialog,
|
toggleManagerDialog,
|
||||||
toggleManagerProgressDialog,
|
|
||||||
showLayoutDialog,
|
showLayoutDialog,
|
||||||
showNodeConflictDialog
|
showNodeConflictDialog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
|
|||||||
| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
|
| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
|
||||||
| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
|
| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
|
||||||
| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
|
| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
|
||||||
| comfyManagerStore.ts | useManagerProgressDialogStore | Manages manager progress dialog state | UI |
|
|
||||||
| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
|
| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
|
||||||
| commandStore.ts | useCommandStore | Manages commands and command execution | Core |
|
| commandStore.ts | useCommandStore | Manages commands and command execution | Core |
|
||||||
| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |
|
| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<GlobalToast />
|
<GlobalToast />
|
||||||
<RerouteMigrationToast />
|
<RerouteMigrationToast />
|
||||||
<ModelImportProgressDialog />
|
<ModelImportProgressDialog />
|
||||||
|
<ManagerProgressToast />
|
||||||
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
||||||
<MenuHamburger />
|
<MenuHamburger />
|
||||||
</template>
|
</template>
|
||||||
@@ -80,6 +81,7 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
|||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||||
import LinearView from '@/views/LinearView.vue'
|
import LinearView from '@/views/LinearView.vue'
|
||||||
|
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||||
|
|
||||||
setupAutoQueueHandler()
|
setupAutoQueueHandler()
|
||||||
useProgressFavicon()
|
useProgressFavicon()
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
import type { VueWrapper } from '@vue/test-utils'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
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' with { type: '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('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
|
||||||
useComfyManagerStore: vi.fn(() => ({
|
|
||||||
taskLogs: [...defaultMockTaskLogs],
|
|
||||||
succeededTasksLogs: [...defaultMockTaskLogs],
|
|
||||||
failedTasksLogs: [...defaultMockTaskLogs],
|
|
||||||
managerQueue: { historyCount: 2 },
|
|
||||||
isLoading: false
|
|
||||||
})),
|
|
||||||
useManagerProgressDialogStore: vi.fn(() => ({
|
|
||||||
isExpanded: true,
|
|
||||||
activeTabIndex: 0,
|
|
||||||
getActiveTabIndex: vi.fn(() => 0),
|
|
||||||
setActiveTabIndex: vi.fn(),
|
|
||||||
toggle: vi.fn(),
|
|
||||||
collapse: mockCollapse,
|
|
||||||
expand: vi.fn()
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="overflow-hidden transition-all duration-300"
|
|
||||||
:class="{
|
|
||||||
'max-h-[500px]': isExpanded,
|
|
||||||
'm-0 max-h-0 p-0': !isExpanded
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref="sectionsContainerRef"
|
|
||||||
class="scroll-container max-h-[450px] overflow-y-auto px-6 py-4"
|
|
||||||
:style="{
|
|
||||||
scrollbarWidth: 'thin',
|
|
||||||
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
|
|
||||||
}"
|
|
||||||
:class="{
|
|
||||||
'max-h-[450px]': isExpanded,
|
|
||||||
'max-h-0': !isExpanded
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div v-for="(log, index) in focusedLogs" :key="index">
|
|
||||||
<Panel
|
|
||||||
:expanded="collapsedPanels[index] === true"
|
|
||||||
toggleable
|
|
||||||
class="shadow-elevation-1 mt-2 rounded-lg"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex w-full items-center justify-between py-2">
|
|
||||||
<div class="flex flex-col text-sm leading-normal font-medium">
|
|
||||||
<span>{{ log.taskName }}</span>
|
|
||||||
<span class="text-muted">
|
|
||||||
{{
|
|
||||||
isInProgress(index)
|
|
||||||
? $t('g.inProgress')
|
|
||||||
: $t('g.completed') + ' ✓'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #toggleicon>
|
|
||||||
<Button
|
|
||||||
variant="textonly"
|
|
||||||
class="text-neutral-300"
|
|
||||||
@click="togglePanel(index)"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
:class="
|
|
||||||
collapsedPanels[index]
|
|
||||||
? 'pi pi-chevron-right'
|
|
||||||
: 'pi pi-chevron-down'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
:ref="
|
|
||||||
index === focusedLogs.length - 1
|
|
||||||
? (el) => (lastPanelRef = el as HTMLElement)
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
class="h-64 overflow-y-auto rounded-lg bg-black"
|
|
||||||
:class="{
|
|
||||||
'h-64': index !== focusedLogs.length - 1,
|
|
||||||
grow: index === focusedLogs.length - 1
|
|
||||||
}"
|
|
||||||
@scroll="handleScroll"
|
|
||||||
>
|
|
||||||
<div class="h-full">
|
|
||||||
<div
|
|
||||||
v-for="(logLine, logIndex) in log.logs"
|
|
||||||
:key="logIndex"
|
|
||||||
class="text-muted"
|
|
||||||
>
|
|
||||||
<pre class="break-words whitespace-pre-wrap">{{ logLine }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useScroll, whenever } from '@vueuse/core'
|
|
||||||
import Panel from 'primevue/panel'
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import {
|
|
||||||
useComfyManagerStore,
|
|
||||||
useManagerProgressDialogStore
|
|
||||||
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
|
||||||
|
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
|
||||||
const progressDialogContent = useManagerProgressDialogStore()
|
|
||||||
|
|
||||||
const isInProgress = (index: number) => {
|
|
||||||
const log = focusedLogs.value[index]
|
|
||||||
if (!log) return false
|
|
||||||
|
|
||||||
// Check if this task is in the running or pending queue
|
|
||||||
const taskQueue = comfyManagerStore.taskQueue
|
|
||||||
if (!taskQueue) return false
|
|
||||||
|
|
||||||
const allQueueTasks = [
|
|
||||||
...(taskQueue.running_queue || []),
|
|
||||||
...(taskQueue.pending_queue || [])
|
|
||||||
]
|
|
||||||
|
|
||||||
return allQueueTasks.some((task) => task.ui_id === log.taskId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusedLogs = computed(() => {
|
|
||||||
if (progressDialogContent.getActiveTabIndex() === 0) {
|
|
||||||
return comfyManagerStore.succeededTasksLogs
|
|
||||||
}
|
|
||||||
return comfyManagerStore.failedTasksLogs
|
|
||||||
})
|
|
||||||
const isExpanded = computed(() => progressDialogContent.isExpanded)
|
|
||||||
const isCollapsed = computed(() => !isExpanded.value)
|
|
||||||
|
|
||||||
const collapsedPanels = ref<Record<number, boolean>>({})
|
|
||||||
const togglePanel = (index: number) => {
|
|
||||||
collapsedPanels.value[index] = !collapsedPanels.value[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionsContainerRef = ref<HTMLElement | null>(null)
|
|
||||||
const { y: scrollY } = useScroll(sectionsContainerRef, {
|
|
||||||
eventListenerOptions: {
|
|
||||||
passive: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastPanelRef = ref<HTMLElement | null>(null)
|
|
||||||
const isUserScrolling = ref(false)
|
|
||||||
const lastPanelLogs = computed(() => focusedLogs.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
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
scrollContentToBottom()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
progressDialogContent.collapse()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex w-full items-center justify-between px-6 py-2 shadow-lg"
|
|
||||||
:class="{
|
|
||||||
'rounded-t-none': progressDialogContent.isExpanded,
|
|
||||||
'rounded-lg': !progressDialogContent.isExpanded
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="flex items-center text-base leading-none">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<template v-if="isInProgress">
|
|
||||||
<DotSpinner duration="1s" class="mr-2" />
|
|
||||||
<span>{{ currentTaskName }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="isRestartCompleted">
|
|
||||||
<span class="mr-2">🎉</span>
|
|
||||||
<span>{{ currentTaskName }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="mr-2">✅</span>
|
|
||||||
<span>{{ $t('manager.restartToApplyChanges') }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span v-if="isInProgress" class="text-sm text-neutral-700">
|
|
||||||
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
|
|
||||||
{{ totalTasksCount }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Button
|
|
||||||
v-if="!isInProgress && !isRestartCompleted"
|
|
||||||
variant="secondary"
|
|
||||||
class="mr-4 rounded-full border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
|
|
||||||
@click="handleRestart"
|
|
||||||
>
|
|
||||||
{{ $t('manager.applyChanges') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-else-if="!isRestartCompleted"
|
|
||||||
variant="muted-textonly"
|
|
||||||
size="sm"
|
|
||||||
class="rounded-full font-bold"
|
|
||||||
:aria-label="
|
|
||||||
$t(
|
|
||||||
progressDialogContent.isExpanded
|
|
||||||
? 'contextMenu.Collapse'
|
|
||||||
: 'contextMenu.Expand'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click.stop="progressDialogContent.toggle"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
:class="
|
|
||||||
progressDialogContent.isExpanded
|
|
||||||
? 'pi pi-chevron-up'
|
|
||||||
: 'pi pi-chevron-down'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="muted-textonly"
|
|
||||||
size="sm"
|
|
||||||
class="rounded-full font-bold"
|
|
||||||
:aria-label="$t('g.close')"
|
|
||||||
@click.stop="closeDialog"
|
|
||||||
>
|
|
||||||
<i class="pi pi-times" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useEventListener } from '@vueuse/core'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
|
||||||
import { api } from '@/scripts/api'
|
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
|
||||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
|
||||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
|
||||||
import {
|
|
||||||
useComfyManagerStore,
|
|
||||||
useManagerProgressDialogStore
|
|
||||||
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const dialogStore = useDialogStore()
|
|
||||||
const progressDialogContent = useManagerProgressDialogStore()
|
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
|
||||||
const settingStore = useSettingStore()
|
|
||||||
const { runFullConflictAnalysis } = useConflictDetection()
|
|
||||||
|
|
||||||
// State management for restart process
|
|
||||||
const isRestarting = ref<boolean>(false)
|
|
||||||
const isRestartCompleted = ref<boolean>(false)
|
|
||||||
|
|
||||||
const isInProgress = computed(
|
|
||||||
() => comfyManagerStore.isProcessingTasks || isRestarting.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const completedTasksCount = computed(() => {
|
|
||||||
return (
|
|
||||||
comfyManagerStore.succeededTasksIds.length +
|
|
||||||
comfyManagerStore.failedTasksIds.length
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalTasksCount = computed(() => {
|
|
||||||
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
|
|
||||||
const taskQueue = comfyManagerStore.taskQueue
|
|
||||||
const queuedTasks = taskQueue
|
|
||||||
? (taskQueue.running_queue?.length || 0) +
|
|
||||||
(taskQueue.pending_queue?.length || 0)
|
|
||||||
: 0
|
|
||||||
return completedTasks + queuedTasks
|
|
||||||
})
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackTaskName = t('manager.installingDependencies')
|
|
||||||
const currentTaskName = computed(() => {
|
|
||||||
if (isRestarting.value) {
|
|
||||||
return t('manager.restartingBackend')
|
|
||||||
}
|
|
||||||
if (isRestartCompleted.value) {
|
|
||||||
return t('manager.extensionsSuccessfullyInstalled')
|
|
||||||
}
|
|
||||||
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
|
|
||||||
const task = comfyManagerStore.taskLogs.at(-1)
|
|
||||||
return task?.taskName ?? fallbackTaskName
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleRestart = async () => {
|
|
||||||
// Store original toast setting value
|
|
||||||
const originalToastSetting = settingStore.get(
|
|
||||||
'Comfy.Toast.DisableReconnectingToast'
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
|
|
||||||
|
|
||||||
isRestarting.value = true
|
|
||||||
|
|
||||||
const onReconnect = async () => {
|
|
||||||
try {
|
|
||||||
comfyManagerStore.setStale()
|
|
||||||
|
|
||||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
|
||||||
|
|
||||||
await useWorkflowService().reloadCurrentWorkflow()
|
|
||||||
|
|
||||||
// Run conflict detection in background after restart completion
|
|
||||||
void runFullConflictAnalysis()
|
|
||||||
} finally {
|
|
||||||
await settingStore.set(
|
|
||||||
'Comfy.Toast.DisableReconnectingToast',
|
|
||||||
originalToastSetting
|
|
||||||
)
|
|
||||||
|
|
||||||
isRestarting.value = false
|
|
||||||
isRestartCompleted.value = true
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
closeDialog()
|
|
||||||
comfyManagerStore.resetTaskState()
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
|
||||||
|
|
||||||
await useComfyManagerService().rebootComfyUI()
|
|
||||||
} catch (error) {
|
|
||||||
// If restart fails, restore settings and reset state
|
|
||||||
await settingStore.set(
|
|
||||||
'Comfy.Toast.DisableReconnectingToast',
|
|
||||||
originalToastSetting
|
|
||||||
)
|
|
||||||
isRestarting.value = false
|
|
||||||
isRestartCompleted.value = false
|
|
||||||
closeDialog() // Close dialog on error
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="progressDialogContent.isExpanded"
|
|
||||||
class="flex items-center px-4 py-2"
|
|
||||||
>
|
|
||||||
<TabMenu
|
|
||||||
v-model:active-index="activeTabIndex"
|
|
||||||
:model="tabs"
|
|
||||||
class="w-full border-none"
|
|
||||||
:pt="{
|
|
||||||
menu: { class: 'border-none' },
|
|
||||||
menuitem: { class: 'font-medium' },
|
|
||||||
action: { class: 'px-4 py-2' }
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import TabMenu from 'primevue/tabmenu'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import {
|
|
||||||
useComfyManagerStore,
|
|
||||||
useManagerProgressDialogStore
|
|
||||||
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
|
||||||
|
|
||||||
const progressDialogContent = useManagerProgressDialogStore()
|
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
|
||||||
const activeTabIndex = computed({
|
|
||||||
get: () => progressDialogContent.getActiveTabIndex(),
|
|
||||||
set: (value) => progressDialogContent.setActiveTabIndex(value)
|
|
||||||
})
|
|
||||||
const { t } = useI18n()
|
|
||||||
const tabs = computed(() => [
|
|
||||||
{ label: t('manager.installationQueue') },
|
|
||||||
{
|
|
||||||
label: t('manager.failed', {
|
|
||||||
count: comfyManagerStore.failedTasksIds.length
|
|
||||||
})
|
|
||||||
}
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener, useScroll, whenever } from '@vueuse/core'
|
||||||
|
import Panel from 'primevue/panel'
|
||||||
|
import TabMenu from 'primevue/tabmenu'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||||
|
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||||
|
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||||
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const { runFullConflictAnalysis } = useConflictDetection()
|
||||||
|
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const activeTabIndex = ref(0)
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ label: t('manager.installationQueue') },
|
||||||
|
{
|
||||||
|
label: t('manager.failed', {
|
||||||
|
count: comfyManagerStore.failedTasksIds.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const focusedLogs = computed(() => {
|
||||||
|
if (activeTabIndex.value === 0) {
|
||||||
|
return comfyManagerStore.succeededTasksLogs
|
||||||
|
}
|
||||||
|
return comfyManagerStore.failedTasksLogs
|
||||||
|
})
|
||||||
|
|
||||||
|
const visible = computed(() => comfyManagerStore.taskLogs.length > 0)
|
||||||
|
|
||||||
|
const isRestarting = ref(false)
|
||||||
|
const isRestartCompleted = ref(false)
|
||||||
|
|
||||||
|
const isInProgress = computed(
|
||||||
|
() => comfyManagerStore.isProcessingTasks || isRestarting.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const isTaskInProgress = (index: number) => {
|
||||||
|
const log = focusedLogs.value[index]
|
||||||
|
if (!log) return false
|
||||||
|
|
||||||
|
const taskQueue = comfyManagerStore.taskQueue
|
||||||
|
if (!taskQueue) return false
|
||||||
|
|
||||||
|
const allQueueTasks = [
|
||||||
|
...(taskQueue.running_queue || []),
|
||||||
|
...(taskQueue.pending_queue || [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return allQueueTasks.some((task) => task.ui_id === log.taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTasksCount = computed(() => {
|
||||||
|
return (
|
||||||
|
comfyManagerStore.succeededTasksIds.length +
|
||||||
|
comfyManagerStore.failedTasksIds.length
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalTasksCount = computed(() => {
|
||||||
|
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
|
||||||
|
const taskQueue = comfyManagerStore.taskQueue
|
||||||
|
const queuedTasks = taskQueue
|
||||||
|
? (taskQueue.running_queue?.length || 0) +
|
||||||
|
(taskQueue.pending_queue?.length || 0)
|
||||||
|
: 0
|
||||||
|
return completedTasks + queuedTasks
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTaskName = computed(() => {
|
||||||
|
if (isRestarting.value) {
|
||||||
|
return t('manager.restartingBackend')
|
||||||
|
}
|
||||||
|
if (isRestartCompleted.value) {
|
||||||
|
return t('manager.extensionsSuccessfullyInstalled')
|
||||||
|
}
|
||||||
|
if (!comfyManagerStore.taskLogs.length)
|
||||||
|
return t('manager.installingDependencies')
|
||||||
|
const task = comfyManagerStore.taskLogs.at(-1)
|
||||||
|
return task?.taskName ?? t('manager.installingDependencies')
|
||||||
|
})
|
||||||
|
|
||||||
|
const collapsedPanels = ref<Record<number, boolean>>({})
|
||||||
|
function togglePanel(index: number) {
|
||||||
|
collapsedPanels.value[index] = !collapsedPanels.value[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionsContainerRef = ref<HTMLElement | null>(null)
|
||||||
|
const { y: scrollY } = useScroll(sectionsContainerRef, {
|
||||||
|
eventListenerOptions: { passive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
const isUserScrolling = ref(false)
|
||||||
|
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
|
||||||
|
|
||||||
|
function isAtBottom(el: HTMLElement | null) {
|
||||||
|
if (!el) return false
|
||||||
|
const threshold = 20
|
||||||
|
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollLastPanelToBottom() {
|
||||||
|
if (!lastPanelRef.value || isUserScrolling.value) return
|
||||||
|
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollContentToBottom() {
|
||||||
|
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUserScrolling() {
|
||||||
|
isUserScrolling.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(e: Event) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target !== lastPanelRef.value) return
|
||||||
|
isUserScrolling.value = !isAtBottom(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLogsAdded() {
|
||||||
|
if (isUserScrolling.value) return
|
||||||
|
scrollLastPanelToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
|
||||||
|
whenever(() => isExpanded.value, scrollContentToBottom)
|
||||||
|
whenever(() => !isExpanded.value, resetUserScrolling)
|
||||||
|
|
||||||
|
function closeToast() {
|
||||||
|
comfyManagerStore.resetTaskState()
|
||||||
|
isExpanded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestart() {
|
||||||
|
const originalToastSetting = settingStore.get(
|
||||||
|
'Comfy.Toast.DisableReconnectingToast'
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
|
||||||
|
|
||||||
|
isRestarting.value = true
|
||||||
|
|
||||||
|
const onReconnect = async () => {
|
||||||
|
try {
|
||||||
|
comfyManagerStore.setStale()
|
||||||
|
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||||
|
await useWorkflowService().reloadCurrentWorkflow()
|
||||||
|
void runFullConflictAnalysis()
|
||||||
|
} finally {
|
||||||
|
await settingStore.set(
|
||||||
|
'Comfy.Toast.DisableReconnectingToast',
|
||||||
|
originalToastSetting
|
||||||
|
)
|
||||||
|
isRestarting.value = false
|
||||||
|
isRestartCompleted.value = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
closeToast()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
||||||
|
|
||||||
|
await useComfyManagerService().rebootComfyUI()
|
||||||
|
} catch (error) {
|
||||||
|
await settingStore.set(
|
||||||
|
'Comfy.Toast.DisableReconnectingToast',
|
||||||
|
originalToastSetting
|
||||||
|
)
|
||||||
|
isRestarting.value = false
|
||||||
|
isRestartCompleted.value = false
|
||||||
|
closeToast()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scrollContentToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
isExpanded.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HoneyToast v-model:expanded="isExpanded" :visible>
|
||||||
|
<template #default>
|
||||||
|
<div v-if="isExpanded" class="flex items-center px-4 py-2">
|
||||||
|
<TabMenu
|
||||||
|
v-model:active-index="activeTabIndex"
|
||||||
|
:model="tabs"
|
||||||
|
class="w-full border-none"
|
||||||
|
:pt="{
|
||||||
|
menu: { class: 'border-none' },
|
||||||
|
menuitem: { class: 'font-medium' },
|
||||||
|
action: { class: 'px-4 py-2' }
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="sectionsContainerRef"
|
||||||
|
class="scroll-container max-h-[450px] overflow-y-auto px-6 py-4"
|
||||||
|
:style="{
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-for="(log, index) in focusedLogs" :key="index">
|
||||||
|
<Panel
|
||||||
|
:expanded="collapsedPanels[index] === true"
|
||||||
|
toggleable
|
||||||
|
class="shadow-elevation-1 mt-2 rounded-lg"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex w-full items-center justify-between py-2">
|
||||||
|
<div class="flex flex-col text-sm leading-normal font-medium">
|
||||||
|
<span>{{ log.taskName }}</span>
|
||||||
|
<span class="text-muted">
|
||||||
|
{{
|
||||||
|
isTaskInProgress(index)
|
||||||
|
? t('g.inProgress')
|
||||||
|
: t('g.completed') + ' ✓'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #toggleicon>
|
||||||
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
class="text-neutral-300"
|
||||||
|
@click="togglePanel(index)"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
collapsedPanels[index]
|
||||||
|
? 'pi pi-chevron-right'
|
||||||
|
: 'pi pi-chevron-down'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
:ref="
|
||||||
|
index === focusedLogs.length - 1
|
||||||
|
? (el) => (lastPanelRef = el as HTMLElement)
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
class="h-64 overflow-y-auto rounded-lg bg-black"
|
||||||
|
:class="{
|
||||||
|
'h-64': index !== focusedLogs.length - 1,
|
||||||
|
grow: index === focusedLogs.length - 1
|
||||||
|
}"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<div class="h-full">
|
||||||
|
<div
|
||||||
|
v-for="(logLine, logIndex) in log.logs"
|
||||||
|
:key="logIndex"
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
<pre class="break-words whitespace-pre-wrap">{{
|
||||||
|
logLine
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ toggle }">
|
||||||
|
<div class="flex w-full items-center justify-between px-6 py-2 shadow-lg">
|
||||||
|
<div class="flex items-center text-base leading-none">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<template v-if="isInProgress">
|
||||||
|
<DotSpinner duration="1s" class="mr-2" />
|
||||||
|
<span>{{ currentTaskName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isRestartCompleted">
|
||||||
|
<span class="mr-2">🎉</span>
|
||||||
|
<span>{{ currentTaskName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="mr-2">✅</span>
|
||||||
|
<span>{{ t('manager.restartToApplyChanges') }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span v-if="isInProgress" class="text-sm text-muted-foreground">
|
||||||
|
{{ completedTasksCount }} {{ t('g.progressCountOf') }}
|
||||||
|
{{ totalTasksCount }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button
|
||||||
|
v-if="!isInProgress && !isRestartCompleted"
|
||||||
|
variant="secondary"
|
||||||
|
class="mr-4 rounded-full border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
|
||||||
|
@click="handleRestart"
|
||||||
|
>
|
||||||
|
{{ t('manager.applyChanges') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="!isRestartCompleted"
|
||||||
|
variant="muted-textonly"
|
||||||
|
size="sm"
|
||||||
|
class="rounded-full font-bold"
|
||||||
|
:aria-label="
|
||||||
|
t(isExpanded ? 'contextMenu.Collapse' : 'contextMenu.Expand')
|
||||||
|
"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="muted-textonly"
|
||||||
|
size="sm"
|
||||||
|
class="rounded-full font-bold"
|
||||||
|
:aria-label="t('g.close')"
|
||||||
|
@click.stop="closeToast"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoneyToast>
|
||||||
|
</template>
|
||||||
@@ -4,7 +4,6 @@ import type { Ref } from 'vue'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
|
||||||
import { normalizePackKeys } from '@/utils/packUtils'
|
import { normalizePackKeys } from '@/utils/packUtils'
|
||||||
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||||
|
|
||||||
@@ -27,8 +26,6 @@ export const useManagerQueue = (
|
|||||||
taskQueue: Ref<ManagerTaskQueue>,
|
taskQueue: Ref<ManagerTaskQueue>,
|
||||||
installedPacks: Ref<Record<string, any>>
|
installedPacks: Ref<Record<string, any>>
|
||||||
) => {
|
) => {
|
||||||
const { showManagerProgressDialog } = useDialogService()
|
|
||||||
|
|
||||||
// Task queue state (read-only from server)
|
// Task queue state (read-only from server)
|
||||||
const maxHistoryItems = ref(64)
|
const maxHistoryItems = ref(64)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -113,15 +110,6 @@ export const useManagerQueue = (
|
|||||||
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
|
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
|
||||||
if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) {
|
if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) {
|
||||||
updateTaskState(event.detail.state)
|
updateTaskState(event.detail.state)
|
||||||
|
|
||||||
// If no more tasks are running/pending, hide the progress dialog
|
|
||||||
if (allTasksDone.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (allTasksDone.value) {
|
|
||||||
showManagerProgressDialog()
|
|
||||||
}
|
|
||||||
}, 1000) // Small delay to let users see completion
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -133,9 +121,6 @@ export const useManagerQueue = (
|
|||||||
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
|
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
|
||||||
if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) {
|
if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) {
|
||||||
updateTaskState(event.detail.state)
|
updateTaskState(event.detail.state)
|
||||||
|
|
||||||
// Show progress dialog when a task starts
|
|
||||||
showManagerProgressDialog()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
|
|||||||
import { useServerLogs } from '@/composables/useServerLogs'
|
import { useServerLogs } from '@/composables/useServerLogs'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
|
||||||
import { normalizePackKeys } from '@/utils/packUtils'
|
import { normalizePackKeys } from '@/utils/packUtils'
|
||||||
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
|
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
|
||||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||||
@@ -32,7 +32,6 @@ type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
|
|||||||
export const useComfyManagerStore = defineStore('comfyManager', () => {
|
export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const managerService = useComfyManagerService()
|
const managerService = useComfyManagerService()
|
||||||
const { showManagerProgressDialog } = useDialogService()
|
|
||||||
|
|
||||||
const installedPacks = ref<InstalledPacksResponse>({})
|
const installedPacks = ref<InstalledPacksResponse>({})
|
||||||
const enabledPacksIds = ref<Set<string>>(new Set())
|
const enabledPacksIds = ref<Set<string>>(new Set())
|
||||||
@@ -204,8 +203,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show progress dialog immediately when task is queued
|
|
||||||
showManagerProgressDialog()
|
|
||||||
managerQueue.isProcessing.value = true
|
managerQueue.isProcessing.value = true
|
||||||
|
|
||||||
// Prepare logging hook
|
// Prepare logging hook
|
||||||
@@ -392,44 +389,3 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
|||||||
enablePack
|
enablePack
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Store for state of the manager progress dialog content.
|
|
||||||
* The dialog itself is managed by the dialog store. This store is used to
|
|
||||||
* manage the visibility of the dialog's content, header, footer.
|
|
||||||
*/
|
|
||||||
export const useManagerProgressDialogStore = defineStore(
|
|
||||||
'managerProgressDialog',
|
|
||||||
() => {
|
|
||||||
const isExpanded = ref(false)
|
|
||||||
const activeTabIndex = ref(0)
|
|
||||||
|
|
||||||
const setActiveTabIndex = (index: number) => {
|
|
||||||
activeTabIndex.value = index
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActiveTabIndex = () => {
|
|
||||||
return activeTabIndex.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
isExpanded.value = !isExpanded.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapse = () => {
|
|
||||||
isExpanded.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const expand = () => {
|
|
||||||
isExpanded.value = true
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
isExpanded,
|
|
||||||
toggle,
|
|
||||||
collapse,
|
|
||||||
expand,
|
|
||||||
setActiveTabIndex,
|
|
||||||
getActiveTabIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import PrimeVue from 'primevue/config'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { nextTick } from 'vue'
|
|
||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
|
||||||
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
|
|
||||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
|
||||||
import {
|
|
||||||
useComfyManagerStore,
|
|
||||||
useManagerProgressDialogStore
|
|
||||||
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
|
||||||
import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
||||||
|
|
||||||
// Mock modules
|
|
||||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
|
|
||||||
vi.mock('@/stores/dialogStore')
|
|
||||||
vi.mock('@/platform/settings/settingStore')
|
|
||||||
vi.mock('@/stores/commandStore')
|
|
||||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService')
|
|
||||||
vi.mock(
|
|
||||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
|
||||||
() => ({
|
|
||||||
useConflictDetection: vi.fn(() => ({
|
|
||||||
conflictedPackages: { value: [] },
|
|
||||||
runFullConflictAnalysis: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mock useEventListener to capture the event handler
|
|
||||||
let reconnectHandler: (() => void) | null = null
|
|
||||||
vi.mock('@vueuse/core', async () => {
|
|
||||||
const actual = await vi.importActual('@vueuse/core')
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useEventListener: vi.fn(
|
|
||||||
(_target: any, event: string, handler: any, _options: any) => {
|
|
||||||
if (event === 'reconnected') {
|
|
||||||
reconnectHandler = handler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
|
||||||
useWorkflowService: vi.fn(() => ({
|
|
||||||
reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
|
||||||
useColorPaletteStore: vi.fn(() => ({
|
|
||||||
completedActivePalette: {
|
|
||||||
light_theme: false
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Helper function to mount component with required setup
|
|
||||||
const mountComponent = (options: { captureError?: boolean } = {}) => {
|
|
||||||
const pinia = createPinia()
|
|
||||||
setActivePinia(pinia)
|
|
||||||
|
|
||||||
const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: 'en',
|
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
g: {
|
|
||||||
close: 'Close',
|
|
||||||
progressCountOf: 'of'
|
|
||||||
},
|
|
||||||
contextMenu: {
|
|
||||||
Collapse: 'Collapse',
|
|
||||||
Expand: 'Expand'
|
|
||||||
},
|
|
||||||
manager: {
|
|
||||||
clickToFinishSetup: 'Click',
|
|
||||||
applyChanges: 'Apply Changes',
|
|
||||||
toFinishSetup: 'to finish setup',
|
|
||||||
restartingBackend: 'Restarting backend to apply changes...',
|
|
||||||
extensionsSuccessfullyInstalled:
|
|
||||||
'Extension(s) successfully installed and are ready to use!',
|
|
||||||
restartToApplyChanges: 'To apply changes, please restart ComfyUI',
|
|
||||||
installingDependencies: 'Installing dependencies...'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const config: any = {
|
|
||||||
global: {
|
|
||||||
plugins: [pinia, PrimeVue, i18n]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add error handler for tests that expect errors
|
|
||||||
if (options.captureError) {
|
|
||||||
config.global.config = {
|
|
||||||
errorHandler: () => {
|
|
||||||
// Suppress error in test
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mount(ManagerProgressFooter, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ManagerProgressFooter', () => {
|
|
||||||
const mockTaskLogs: TaskLog[] = []
|
|
||||||
|
|
||||||
const mockComfyManagerStore = {
|
|
||||||
taskLogs: mockTaskLogs,
|
|
||||||
allTasksDone: true,
|
|
||||||
isProcessingTasks: false,
|
|
||||||
succeededTasksIds: [] as string[],
|
|
||||||
failedTasksIds: [] as string[],
|
|
||||||
taskHistory: {} as Record<string, any>,
|
|
||||||
taskQueue: null,
|
|
||||||
resetTaskState: vi.fn(),
|
|
||||||
clearLogs: vi.fn(),
|
|
||||||
setStale: vi.fn(),
|
|
||||||
// Add other required properties
|
|
||||||
isLoading: { value: false },
|
|
||||||
error: { value: null },
|
|
||||||
statusMessage: { value: 'DONE' },
|
|
||||||
installedPacks: {},
|
|
||||||
installedPacksIds: new Set(),
|
|
||||||
isPackInstalled: vi.fn(),
|
|
||||||
isPackEnabled: vi.fn(),
|
|
||||||
getInstalledPackVersion: vi.fn(),
|
|
||||||
refreshInstalledList: vi.fn(),
|
|
||||||
installPack: vi.fn(),
|
|
||||||
uninstallPack: vi.fn(),
|
|
||||||
updatePack: vi.fn(),
|
|
||||||
updateAllPacks: vi.fn(),
|
|
||||||
disablePack: vi.fn(),
|
|
||||||
enablePack: vi.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockDialogStore = {
|
|
||||||
closeDialog: vi.fn(),
|
|
||||||
// Add other required properties
|
|
||||||
dialogStack: { value: [] },
|
|
||||||
showDialog: vi.fn(),
|
|
||||||
$id: 'dialog',
|
|
||||||
$state: {} as any,
|
|
||||||
$patch: vi.fn(),
|
|
||||||
$reset: vi.fn(),
|
|
||||||
$subscribe: vi.fn(),
|
|
||||||
$dispose: vi.fn(),
|
|
||||||
$onAction: vi.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockSettingStore = {
|
|
||||||
get: vi.fn().mockReturnValue(false),
|
|
||||||
set: vi.fn(),
|
|
||||||
// Add other required properties
|
|
||||||
settingValues: { value: {} },
|
|
||||||
settingsById: { value: {} },
|
|
||||||
exists: vi.fn(),
|
|
||||||
getDefaultValue: vi.fn(),
|
|
||||||
loadSettingValues: vi.fn(),
|
|
||||||
updateValue: vi.fn(),
|
|
||||||
$id: 'setting',
|
|
||||||
$state: {} as any,
|
|
||||||
$patch: vi.fn(),
|
|
||||||
$reset: vi.fn(),
|
|
||||||
$subscribe: vi.fn(),
|
|
||||||
$dispose: vi.fn(),
|
|
||||||
$onAction: vi.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockProgressDialogStore = {
|
|
||||||
isExpanded: false,
|
|
||||||
toggle: vi.fn(),
|
|
||||||
collapse: vi.fn(),
|
|
||||||
expand: vi.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockCommandStore = {
|
|
||||||
execute: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockComfyManagerService = {
|
|
||||||
rebootComfyUI: vi.fn().mockResolvedValue(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
// Create new pinia instance for each test
|
|
||||||
const pinia = createPinia()
|
|
||||||
setActivePinia(pinia)
|
|
||||||
|
|
||||||
// Reset task logs
|
|
||||||
mockTaskLogs.length = 0
|
|
||||||
mockComfyManagerStore.taskLogs = mockTaskLogs
|
|
||||||
// Reset event handler
|
|
||||||
reconnectHandler = null
|
|
||||||
|
|
||||||
vi.mocked(useComfyManagerStore).mockReturnValue(
|
|
||||||
mockComfyManagerStore as any
|
|
||||||
)
|
|
||||||
vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any)
|
|
||||||
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
|
|
||||||
vi.mocked(useManagerProgressDialogStore).mockReturnValue(
|
|
||||||
mockProgressDialogStore as any
|
|
||||||
)
|
|
||||||
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
|
|
||||||
vi.mocked(useComfyManagerService).mockReturnValue(
|
|
||||||
mockComfyManagerService as any
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('State 1: Queue Running', () => {
|
|
||||||
it('should display loading spinner and progress counter when queue is running', async () => {
|
|
||||||
// Setup queue running state
|
|
||||||
mockComfyManagerStore.isProcessingTasks = true
|
|
||||||
mockComfyManagerStore.succeededTasksIds = ['1', '2']
|
|
||||||
mockComfyManagerStore.failedTasksIds = []
|
|
||||||
mockComfyManagerStore.taskHistory = {
|
|
||||||
'1': { taskName: 'Installing pack1' },
|
|
||||||
'2': { taskName: 'Installing pack2' },
|
|
||||||
'3': { taskName: 'Installing pack3' }
|
|
||||||
}
|
|
||||||
mockTaskLogs.push(
|
|
||||||
{ taskName: 'Installing pack1', taskId: '1', logs: [] },
|
|
||||||
{ taskName: 'Installing pack2', taskId: '2', logs: [] },
|
|
||||||
{ taskName: 'Installing pack3', taskId: '3', logs: [] }
|
|
||||||
)
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
// Check loading spinner exists (DotSpinner component)
|
|
||||||
expect(wrapper.find('.inline-flex').exists()).toBe(true)
|
|
||||||
|
|
||||||
// Check current task name is displayed
|
|
||||||
expect(wrapper.text()).toContain('Installing pack3')
|
|
||||||
|
|
||||||
// Check progress counter (completed: 2 of 3)
|
|
||||||
expect(wrapper.text()).toMatch(/2.*of.*3/)
|
|
||||||
|
|
||||||
// Check expand/collapse button exists
|
|
||||||
const expandButton = wrapper.find('[aria-label="Expand"]')
|
|
||||||
expect(expandButton.exists()).toBe(true)
|
|
||||||
|
|
||||||
// Check Apply Changes button is NOT shown
|
|
||||||
expect(wrapper.text()).not.toContain('Apply Changes')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle expansion when expand button is clicked', async () => {
|
|
||||||
mockComfyManagerStore.isProcessingTasks = true
|
|
||||||
mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] })
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
const expandButton = wrapper.find('[aria-label="Expand"]')
|
|
||||||
await expandButton.trigger('click')
|
|
||||||
|
|
||||||
expect(mockProgressDialogStore.toggle).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('State 2: Tasks Completed (Waiting for Restart)', () => {
|
|
||||||
it('should display check mark and Apply Changes button when all tasks are done', async () => {
|
|
||||||
// Setup tasks completed state
|
|
||||||
mockComfyManagerStore.isProcessingTasks = false
|
|
||||||
mockTaskLogs.push(
|
|
||||||
{ taskName: 'Installed pack1', taskId: '1', logs: [] },
|
|
||||||
{ taskName: 'Installed pack2', taskId: '2', logs: [] }
|
|
||||||
)
|
|
||||||
mockComfyManagerStore.allTasksDone = true
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
// Check check mark emoji
|
|
||||||
expect(wrapper.text()).toContain('✅')
|
|
||||||
|
|
||||||
// Check restart message
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'To apply changes, please restart ComfyUI'
|
|
||||||
)
|
|
||||||
expect(wrapper.text()).toContain('Apply Changes')
|
|
||||||
|
|
||||||
// Check Apply Changes button exists
|
|
||||||
const applyButton = wrapper
|
|
||||||
.findAll('button')
|
|
||||||
.find((btn) => btn.text().includes('Apply Changes'))
|
|
||||||
expect(applyButton).toBeTruthy()
|
|
||||||
|
|
||||||
// Check no progress counter
|
|
||||||
expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('State 3: Restarting', () => {
|
|
||||||
it('should display restarting message and spinner during restart', async () => {
|
|
||||||
// Setup completed state first
|
|
||||||
mockComfyManagerStore.isProcessingTasks = false
|
|
||||||
mockComfyManagerStore.allTasksDone = true
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
// Click Apply Changes to trigger restart
|
|
||||||
const applyButton = wrapper
|
|
||||||
.findAll('button')
|
|
||||||
.find((btn) => btn.text().includes('Apply Changes'))
|
|
||||||
await applyButton?.trigger('click')
|
|
||||||
|
|
||||||
// Wait for state update
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Check restarting message
|
|
||||||
expect(wrapper.text()).toContain('Restarting backend to apply changes...')
|
|
||||||
|
|
||||||
// Check loading spinner during restart
|
|
||||||
expect(wrapper.find('.inline-flex').exists()).toBe(true)
|
|
||||||
|
|
||||||
// Check Apply Changes button is hidden
|
|
||||||
expect(wrapper.text()).not.toContain('Apply Changes')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('State 4: Restart Completed', () => {
|
|
||||||
it('should display success message and auto-close after 3 seconds', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
|
|
||||||
// Setup completed state
|
|
||||||
mockComfyManagerStore.isProcessingTasks = false
|
|
||||||
mockComfyManagerStore.allTasksDone = true
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
// Trigger restart
|
|
||||||
const applyButton = wrapper
|
|
||||||
.findAll('button')
|
|
||||||
.find((btn) => btn.text().includes('Apply Changes'))
|
|
||||||
await applyButton?.trigger('click')
|
|
||||||
|
|
||||||
// Wait for event listener to be set up
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Trigger the reconnect handler directly
|
|
||||||
if (reconnectHandler) {
|
|
||||||
await reconnectHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for restart completed state
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Check success message
|
|
||||||
expect(wrapper.text()).toContain('🎉')
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'Extension(s) successfully installed and are ready to use!'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check dialog closes after 3 seconds
|
|
||||||
vi.advanceTimersByTime(3000)
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
|
||||||
key: 'global-manager-progress-dialog'
|
|
||||||
})
|
|
||||||
expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled()
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Common Features', () => {
|
|
||||||
it('should always display close button', async () => {
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
const closeButton = wrapper.find('[aria-label="Close"]')
|
|
||||||
expect(closeButton.exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should close dialog when close button is clicked', async () => {
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
const closeButton = wrapper.find('[aria-label="Close"]')
|
|
||||||
await closeButton.trigger('click')
|
|
||||||
|
|
||||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
|
||||||
key: 'global-manager-progress-dialog'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Toast Management', () => {
|
|
||||||
it('should suppress reconnection toasts during restart', async () => {
|
|
||||||
mockComfyManagerStore.isProcessingTasks = false
|
|
||||||
mockComfyManagerStore.allTasksDone = true
|
|
||||||
mockSettingStore.get.mockReturnValue(false) // Original setting
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
// Click Apply Changes
|
|
||||||
const applyButton = wrapper
|
|
||||||
.findAll('button')
|
|
||||||
.find((btn) => btn.text().includes('Apply Changes'))
|
|
||||||
await applyButton?.trigger('click')
|
|
||||||
|
|
||||||
// Check toast setting was disabled
|
|
||||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
|
||||||
'Comfy.Toast.DisableReconnectingToast',
|
|
||||||
true
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should restore toast settings after restart completes', async () => {
|
|
||||||
mockComfyManagerStore.isProcessingTasks = false
|
|
||||||
mockComfyManagerStore.allTasksDone = true
|
|
||||||
mockSettingStore.get.mockReturnValue(false) // Original setting
|
|
||||||
|
|
||||||
const wrapper = mountComponent()
|
|
||||||
|
|
||||||
// Click Apply Changes
|
|
||||||
const applyButton = wrapper
|
|
||||||
.findAll('button')
|
|
||||||
.find((btn) => btn.text().includes('Apply Changes'))
|
|
||||||
await applyButton?.trigger('click')
|
|
||||||
|
|
||||||
// Wait for event listener to be set up
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Trigger the reconnect handler directly
|
|
||||||
if (reconnectHandler) {
|
|
||||||
await reconnectHandler()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for settings restoration
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
|
||||||
'Comfy.Toast.DisableReconnectingToast',
|
|
||||||
false // Restored to original
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
|
||||||
it('should restore state and close dialog on restart error', async () => {
|
|
||||||
mockComfyManagerStore.isProcessingTasks = false
|
|
||||||
mockComfyManagerStore.allTasksDone = true
|
|
||||||
|
|
||||||
// Mock restart to throw error
|
|
||||||
mockComfyManagerService.rebootComfyUI.mockRejectedValue(
|
|
||||||
new Error('Restart failed')
|
|
||||||
)
|
|
||||||
|
|
||||||
const wrapper = mountComponent({ captureError: true })
|
|
||||||
|
|
||||||
// Click Apply Changes
|
|
||||||
const applyButton = wrapper
|
|
||||||
.findAll('button')
|
|
||||||
.find((btn) => btn.text().includes('Apply Changes'))
|
|
||||||
|
|
||||||
expect(applyButton).toBeTruthy()
|
|
||||||
|
|
||||||
// The component throws the error but Vue Test Utils catches it
|
|
||||||
// We need to check if the error handling logic was executed
|
|
||||||
await applyButton!.trigger('click').catch(() => {
|
|
||||||
// Error is expected, ignore it
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for error handling
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Check dialog was closed on error
|
|
||||||
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
|
|
||||||
// Check toast settings were restored
|
|
||||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
|
||||||
'Comfy.Toast.DisableReconnectingToast',
|
|
||||||
false
|
|
||||||
)
|
|
||||||
// Check that the error handler was called
|
|
||||||
expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -4,13 +4,6 @@ import { ref } from 'vue'
|
|||||||
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
|
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
|
||||||
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||||
|
|
||||||
// Mock dialog service
|
|
||||||
vi.mock('@/services/dialogService', () => ({
|
|
||||||
useDialogService: () => ({
|
|
||||||
showManagerProgressDialog: vi.fn()
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock the app API
|
// Mock the app API
|
||||||
vi.mock('@/scripts/app', () => ({
|
vi.mock('@/scripts/app', () => ({
|
||||||
app: {
|
app: {
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
|||||||
useComfyManagerService: vi.fn()
|
useComfyManagerService: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/services/dialogService', () => ({
|
|
||||||
useDialogService: () => ({
|
|
||||||
showManagerProgressDialog: vi.fn()
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => {
|
vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => {
|
||||||
const enqueueTaskMock = vi.fn()
|
const enqueueTaskMock = vi.fn()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user