feat: Add more Storybook stories for UI components

This commit is contained in:
snomiao
2025-09-22 21:10:30 +00:00
parent e5d4d07d32
commit ce71c2c529
521 changed files with 8078 additions and 22384 deletions

View File

@@ -21,8 +21,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { CSSProperties } from 'vue'
import { computed, watchEffect } from 'vue'
import { CSSProperties, computed, watchEffect } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'

View File

@@ -0,0 +1,546 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BatchCountEdit from './BatchCountEdit.vue'
const meta: Meta = {
title: 'Components/Actionbar/BatchCountEdit',
component: BatchCountEdit,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'BatchCountEdit allows users to set the batch count for queue operations with smart increment/decrement logic. Features exponential scaling (doubling/halving) and integrates with the queue settings store for ComfyUI workflow execution. This component can accept props for controlled mode or use Pinia store state by default.'
}
}
},
argTypes: {
minQueueCount: {
control: 'number',
description: 'Minimum allowed batch count',
table: {
defaultValue: { summary: '1' }
}
},
maxQueueCount: {
control: 'number',
description: 'Maximum allowed batch count',
table: {
defaultValue: { summary: '100' }
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const Default: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Batch Count Editor</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Set the number of times to run the workflow. Smart increment/decrement with exponential scaling.
</p>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<strong>Note:</strong> Current value: {{count}}. Check console for action logs.
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Default batch count editor with smart exponential scaling. Uses Pinia store for state management. Click +/- buttons to see the doubling/halving behavior.'
}
}
}
}
export const WithTooltip: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 4,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 40px;">
<div style="margin-bottom: 16px; text-align: center;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
Hover over the input to see tooltip
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; text-align: center; margin-top: 20px;">
⬆️ Tooltip appears on hover with 600ms delay
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit with tooltip functionality - hover to see the "Batch Count" tooltip.'
}
}
}
}
export const HighBatchCount: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 16,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
High batch count scenario (16 generations):
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 4px; padding: 12px;">
<div style="display: flex; align-items: center; gap: 8px; color: #b45309;">
<i class="pi pi-exclamation-triangle"></i>
<span style="font-size: 14px; font-weight: 600;">High Batch Count Warning</span>
</div>
<div style="font-size: 12px; color: #92400e; margin-top: 4px;">
Running 16 generations will consume significant GPU time and memory. Consider reducing batch size for faster iteration.
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'High batch count scenario showing potential performance warnings for large generation batches.'
}
}
}
}
export const ActionBarContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 2,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
BatchCountEdit in realistic action bar context:
</div>
<div style="display: flex; align-items: center; gap: 16px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
<!-- Mock Queue Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<!-- BatchCountEdit -->
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<!-- Mock Clear Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #6b7280; color: white; border: none; border-radius: 6px; cursor: pointer;">
<i class="pi pi-trash"></i>
Clear
</button>
<!-- Mock Settings -->
<button style="padding: 8px; background: none; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer;">
<i class="pi pi-cog" style="color: #6b7280;"></i>
</button>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit integrated within a realistic ComfyUI action bar layout with queue controls.'
}
}
}
}
export const ExponentialScaling: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
scalingLog: [],
currentValue: 1,
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
methods: {
simulateIncrement() {
const current = this.currentValue
const newValue = Math.min(current * 2, 100)
this.scalingLog.unshift(`Increment: ${current}${newValue} (×2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
simulateDecrement() {
const current = this.currentValue
const newValue = Math.floor(current / 2) || 1
this.scalingLog.unshift(`Decrement: ${current}${newValue} (÷2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
reset() {
this.currentValue = 1
this.scalingLog = []
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Exponential Scaling Demo</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Demonstrates the smart doubling/halving behavior of batch count controls.
</p>
</div>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 600;">Current Value:</span>
<span style="font-size: 18px; font-weight: bold; color: #3b82f6;">{{ currentValue }}</span>
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button @click="simulateIncrement" style="padding: 6px 12px; background: #10b981; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-plus"></i> Double
</button>
<button @click="simulateDecrement" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-minus"></i> Halve
</button>
<button @click="reset" style="padding: 6px 12px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-refresh"></i> Reset
</button>
</div>
<div v-if="scalingLog.length" style="background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Scaling Log:</div>
<div v-for="(entry, index) in scalingLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Demonstrates the exponential scaling behavior - increment doubles the value, decrement halves it.'
}
}
}
}
export const QueueWorkflowContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
queueStatus: 'Ready',
totalGenerations: 1,
estimatedTime: '~2 min',
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
computed: {
statusColor() {
return this.queueStatus === 'Ready'
? '#10b981'
: this.queueStatus === 'Running'
? '#f59e0b'
: '#6b7280'
}
},
methods: {
updateEstimate() {
// Simulate batch count change affecting estimates
this.totalGenerations = 1 // This would be updated by actual batch count
this.estimatedTime = `~${this.totalGenerations * 2} min`
},
queueWorkflow() {
this.queueStatus = 'Running'
setTimeout(() => {
this.queueStatus = 'Complete'
}, 3000)
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Queue Workflow Context</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
BatchCountEdit within a complete workflow queuing interface.
</p>
</div>
<!-- Mock Workflow Preview -->
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<i class="pi pi-sitemap" style="color: #6366f1;"></i>
<span style="font-weight: 600;">SDXL Portrait Generation</span>
<span :style="{color: statusColor, fontSize: '12px', fontWeight: '600'}" style="background: rgba(0,0,0,0.05); padding: 2px 8px; border-radius: 12px;">
{{ queueStatus }}
</span>
</div>
<!-- Queue Controls -->
<div style="display: flex; align-items: center; gap: 12px; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 12px;">
<button @click="queueWorkflow" style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="text-align: right;">
<div style="font-size: 12px; color: #6b7280;">Total: {{ totalGenerations }} generations</div>
<div style="font-size: 12px; color: #6b7280;">Est. time: {{ estimatedTime }}</div>
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit in a complete workflow queuing context with status and time estimates.'
}
}
}
}
export const LimitConstraints: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
},
scenarios: [
{
name: 'Conservative (max 10)',
maxLimit: 10,
description: 'For memory-constrained systems'
},
{
name: 'Standard (max 50)',
maxLimit: 50,
description: 'Typical production usage'
},
{
name: 'High-end (max 200)',
maxLimit: 200,
description: 'For powerful GPU setups'
}
]
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Limit Constraints</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Different batch count limits for various system configurations.
</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px;">
<div v-for="scenario in scenarios" :key="scenario.name" style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="font-weight: 600; margin-bottom: 4px;">{{ scenario.name }}</div>
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">{{ scenario.description }}</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 12px; font-weight: 600;">BATCH:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
Max limit: {{ scenario.maxLimit }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Different batch count limit scenarios for various system configurations and use cases.'
}
}
}
}
export const MinimalInline: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 20
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 3,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Minimal inline usage:
</div>
<div style="display: flex; align-items: center; gap: 8px; font-size: 14px;">
<span>Run</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
<span>times</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Minimal inline usage of BatchCountEdit within a sentence context.'
}
}
}
}

View File

@@ -40,15 +40,51 @@ import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
interface Props {
batchCount?: number
minQueueCount?: number
maxQueueCount?: number
}
interface Emits {
(e: 'update:batch-count', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
batchCount: undefined,
minQueueCount: 1,
maxQueueCount: undefined
})
const emit = defineEmits<Emits>()
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const { batchCount: storeBatchCount } = storeToRefs(queueSettingsStore)
const settingStore = useSettingStore()
const maxQueueCount = computed(() =>
const defaultMaxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
// Use props if provided, otherwise fallback to store values
const batchCount = computed({
get() {
return props.batchCount ?? storeBatchCount.value
},
set(value: number) {
if (props.batchCount !== undefined) {
emit('update:batch-count', value)
} else {
storeBatchCount.value = value
}
}
})
const minQueueCount = computed(() => props.minQueueCount)
const maxQueueCount = computed(
() => props.maxQueueCount ?? defaultMaxQueueCount.value
)
const handleClick = (increment: boolean) => {
let newCount: number
if (increment) {

View File

@@ -22,8 +22,7 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import type { Ref } from 'vue'
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'

View File

@@ -26,8 +26,7 @@
import { useElementHover, useEventListener } from '@vueuse/core'
import type { IDisposable } from '@xterm/xterm'
import Button from 'primevue/button'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
@@ -47,7 +46,7 @@ const hasSelection = ref(false)
const isHovered = useElementHover(rootEl)
const terminalData = useTerminal(terminalEl)
emit('created', terminalData, ref(rootEl))
emit('created', terminalData, rootEl)
const { terminal } = terminalData
let selectionDisposable: IDisposable | undefined

View File

@@ -3,9 +3,8 @@
</template>
<script setup lang="ts">
import type { IDisposable } from '@xterm/xterm'
import type { Ref } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { IDisposable } from '@xterm/xterm'
import { Ref, onMounted, onUnmounted } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil'

View File

@@ -15,11 +15,10 @@
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { Ref, onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'

View File

@@ -47,8 +47,7 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'

View File

@@ -0,0 +1,152 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ContentDivider from './ContentDivider.vue'
const meta: Meta<typeof ContentDivider> = {
title: 'Components/Common/ContentDivider',
component: ContentDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'ContentDivider provides a visual separation between content sections. It supports both horizontal and vertical orientations with customizable width/thickness.'
}
}
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Direction of the divider line',
defaultValue: 'horizontal'
},
width: {
control: { type: 'range', min: 0.1, max: 10, step: 0.1 },
description: 'Width/thickness of the divider in pixels',
defaultValue: 0.3
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ContentDivider>
export const Horizontal: Story = {
args: {
orientation: 'horizontal',
width: 0.3
},
parameters: {
docs: {
description: {
story:
'Default horizontal divider for separating content sections vertically.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const Vertical: Story = {
args: {
orientation: 'vertical',
width: 0.3
},
parameters: {
docs: {
description: {
story: 'Vertical divider for separating content sections horizontally.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 10px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 10px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const ThickHorizontal: Story = {
args: {
orientation: 'horizontal',
width: 2
},
parameters: {
docs: {
description: {
story:
'Thicker horizontal divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const ThickVertical: Story = {
args: {
orientation: 'vertical',
width: 3
},
parameters: {
docs: {
description: {
story: 'Thicker vertical divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,259 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import EditableText from './EditableText.vue'
const meta: Meta<typeof EditableText> = {
title: 'Components/Common/EditableText',
component: EditableText,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'EditableText allows inline text editing with sophisticated focus management and keyboard handling. It supports automatic text selection, smart filename handling (excluding extensions), and seamless transitions between view and edit modes.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'The text value to display and edit'
},
isEditing: {
control: 'boolean',
description: 'Whether the component is currently in edit mode'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof EditableText>
const createEditableStoryRender =
(
initialText = 'Click to edit this text',
initialEditing = false,
stayEditing = false
) =>
(args: any) => ({
components: { EditableText },
setup() {
const text = ref(args.modelValue || initialText)
const editing = ref(args.isEditing ?? initialEditing)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleEdit = (newValue: string) => {
logAction('Edit completed', newValue)
text.value = newValue
editing.value = stayEditing // Stay in edit mode if specified
}
const startEdit = () => {
editing.value = true
logAction('Edit started')
}
return { args, text, editing, actions, handleEdit, startEdit }
},
template: `
<div style="padding: 20px;">
<div @click="startEdit" style="cursor: pointer; border: 2px dashed #ccc; border-radius: 4px; padding: 20px;">
<div style="margin-bottom: 8px; font-size: 12px; color: #666;">Click text to edit:</div>
<EditableText
:modelValue="text"
:isEditing="editing"
@edit="handleEdit"
/>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createEditableStoryRender(),
args: {
modelValue: 'Click to edit this text',
isEditing: false
}
}
export const AlwaysEditing: Story = {
render: createEditableStoryRender('Always in edit mode', true, true),
args: {
modelValue: 'Always in edit mode',
isEditing: true
}
}
export const FilenameEditing: Story = {
render: () => ({
components: { EditableText },
setup() {
const filenames = ref([
'my_workflow.json',
'image_processing.png',
'model_config.yaml',
'final_render.mp4'
])
const actions = ref<string[]>([])
const logAction = (action: string, filename: string, newName: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(
`${action}: "${filename}" → "${newName}" (${timestamp})`
)
if (actions.value.length > 5) actions.value.pop()
console.log(action, { filename, newName })
}
const handleFilenameEdit = (index: number, newValue: string) => {
const oldName = filenames.value[index]
filenames.value[index] = newValue
logAction('Filename changed', oldName, newValue)
}
return { filenames, actions, handleFilenameEdit }
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: bold;">File Browser (click filenames to edit):</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(filename, index) in filenames" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f9f9f9; border-radius: 4px;">
<i style="margin-right: 8px; color: #666;" class="pi pi-file"></i>
<EditableText
:modelValue="filename"
:isEditing="false"
@edit="(newValue) => handleFilenameEdit(index, newValue)"
/>
</div>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
}
export const LongText: Story = {
render: createEditableStoryRender(
'This is a much longer text that demonstrates how the EditableText component handles longer content with multiple words and potentially line wrapping scenarios.'
),
args: {
modelValue:
'This is a much longer text that demonstrates how the EditableText component handles longer content.',
isEditing: false
}
}
export const EmptyState: Story = {
render: createEditableStoryRender(''),
args: {
modelValue: '',
isEditing: false
}
}
export const SingleCharacter: Story = {
render: createEditableStoryRender('A'),
args: {
modelValue: 'A',
isEditing: false
}
}
// ComfyUI usage examples
export const WorkflowNaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const workflows = ref([
'Portrait Enhancement',
'Landscape Generation',
'Style Transfer Workflow',
'Untitled Workflow'
])
const handleWorkflowRename = (index: number, newName: string) => {
workflows.value[index] = newName
console.log('Workflow renamed:', { index, newName })
}
return { workflows, handleWorkflowRename }
},
template: `
<div style="padding: 20px; width: 300px;">
<div style="margin-bottom: 16px; font-weight: bold;">Workflow Library</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div v-for="(workflow, index) in workflows" :key="index"
style="padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: white;">
<EditableText
:modelValue="workflow"
:isEditing="false"
@edit="(newName) => handleWorkflowRename(index, newName)"
style="font-size: 14px; font-weight: 500;"
/>
<div style="margin-top: 4px; font-size: 11px; color: #666;">
Last modified: 2 hours ago
</div>
</div>
</div>
</div>
`
})
}
export const ModelRenaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const models = ref([
'stable-diffusion-v1-5.safetensors',
'controlnet_depth.pth',
'vae-ft-mse-840000-ema.ckpt'
])
const handleModelRename = (index: number, newName: string) => {
models.value[index] = newName
console.log('Model renamed:', { index, newName })
}
return { models, handleModelRename }
},
template: `
<div style="padding: 20px; width: 350px;">
<div style="margin-bottom: 16px; font-weight: bold;">Model Manager</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(model, index) in models" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f8f8f8; border-radius: 4px;">
<i style="margin-right: 8px; color: #4a90e2;" class="pi pi-box"></i>
<EditableText
:modelValue="model"
:isEditing="false"
@edit="(newName) => handleModelRename(index, newName)"
style="flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 12px;"
/>
</div>
</div>
</div>
`
})
}

View File

@@ -7,7 +7,7 @@
<InputText
v-else
ref="inputRef"
v-model:model-value="inputValue"
v-model:modelValue="inputValue"
v-focus
type="text"
size="small"

View File

@@ -17,7 +17,7 @@
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
const props = defineProps<{
extension: VueExtension | CustomExtension

View File

@@ -0,0 +1,672 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { FormItem as FormItemType } from '@/types/settingTypes'
import FormItem from './FormItem.vue'
const meta: Meta = {
title: 'Components/Common/FormItem',
component: FormItem as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'FormItem is a generalized form component that dynamically renders different input types based on configuration. Supports text, number, boolean, combo, slider, knob, color, image, and custom renderer inputs with proper labeling and accessibility.'
}
}
},
argTypes: {
item: {
control: 'object',
description:
'FormItem configuration object defining the input type and properties'
},
formValue: {
control: 'text',
description: 'The current form value (v-model)',
defaultValue: ''
},
id: {
control: 'text',
description: 'Optional HTML id for the form input',
defaultValue: undefined
},
labelClass: {
control: 'text',
description: 'Additional CSS classes for the label',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const TextInput: Story = {
render: (args: any) => ({
components: { FormItem },
setup() {
return { args }
},
data() {
return {
value: args.formValue || 'Default text value',
textItem: {
name: 'Workflow Name',
type: 'text',
tooltip: 'Enter a descriptive name for your workflow',
attrs: {
placeholder: 'e.g., SDXL Portrait Generation'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Text value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Text input form item with tooltip:
</div>
<FormItem
:item="textItem"
:formValue="value"
@update:formValue="updateValue"
id="workflow-name"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current value: "{{ value }}"
</div>
</div>
`
}),
args: {
formValue: 'My Workflow'
},
parameters: {
docs: {
description: {
story:
'Text input FormItem with tooltip and placeholder. Hover over the info icon to see the tooltip.'
}
}
}
}
export const NumberInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 7.5,
numberItem: {
name: 'CFG Scale',
type: 'number',
tooltip:
'Classifier-free guidance scale controls how closely the AI follows your prompt',
attrs: {
min: 1,
max: 30,
step: 0.5,
showButtons: true
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('CFG scale updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Number input with controls and constraints:
</div>
<FormItem
:item="numberItem"
:formValue="value"
@update:formValue="updateValue"
id="cfg-scale"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current CFG scale: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Number input FormItem with min/max constraints and increment buttons for CFG scale parameter.'
}
}
}
}
export const BooleanToggle: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: false,
booleanItem: {
name: 'Enable GPU Acceleration',
type: 'boolean',
tooltip: 'Use GPU for faster processing when available'
} as FormItemType
}
},
methods: {
updateValue(newValue: boolean) {
console.log('GPU acceleration toggled:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Boolean toggle switch form item:
</div>
<FormItem
:item="booleanItem"
:formValue="value"
@update:formValue="updateValue"
id="gpu-accel"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
GPU acceleration: {{ value ? 'Enabled' : 'Disabled' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Boolean FormItem using ToggleSwitch component for enable/disable settings.'
}
}
}
}
export const ComboSelect: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'euler_a',
comboItem: {
name: 'Sampling Method',
type: 'combo',
tooltip: 'Algorithm used for denoising during generation',
options: [
'euler_a',
'euler',
'heun',
'dpm_2',
'dpm_2_ancestral',
'lms',
'dpm_fast',
'dpm_adaptive',
'dpmpp_2s_ancestral',
'dpmpp_sde',
'dpmpp_2m'
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Sampling method updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo select with sampling methods:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-method"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Combo select FormItem with ComfyUI sampling methods showing dropdown selection.'
}
}
}
}
export const SliderInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 0.7,
sliderItem: {
name: 'Denoise Strength',
type: 'slider',
tooltip:
'How much to denoise the input image (0 = no change, 1 = complete redraw)',
attrs: {
min: 0,
max: 1,
step: 0.01
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Denoise strength updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Slider input with precise decimal control:
</div>
<FormItem
:item="sliderItem"
:formValue="value"
@update:formValue="updateValue"
id="denoise-strength"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Denoise: {{ (value * 100).toFixed(0) }}%
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Slider FormItem for denoise strength with percentage display and fine-grained control.'
}
}
}
}
export const KnobInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 20,
knobItem: {
name: 'Sampling Steps',
type: 'knob',
tooltip:
'Number of denoising steps - more steps = higher quality but slower generation',
attrs: {
min: 1,
max: 150,
step: 1
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Steps updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Knob input for sampling steps:
</div>
<FormItem
:item="knobItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-steps"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Steps: {{ value }} ({{ value < 10 ? 'Very Fast' : value < 30 ? 'Fast' : value < 50 ? 'Balanced' : 'High Quality' }})
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Knob FormItem for sampling steps with quality indicator based on step count.'
}
}
}
}
export const MultipleFormItems: Story = {
render: () => ({
components: { FormItem },
data() {
return {
widthValue: 512,
heightValue: 512,
stepsValue: 20,
cfgValue: 7.5,
samplerValue: 'euler_a',
hiresValue: false
}
},
computed: {
formItems() {
return [
{
name: 'Width',
type: 'number',
tooltip: 'Image width in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Height',
type: 'number',
tooltip: 'Image height in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Sampling Steps',
type: 'knob',
tooltip: 'Number of denoising steps',
attrs: { min: 1, max: 150, step: 1 }
},
{
name: 'CFG Scale',
type: 'slider',
tooltip: 'Classifier-free guidance scale',
attrs: { min: 1, max: 30, step: 0.5 }
},
{
name: 'Sampler',
type: 'combo',
tooltip: 'Sampling algorithm',
options: ['euler_a', 'euler', 'heun', 'dpm_2', 'dpmpp_2m']
},
{
name: 'High-res Fix',
type: 'boolean',
tooltip: 'Enable high-resolution generation'
}
] as FormItemType[]
},
allSettings() {
return {
width: this.widthValue,
height: this.heightValue,
steps: this.stepsValue,
cfg: this.cfgValue,
sampler: this.samplerValue,
enableHires: this.hiresValue
}
}
},
template: `
<div style="padding: 20px; min-width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI Generation Settings</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multiple form items demonstrating different input types in a realistic settings panel.
</p>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="display: flex; flex-direction: column; gap: 16px;">
<FormItem
:item="formItems[0]"
:formValue="widthValue"
@update:formValue="(value) => widthValue = value"
id="form-width"
/>
<FormItem
:item="formItems[1]"
:formValue="heightValue"
@update:formValue="(value) => heightValue = value"
id="form-height"
/>
<FormItem
:item="formItems[2]"
:formValue="stepsValue"
@update:formValue="(value) => stepsValue = value"
id="form-steps"
/>
<FormItem
:item="formItems[3]"
:formValue="cfgValue"
@update:formValue="(value) => cfgValue = value"
id="form-cfg"
/>
<FormItem
:item="formItems[4]"
:formValue="samplerValue"
@update:formValue="(value) => samplerValue = value"
id="form-sampler"
/>
<FormItem
:item="formItems[5]"
:formValue="hiresValue"
@update:formValue="(value) => hiresValue = value"
id="form-hires"
/>
</div>
</div>
<div style="margin-top: 16px; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Current Settings:</div>
<div style="font-family: monospace; font-size: 12px; color: #4b5563;">
{{ JSON.stringify(allSettings, null, 2) }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Multiple FormItems demonstrating all major input types in a realistic ComfyUI settings panel.'
}
}
}
}
export const WithCustomLabels: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'custom_model.safetensors',
customItem: {
name: 'Model File',
type: 'text',
tooltip: 'Select the checkpoint model file to use for generation',
attrs: {
placeholder: 'Select or enter model filename...'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Model file updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
FormItem with custom label styling and slots:
</div>
<FormItem
:item="customItem"
:formValue="value"
@update:formValue="updateValue"
id="model-file"
:labelClass="{ 'font-bold': true, 'text-blue-600': true }"
>
<template #name-prefix>
<i class="pi pi-download" style="margin-right: 6px; color: #3b82f6;"></i>
</template>
<template #name-suffix>
<span style="margin-left: 6px; font-size: 10px; color: #ef4444;">*</span>
</template>
</FormItem>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected model: {{ value || 'None' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'FormItem with custom label styling and prefix/suffix slots for enhanced UI elements.'
}
}
}
}
export const ColorPicker: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: '#3b82f6',
colorItem: {
name: 'Theme Accent Color',
type: 'color',
tooltip: 'Primary accent color for the interface theme'
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Color updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Color picker form item:
</div>
<FormItem
:item="colorItem"
:formValue="value"
@update:formValue="updateValue"
id="theme-color"
/>
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<div style="font-size: 12px; color: #6b7280;">Preview:</div>
<div
:style="{
backgroundColor: value,
width: '40px',
height: '20px',
borderRadius: '4px',
border: '1px solid #e2e8f0'
}"
></div>
<span style="font-family: monospace; font-size: 12px;">{{ value }}</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Color picker FormItem with live preview showing the selected color value.'
}
}
}
}
export const ComboWithComplexOptions: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'medium',
comboItem: {
name: 'Quality Preset',
type: 'combo',
tooltip:
'Predefined quality settings that adjust multiple parameters',
options: [
{ text: 'Draft (Fast)', value: 'draft' },
{ text: 'Medium Quality', value: 'medium' },
{ text: 'High Quality', value: 'high' },
{ text: 'Ultra (Slow)', value: 'ultra' }
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Quality preset updated:', newValue)
this.value = newValue
}
},
computed: {
presetDescription() {
const descriptions = {
draft: 'Fast generation with 10 steps, suitable for previews',
medium: 'Balanced quality with 20 steps, good for most use cases',
high: 'High quality with 40 steps, slower but better results',
ultra: 'Maximum quality with 80 steps, very slow but best results'
}
return (descriptions as any)[this.value] || 'Unknown preset'
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo with complex option objects:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="quality-preset"
/>
<div style="margin-top: 12px; padding: 8px; background: rgba(0,0,0,0.05); border-radius: 4px;">
<div style="font-size: 12px; font-weight: 600; color: #374151;">{{ presetDescription }}</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complex combo FormItem with object options showing text/value pairs and descriptions.'
}
}
}
}

View File

@@ -21,7 +21,7 @@
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:model-value="formValue"
v-model:modelValue="formValue"
:aria-labelledby="`${props.id}-label`"
v-bind="getFormAttrs(props.item)"
/>
@@ -44,7 +44,7 @@ import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
import InputKnob from '@/components/common/InputKnob.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { FormItem } from '@/platform/settings/types'
import { FormItem } from '@/platform/settings/types'
const formValue = defineModel<any>('formValue')
const props = defineProps<{

View File

@@ -0,0 +1,566 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import InputKnob from './InputKnob.vue'
const meta: Meta<typeof InputKnob> = {
title: 'Components/Common/InputKnob',
component: InputKnob,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'InputKnob combines a PrimeVue Knob and InputNumber for dual input methods. It features value synchronization, range validation, step constraints, and automatic decimal precision handling based on step values.'
}
}
},
argTypes: {
modelValue: {
control: { type: 'number' },
description: 'Current numeric value (v-model)',
defaultValue: 50
},
min: {
control: { type: 'number' },
description: 'Minimum allowed value',
defaultValue: 0
},
max: {
control: { type: 'number' },
description: 'Maximum allowed value',
defaultValue: 100
},
step: {
control: { type: 'number', step: 0.01 },
description: 'Step increment for both knob and input',
defaultValue: 1
},
resolution: {
control: { type: 'number', min: 0, max: 5 },
description:
'Number of decimal places to display (auto-calculated from step if not provided)',
defaultValue: undefined
},
inputClass: {
control: 'text',
description: 'Additional CSS classes for the number input',
defaultValue: undefined
},
knobClass: {
control: 'text',
description: 'Additional CSS classes for the knob',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof InputKnob>
export const Default: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 50
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Current Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
:inputClass="args.inputClass"
:knobClass="args.knobClass"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 50,
min: 0,
max: 100,
step: 1
},
parameters: {
docs: {
description: {
story:
'Default InputKnob with range 0-100 and step of 1. Use either the knob or number input to change the value.'
}
}
}
}
export const DecimalPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 2.5
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Decimal value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Precision Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 2.5,
min: 0,
max: 10,
step: 0.1
},
parameters: {
docs: {
description: {
story:
'InputKnob with decimal step (0.1) - automatically shows one decimal place based on step precision.'
}
}
}
}
export const HighPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 1.234
}
},
methods: {
handleUpdate(newValue: number) {
console.log('High precision value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>High Precision: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 1.234,
min: 0,
max: 5,
step: 0.001,
resolution: 3
},
parameters: {
docs: {
description: {
story:
'High precision InputKnob with step of 0.001 and 3 decimal places resolution.'
}
}
}
}
export const LargeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 500
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Large range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Large Range Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 500,
min: 0,
max: 1000,
step: 10
},
parameters: {
docs: {
description: {
story:
'InputKnob with large range (0-1000) and step of 10 for coarser control.'
}
}
}
}
export const NegativeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 0
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Negative range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Negative Range: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 0,
min: -50,
max: 50,
step: 5
},
parameters: {
docs: {
description: {
story:
'InputKnob with negative range (-50 to 50) demonstrating bidirectional control.'
}
}
}
}
// ComfyUI specific examples
export const CFGScale: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
cfgScale: 7.5
}
},
methods: {
updateCFG(value: number) {
console.log('CFG Scale updated:', value)
this.cfgScale = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
CFG Scale
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Controls how closely the model follows the prompt
</div>
<InputKnob
:modelValue="cfgScale"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="updateCFG"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ cfgScale }} (Recommended: 6-8)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI CFG Scale parameter example - common parameter for controlling prompt adherence.'
}
}
}
}
export const SamplingSteps: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
steps: 20
}
},
methods: {
updateSteps(value: number) {
console.log('Sampling steps updated:', value)
this.steps = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Sampling Steps
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Number of denoising steps for image generation
</div>
<InputKnob
:modelValue="steps"
:min="1"
:max="150"
:step="1"
@update:modelValue="updateSteps"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ steps }} (Higher = better quality, slower)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Sampling Steps parameter example - controls generation quality vs speed.'
}
}
}
}
export const DenoiseStrength: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
denoise: 1.0
}
},
methods: {
updateDenoise(value: number) {
console.log('Denoise strength updated:', value)
this.denoise = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Denoise Strength
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
How much noise to add (1.0 = complete denoising)
</div>
<InputKnob
:modelValue="denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="updateDenoise"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ denoise }} (0.0 = no change, 1.0 = full generation)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Denoise Strength parameter example - high precision control for img2img workflows.'
}
}
}
}
export const CustomStyling: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
value: 75
}
},
methods: {
updateValue(newValue: number) {
console.log('Custom styled value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: 600;">
Custom Styled InputKnob
</div>
<InputKnob
:modelValue="value"
:min="0"
:max="100"
:step="1"
inputClass="custom-input"
knobClass="custom-knob"
@update:modelValue="updateValue"
/>
<style>
.custom-input {
font-weight: bold;
color: #2563eb;
}
.custom-knob {
transform: scale(1.2);
}
</style>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'InputKnob with custom CSS classes applied to both knob and input components.'
}
}
}
}
// Gallery showing different parameter types
export const ParameterGallery: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
params: {
cfg: 7.5,
steps: 20,
denoise: 1.0,
temperature: 0.8
}
}
},
methods: {
updateParam(param: string, value: number) {
console.log(`${param} updated:`, value)
;(this.params as any)[param] = value
}
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 20px; max-width: 600px;">
<div>
<div style="font-weight: 600; margin-bottom: 8px;">CFG Scale</div>
<InputKnob
:modelValue="params.cfg"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="(v) => updateParam('cfg', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Steps</div>
<InputKnob
:modelValue="params.steps"
:min="1"
:max="100"
:step="1"
@update:modelValue="(v) => updateParam('steps', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Denoise</div>
<InputKnob
:modelValue="params.denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="(v) => updateParam('denoise', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Temperature</div>
<InputKnob
:modelValue="params.temperature"
:min="0"
:max="2"
:step="0.1"
@update:modelValue="(v) => updateParam('temperature', v)"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different parameter types commonly used in ComfyUI workflows.'
}
}
}
}

View File

@@ -10,7 +10,7 @@
class="absolute inset-0"
/>
<img
v-if="cachedSrc"
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
@@ -77,8 +77,8 @@ const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoadVal) => {
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
@@ -93,7 +93,7 @@ watch(
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoadVal) {
} else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}

View File

@@ -0,0 +1,256 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NoResultsPlaceholder from './NoResultsPlaceholder.vue'
const meta: Meta<typeof NoResultsPlaceholder> = {
title: 'Components/Common/NoResultsPlaceholder',
component: NoResultsPlaceholder,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'NoResultsPlaceholder displays an empty state with optional icon, title, message, and action button. Built with PrimeVue Card component and customizable styling.'
}
}
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes to apply to the wrapper',
defaultValue: undefined
},
icon: {
control: 'text',
description: 'PrimeIcons icon class to display',
defaultValue: undefined
},
title: {
control: 'text',
description: 'Main heading text',
defaultValue: 'No Results'
},
message: {
control: 'text',
description: 'Descriptive message text (supports multi-line with \\n)',
defaultValue: 'No items found'
},
textClass: {
control: 'text',
description: 'Additional CSS classes for the message text',
defaultValue: undefined
},
buttonLabel: {
control: 'text',
description: 'Label for action button (button hidden if not provided)',
defaultValue: undefined
},
onAction: {
action: 'action',
description: 'Event emitted when action button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof NoResultsPlaceholder>
export const Default: Story = {
args: {
title: 'No Results',
message: 'No items found'
},
parameters: {
docs: {
description: {
story: 'Basic placeholder with just title and message.'
}
}
}
}
export const WithIcon: Story = {
args: {
icon: 'pi pi-search',
title: 'No Search Results',
message: 'Try adjusting your search criteria or filters'
},
parameters: {
docs: {
description: {
story:
'Placeholder with a search icon to indicate empty search results.'
}
}
}
}
export const WithActionButton: Story = {
args: {
icon: 'pi pi-plus',
title: 'No Items',
message: 'Get started by creating your first item',
buttonLabel: 'Create Item'
},
parameters: {
docs: {
description: {
story:
'Placeholder with an action button to help users take the next step.'
}
}
}
}
export const MultilineMessage: Story = {
args: {
icon: 'pi pi-exclamation-triangle',
title: 'Connection Error',
message:
'Unable to load data from the server.\nPlease check your internet connection\nand try again.',
buttonLabel: 'Retry'
},
parameters: {
docs: {
description: {
story: 'Placeholder with multi-line message using newline characters.'
}
}
}
}
export const EmptyWorkflow: Story = {
args: {
icon: 'pi pi-sitemap',
title: 'No Workflows',
message:
'Create your first ComfyUI workflow to get started with image generation',
buttonLabel: 'New Workflow'
},
parameters: {
docs: {
description: {
story: 'Example for empty workflow state in ComfyUI context.'
}
}
}
}
export const EmptyModels: Story = {
args: {
icon: 'pi pi-download',
title: 'No Models Found',
message:
'Download models from the model manager to start generating images',
buttonLabel: 'Open Model Manager'
},
parameters: {
docs: {
description: {
story: 'Example for empty models state with download action.'
}
}
}
}
export const FilteredResults: Story = {
args: {
icon: 'pi pi-filter',
title: 'No Matching Results',
message:
'No items match your current filters.\nTry clearing some filters to see more results.',
buttonLabel: 'Clear Filters'
},
parameters: {
docs: {
description: {
story: 'Placeholder for filtered results with option to clear filters.'
}
}
}
}
export const CustomStyling: Story = {
args: {
class: 'custom-placeholder',
icon: 'pi pi-star',
title: 'No Favorites',
message: 'Mark items as favorites to see them here',
textClass: 'text-muted-foreground',
buttonLabel: 'Browse Items'
},
parameters: {
docs: {
description: {
story: 'Placeholder with custom CSS classes applied.'
}
}
}
}
// Interactive story to test action event
export const Interactive: Story = {
args: {
icon: 'pi pi-cog',
title: 'Configuration Required',
message: 'Complete the setup to continue',
buttonLabel: 'Configure'
},
parameters: {
docs: {
description: {
story:
'Interactive placeholder - click the button to see the action event in the Actions panel.'
}
}
}
}
// Gallery view showing different icon options
export const IconGallery: Story = {
render: () => ({
components: { NoResultsPlaceholder },
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; padding: 20px;">
<NoResultsPlaceholder
icon="pi pi-search"
title="Search"
message="No search results"
/>
<NoResultsPlaceholder
icon="pi pi-inbox"
title="Empty Inbox"
message="No messages"
/>
<NoResultsPlaceholder
icon="pi pi-heart"
title="No Favorites"
message="No favorite items"
/>
<NoResultsPlaceholder
icon="pi pi-folder-open"
title="Empty Folder"
message="This folder is empty"
/>
<NoResultsPlaceholder
icon="pi pi-shopping-cart"
title="Empty Cart"
message="Your cart is empty"
/>
<NoResultsPlaceholder
icon="pi pi-users"
title="No Users"
message="No users found"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing different icon options and use cases.'
}
}
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import RefreshButton from './RefreshButton.vue'
const meta: Meta<typeof RefreshButton> = {
title: 'Components/Common/RefreshButton',
component: RefreshButton,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'RefreshButton is an interactive button with loading state management. It shows a refresh icon that transforms into a progress spinner when active, using v-model for state control.'
}
}
},
argTypes: {
modelValue: {
control: 'boolean',
description: 'Active/loading state of the button (v-model)'
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled'
},
outlined: {
control: 'boolean',
description: 'Whether to use outlined button style'
},
severity: {
control: 'select',
options: ['secondary', 'success', 'info', 'warn', 'help', 'danger'],
description: 'PrimeVue severity level for button styling'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof RefreshButton>
const createStoryRender =
(initialState = false, asyncDuration = 2000) =>
(args: any) => ({
components: { RefreshButton },
setup() {
const isActive = ref(args.modelValue ?? initialState)
const actions = ref<string[]>([])
const logAction = (action: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(`${action} (${timestamp})`)
if (actions.value.length > 5) actions.value.pop()
console.log(action)
}
const handleRefresh = async () => {
logAction('Refresh started')
isActive.value = true
await new Promise((resolve) => setTimeout(resolve, asyncDuration))
isActive.value = false
logAction('Refresh completed')
}
return { args, isActive, actions, handleRefresh }
},
template: `
<div style="padding: 20px;">
<RefreshButton
v-model="isActive"
v-bind="args"
@refresh="handleRefresh"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createStoryRender(),
args: {
modelValue: false,
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Active: Story = {
render: createStoryRender(true),
args: {
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Disabled: Story = {
render: createStoryRender(),
args: {
disabled: true,
outlined: true,
severity: 'secondary'
}
}
export const Filled: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: false,
severity: 'secondary'
}
}
export const SuccessSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'success'
}
}
export const DangerSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'danger'
}
}
// Simplified gallery showing all severities
export const SeverityGallery: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const severities = [
'secondary',
'success',
'info',
'warn',
'help',
'danger'
]
const states = ref(Object.fromEntries(severities.map((s) => [s, false])))
const refresh = async (severity: string) => {
console.log(`Refreshing with ${severity} severity`)
states.value[severity] = true
await new Promise((resolve) => setTimeout(resolve, 2000))
states.value[severity] = false
}
return { severities, states, refresh }
},
template: `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; padding: 20px;">
<div v-for="severity in severities" :key="severity" style="text-align: center;">
<RefreshButton
v-model="states[severity]"
:severity="severity"
@refresh="refresh(severity)"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666; text-transform: capitalize;">
{{ severity }}
</div>
</div>
</div>
`
})
}
// ComfyUI usage examples
export const WorkflowRefresh: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const isRefreshing = ref(false)
const refreshWorkflows = async () => {
console.log('Refreshing workflows...')
isRefreshing.value = true
await new Promise((resolve) => setTimeout(resolve, 3000))
isRefreshing.value = false
console.log('Workflows refreshed!')
}
return { isRefreshing, refreshWorkflows }
},
template: `
<div style="display: flex; align-items: center; gap: 12px; padding: 20px;">
<span>Workflows:</span>
<RefreshButton v-model="isRefreshing" @refresh="refreshWorkflows" />
</div>
`
})
}

View File

@@ -32,7 +32,7 @@
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
import { PrimeVueSeverity } from '@/types/primeVueTypes'
const {
disabled,

View File

@@ -0,0 +1,265 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const meta: Meta = {
title: 'Components/Common/SearchBox',
component: SearchBox as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchBox provides a comprehensive search interface with debounced input, active filter chips, and optional filter button. Features automatic clear functionality and sophisticated event handling for search workflows.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'Current search query text (v-model)'
},
placeholder: {
control: 'text',
description: 'Placeholder text for the search input'
},
icon: {
control: 'text',
description: 'PrimeIcons icon class for the search icon'
},
debounceTime: {
control: { type: 'number', min: 0, max: 1000, step: 50 },
description: 'Debounce delay in milliseconds for search events'
},
filterIcon: {
control: 'text',
description: 'Optional filter button icon (button hidden if not provided)'
},
filters: {
control: 'object',
description: 'Array of active filter chips to display'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
const createSearchBoxRender =
(initialFilters: any[] = []) =>
(args: any) => ({
components: { SearchBox },
setup() {
const searchQuery = ref(args.modelValue || '')
const filters = ref(args.filters || initialFilters)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleUpdate = (value: string) => {
searchQuery.value = value
logAction('Search text updated', value)
}
const handleSearch = (value: string, searchFilters: any[]) => {
logAction(
'Debounced search',
`"${value}" with ${searchFilters.length} filters`
)
}
const handleShowFilter = () => {
logAction('Filter button clicked')
}
const handleRemoveFilter = (filter: any) => {
const index = filters.value.findIndex((f: any) => f === filter)
if (index > -1) {
filters.value.splice(index, 1)
logAction('Filter removed', filter.label || filter)
}
}
return {
args,
searchQuery,
filters,
actions,
handleUpdate,
handleSearch,
handleShowFilter,
handleRemoveFilter
}
},
template: `
<div style="width: 400px; padding: 20px;">
<SearchBox
:modelValue="searchQuery"
v-bind="args"
:filters="filters"
@update:modelValue="handleUpdate"
@search="handleSearch"
@showFilter="handleShowFilter"
@removeFilter="handleRemoveFilter"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search nodes...',
icon: 'pi pi-search',
debounceTime: 300,
filters: []
}
}
export const WithFilters: Story = {
render: createSearchBoxRender([
{ label: 'Image', type: 'category' },
{ label: 'Sampling', type: 'category' },
{ label: 'Recent', type: 'sort' }
]),
args: {
modelValue: 'stable diffusion',
placeholder: 'Search models...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter'
}
}
export const WithFilterButton: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search workflows...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter',
filters: []
}
}
export const FastDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Fast search (50ms debounce)...',
icon: 'pi pi-search',
debounceTime: 50,
filters: []
}
}
export const SlowDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Slow search (1000ms debounce)...',
icon: 'pi pi-search',
debounceTime: 1000,
filters: []
}
}
// ComfyUI examples
export const NodeSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('')
const nodeFilters = ref([
{ label: 'Sampling', type: 'category' },
{ label: 'Popular', type: 'sort' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching nodes:', { value, filters })
}
const handleRemoveFilter = (filter: any) => {
const index = nodeFilters.value.findIndex((f) => f === filter)
if (index > -1) {
nodeFilters.value.splice(index, 1)
}
}
return {
searchQuery,
nodeFilters,
handleSearch,
handleRemoveFilter
}
},
template: `
<div style="width: 300px;">
<div style="margin-bottom: 8px; font-weight: 600;">Node Library</div>
<SearchBox
v-model="searchQuery"
placeholder="Search nodes..."
icon="pi pi-box"
:debounceTime="300"
filterIcon="pi pi-filter"
:filters="nodeFilters"
@search="handleSearch"
@removeFilter="handleRemoveFilter"
/>
</div>
`
})
}
export const ModelSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('stable-diffusion')
const modelFilters = ref([
{ label: 'SDXL', type: 'version' },
{ label: 'Checkpoints', type: 'type' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching models:', { value, filters })
}
return {
searchQuery,
modelFilters,
handleSearch
}
},
template: `
<div style="width: 350px;">
<div style="margin-bottom: 8px; font-weight: 600;">Model Manager</div>
<SearchBox
v-model="searchQuery"
placeholder="Search models..."
icon="pi pi-database"
:debounceTime="400"
filterIcon="pi pi-sliders-h"
:filters="modelFilters"
@search="handleSearch"
/>
</div>
`
})
}

View File

@@ -0,0 +1,279 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SearchFilterChip from './SearchFilterChip.vue'
const meta: Meta<typeof SearchFilterChip> = {
title: 'Components/Common/SearchFilterChip',
component: SearchFilterChip,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchFilterChip displays a removable chip with a badge and text, commonly used for showing active filters in search interfaces. Built with PrimeVue Chip and Badge components.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Main text content displayed on the chip',
defaultValue: 'Filter'
},
badge: {
control: 'text',
description: 'Badge text/number displayed before the main text',
defaultValue: '1'
},
badgeClass: {
control: 'select',
options: ['i-badge', 'o-badge', 'c-badge', 's-badge'],
description:
'CSS class for badge styling (i-badge: green, o-badge: red, c-badge: blue, s-badge: yellow)',
defaultValue: 'i-badge'
},
onRemove: {
description: 'Event emitted when the chip remove button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof SearchFilterChip>
export const Default: Story = {
args: {
text: 'Active Filter',
badge: '5',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Default search filter chip with green badge.'
}
}
}
}
export const InputBadge: Story = {
args: {
text: 'Inputs',
badge: '3',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with green input badge (i-badge class).'
}
}
}
}
export const OutputBadge: Story = {
args: {
text: 'Outputs',
badge: '2',
badgeClass: 'o-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with red output badge (o-badge class).'
}
}
}
}
export const CategoryBadge: Story = {
args: {
text: 'Category',
badge: '8',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with blue category badge (c-badge class).'
}
}
}
}
export const StatusBadge: Story = {
args: {
text: 'Status',
badge: '12',
badgeClass: 's-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with yellow status badge (s-badge class).'
}
}
}
}
export const LongText: Story = {
args: {
text: 'Very Long Filter Name That Might Wrap',
badge: '999+',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Filter chip with long text and large badge number to test layout.'
}
}
}
}
export const SingleCharacterBadge: Story = {
args: {
text: 'Model Type',
badge: 'A',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with single character badge.'
}
}
}
}
export const ComfyUIFilters: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 20px;">
<SearchFilterChip
text="Sampling Nodes"
badge="5"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Image Outputs"
badge="3"
badgeClass="o-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Conditioning"
badge="12"
badgeClass="c-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Advanced"
badge="7"
badgeClass="s-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="SDXL Models"
badge="24"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="ControlNet"
badge="8"
badgeClass="o-badge"
@remove="handleRemove"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Example showing multiple filter chips as they might appear in ComfyUI search interface.'
}
}
}
}
export const Interactive: Story = {
args: {
text: 'Removable Filter',
badge: '42',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Interactive chip - click the X button to see the remove event in the Actions panel.'
}
}
}
}
// Gallery showing all badge styles
export const BadgeStyleGallery: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 20px; max-width: 400px;">
<div style="text-align: center;">
<SearchFilterChip
text="Input Badge"
badge="I"
badgeClass="i-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Green (i-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Output Badge"
badge="O"
badgeClass="o-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Red (o-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Category Badge"
badge="C"
badgeClass="c-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Blue (c-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Status Badge"
badge="S"
badgeClass="s-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Yellow (s-badge)</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing all available badge styles and their colors.'
}
}
}
}

View File

@@ -0,0 +1,250 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TextDivider from './TextDivider.vue'
const meta: Meta<typeof TextDivider> = {
title: 'Components/Common/TextDivider',
component: TextDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TextDivider combines text with a PrimeVue divider to create labeled section separators. The text can be positioned on either side of the divider line with various styling options.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Text content to display alongside the divider',
defaultValue: 'Section'
},
position: {
control: 'select',
options: ['left', 'right'],
description: 'Position of text relative to the divider',
defaultValue: 'left'
},
align: {
control: 'select',
options: ['left', 'center', 'right', 'top', 'bottom'],
description: 'Alignment of the divider line',
defaultValue: 'center'
},
type: {
control: 'select',
options: ['solid', 'dashed', 'dotted'],
description: 'Style of the divider line',
defaultValue: 'solid'
},
layout: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Layout direction of the divider',
defaultValue: 'horizontal'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof TextDivider>
export const Default: Story = {
args: {
text: 'Section Title',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Default text divider with text on the left side of a solid horizontal line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const RightPosition: Story = {
args: {
text: 'Section Title',
position: 'right',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with text positioned on the right side of the line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DashedStyle: Story = {
args: {
text: 'Dashed Section',
position: 'left',
align: 'center',
type: 'dashed',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dashed line style for a softer visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DottedStyle: Story = {
args: {
text: 'Dotted Section',
position: 'right',
align: 'center',
type: 'dotted',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dotted line style for subtle content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const VerticalLayout: Story = {
args: {
text: 'Vertical',
position: 'left',
align: 'center',
type: 'solid',
layout: 'vertical'
},
parameters: {
docs: {
description: {
story:
'Text divider in vertical layout for side-by-side content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const LongText: Story = {
args: {
text: 'Configuration Settings and Options',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with longer text content to demonstrate text wrapping and spacing behavior.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,651 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import TreeExplorer from './TreeExplorer.vue'
const meta: Meta = {
title: 'Components/Common/TreeExplorer',
component: TreeExplorer as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TreeExplorer provides a sophisticated tree navigation component with expandable nodes, selection, context menus, drag-and-drop support, and customizable node rendering. Features folder operations, renaming, deletion, and advanced tree manipulation capabilities.'
}
}
},
argTypes: {
root: {
control: 'object',
description: 'Root tree node with hierarchical structure'
},
expandedKeys: {
control: 'object',
description: 'Object tracking which nodes are expanded (v-model)',
defaultValue: {}
},
selectionKeys: {
control: 'object',
description: 'Object tracking which nodes are selected (v-model)',
defaultValue: {}
},
class: {
control: 'text',
description: 'Additional CSS classes for the tree',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const BasicTree: Story = {
render: (args: any) => ({
components: { TreeExplorer },
setup() {
return { args }
},
data() {
return {
expanded: {},
selected: {},
treeData: {
key: 'root',
label: 'Root',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'portrait',
label: 'Portrait Generation.json',
icon: 'pi pi-file'
},
{
key: 'landscape',
label: 'Landscape SDXL.json',
icon: 'pi pi-file'
},
{ key: 'anime', label: 'Anime Style.json', icon: 'pi pi-file' }
]
},
{
key: 'models',
label: 'Models',
icon: 'pi pi-download',
children: [
{
key: 'checkpoints',
label: 'Checkpoints',
icon: 'pi pi-folder',
children: [
{
key: 'sdxl',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'sd15',
label: 'SD_1.5.safetensors',
icon: 'pi pi-file'
}
]
},
{
key: 'lora',
label: 'LoRA',
icon: 'pi pi-folder',
children: [
{
key: 'portrait_lora',
label: 'portrait_enhance.safetensors',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'outputs',
label: 'Outputs',
icon: 'pi pi-images',
children: [
{
key: 'output1',
label: 'ComfyUI_00001_.png',
icon: 'pi pi-image'
},
{
key: 'output2',
label: 'ComfyUI_00002_.png',
icon: 'pi pi-image'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Node clicked:', node.label)
},
handleNodeDelete(node: any) {
console.log('Node delete requested:', node.label)
},
handleContextMenu(node: any, _event: MouseEvent) {
console.log('Context menu on node:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI File Explorer</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Navigate through workflows, models, and outputs
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 400px; overflow: auto;">
<TreeExplorer
:root="treeData"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Expanded: {{ Object.keys(expanded).length }} | Selected: {{ Object.keys(selected).length }}
</div>
</div>
`
}),
args: {
expandedKeys: { workflows: true, models: true },
selectionKeys: { portrait: true }
},
parameters: {
docs: {
description: {
story:
'Basic TreeExplorer with ComfyUI file structure showing workflows, models, and outputs.'
}
}
}
}
export const EmptyTree: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: {},
selected: {},
emptyTree: {
key: 'empty-root',
label: 'Empty Workspace',
children: []
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, event: MouseEvent) {
console.log('Empty tree node clicked:', node, event)
}
},
template: `
<div style="padding: 20px; width: 350px; height: 300px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Empty Workspace</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Empty tree explorer state
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 200px; display: flex; align-items: center; justify-content: center;">
<TreeExplorer
:root="emptyTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
<div style="color: #9ca3af; font-style: italic; text-align: center;">
<i class="pi pi-folder-open" style="display: block; font-size: 24px; margin-bottom: 8px;"></i>
No items in workspace
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Empty TreeExplorer showing the state when no items are present in the workspace.'
}
}
}
}
export const DeepHierarchy: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true, 'stable-diffusion': true },
selected: {},
deepTree: {
key: 'root',
label: 'Projects',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'stable-diffusion',
label: 'Stable Diffusion',
icon: 'pi pi-folder',
children: [
{
key: 'portraits',
label: 'Portraits',
icon: 'pi pi-folder',
children: [
{
key: 'realistic',
label: 'Realistic Portrait.json',
icon: 'pi pi-file'
},
{
key: 'artistic',
label: 'Artistic Portrait.json',
icon: 'pi pi-file'
}
]
},
{
key: 'landscapes',
label: 'Landscapes',
icon: 'pi pi-folder',
children: [
{
key: 'nature',
label: 'Nature Scene.json',
icon: 'pi pi-file'
},
{
key: 'urban',
label: 'Urban Environment.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'controlnet',
label: 'ControlNet',
icon: 'pi pi-folder',
children: [
{
key: 'canny',
label: 'Canny Edge.json',
icon: 'pi pi-file'
},
{
key: 'depth',
label: 'Depth Map.json',
icon: 'pi pi-file'
}
]
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Deep tree node clicked:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Deep Hierarchy</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multi-level nested folder structure with organized workflows
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="deepTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Deep hierarchical TreeExplorer showing multi-level folder organization with workflows.'
}
}
}
}
export const InteractiveOperations: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true },
selected: {},
operationLog: [],
interactiveTree: {
key: 'root',
label: 'Interactive Workspace',
children: [
{
key: 'workflows',
label: 'My Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'workflow1',
label: 'Image Generation.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
},
{
key: 'workflow2',
label: 'Video Processing.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Clicked: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleNodeDelete(node: any) {
this.operationLog.unshift(`Delete requested: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleContextMenu(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Context menu: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
}
},
template: `
<div style="padding: 20px; width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Interactive Operations</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Click nodes, right-click for context menu, test selection behavior
</p>
</div>
<div style="display: flex; gap: 16px;">
<div style="flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="interactiveTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="flex: 1; background: rgba(0,0,0,0.05); border-radius: 8px; padding: 12px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Operation Log:</div>
<div v-if="operationLog.length === 0" style="font-style: italic; color: #9ca3af; font-size: 12px;">
No operations yet...
</div>
<div v-for="(entry, index) in operationLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Interactive TreeExplorer demonstrating click, context menu, and selection operations with live logging.'
}
}
}
}
export const WorkflowManager: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { 'workflow-library': true, 'my-workflows': true },
selected: {},
workflowTree: {
key: 'root',
label: 'Workflow Manager',
children: [
{
key: 'my-workflows',
label: 'My Workflows',
icon: 'pi pi-user',
children: [
{
key: 'draft1',
label: 'Draft - SDXL Portrait.json',
icon: 'pi pi-file-edit'
},
{
key: 'final1',
label: 'Final - Product Shots.json',
icon: 'pi pi-file'
},
{
key: 'temp1',
label: 'Temp - Testing.json',
icon: 'pi pi-clock'
}
]
},
{
key: 'workflow-library',
label: 'Workflow Library',
icon: 'pi pi-book',
children: [
{
key: 'community',
label: 'Community',
icon: 'pi pi-users',
children: [
{
key: 'popular1',
label: 'SDXL Ultimate.json',
icon: 'pi pi-star-fill'
},
{
key: 'popular2',
label: 'ControlNet Pro.json',
icon: 'pi pi-star-fill'
}
]
},
{
key: 'templates',
label: 'Templates',
icon: 'pi pi-clone',
children: [
{
key: 'template1',
label: 'Basic Generation.json',
icon: 'pi pi-file'
},
{
key: 'template2',
label: 'Img2Img Template.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'recent',
label: 'Recent',
icon: 'pi pi-history',
children: [
{
key: 'recent1',
label: 'Last Session.json',
icon: 'pi pi-clock'
},
{
key: 'recent2',
label: 'Quick Test.json',
icon: 'pi pi-clock'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Workflow selected:', node.label)
}
},
template: `
<div style="padding: 20px; width: 450px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Workflow Manager</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Organized workflow library with categories, templates, and recent files
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="workflowTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Realistic workflow manager showing organized hierarchy with categories, templates, and recent files.'
}
}
}
}
export const CompactView: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { models: true },
selected: {},
compactTree: {
key: 'root',
label: 'Models',
children: [
{
key: 'models',
label: 'Checkpoints',
icon: 'pi pi-download',
children: [
{
key: 'model1',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'model2',
label: 'SD_1.5_pruned.safetensors',
icon: 'pi pi-file'
},
{
key: 'model3',
label: 'Realistic_Vision_V5.safetensors',
icon: 'pi pi-file'
},
{
key: 'model4',
label: 'AnythingV5_v3.safetensors',
icon: 'pi pi-file'
}
]
}
]
} as TreeExplorerNode
}
},
template: `
<div style="padding: 20px; width: 300px; height: 400px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Compact Model List</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Compact view for smaller spaces
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="compactTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
class="text-sm"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Compact TreeExplorer view for smaller interface areas with minimal spacing.'
}
}
}
}

View File

@@ -1,7 +1,7 @@
<template>
<Tree
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
class="tree-explorer py-0 px-2 2xl:px-4"
:class="props.class"
:value="renderedRoot.children"

View File

@@ -9,8 +9,10 @@ import { createI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
import {
InjectKeyHandleEditLabelFunction,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
// Create a mock i18n instance
const i18n = createI18n({

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UserAvatar from './UserAvatar.vue'
const meta: Meta<typeof UserAvatar> = {
title: 'Components/Common/UserAvatar',
component: UserAvatar,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'UserAvatar displays a circular avatar image with fallback to a user icon when no image is provided or when the image fails to load. Built on top of PrimeVue Avatar component.'
}
}
},
argTypes: {
photoUrl: {
control: 'text',
description:
'URL of the user photo to display. Falls back to user icon if null, undefined, or fails to load',
defaultValue: null
},
ariaLabel: {
control: 'text',
description: 'Accessibility label for screen readers',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof UserAvatar>
export const Default: Story = {
args: {
photoUrl: null,
ariaLabel: 'User avatar'
},
parameters: {
docs: {
description: {
story: 'Default avatar with no image - shows user icon fallback.'
}
}
}
}
export const WithValidImage: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'John Doe avatar'
},
parameters: {
docs: {
description: {
story: 'Avatar with a valid image URL displaying a user photo.'
}
}
}
}
export const WithBrokenImage: Story = {
args: {
photoUrl: 'https://example.com/nonexistent-image.jpg',
ariaLabel: 'User with broken image'
},
parameters: {
docs: {
description: {
story:
'Avatar with a broken image URL - automatically falls back to user icon when image fails to load.'
}
}
}
}
export const WithCustomAriaLabel: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'Sarah Johnson, Project Manager'
},
parameters: {
docs: {
description: {
story:
'Avatar with custom accessibility label for better screen reader experience.'
}
}
}
}
export const EmptyString: Story = {
args: {
photoUrl: '',
ariaLabel: 'User with empty photo URL'
},
parameters: {
docs: {
description: {
story:
'Avatar with empty string photo URL - treats empty string as no image.'
}
}
}
}
export const UndefinedUrl: Story = {
args: {
photoUrl: undefined,
ariaLabel: 'User with undefined photo URL'
},
parameters: {
docs: {
description: {
story: 'Avatar with undefined photo URL - shows default user icon.'
}
}
}
}
// Gallery view showing different states
export const Gallery: Story = {
render: () => ({
components: { UserAvatar },
template: `
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center; padding: 20px;">
<div style="text-align: center;">
<UserAvatar :photoUrl="null" ariaLabel="No image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">No Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face" ariaLabel="Valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Valid Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face" ariaLabel="Another valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Another Valid</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://example.com/broken.jpg" ariaLabel="Broken image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Broken URL</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="" ariaLabel="Empty string" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Empty String</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different avatar states side by side for comparison.'
}
}
}
}

View File

@@ -59,13 +59,14 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/composables/useManagerState'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import PackInstallButton from './manager/button/PackInstallButton.vue'
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
@@ -137,7 +138,7 @@ const allMissingNodesInstalled = computed(() => {
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled && showInstallAllButton.value) {
if (allInstalled) {
// Use nextTick to ensure state updates are complete
await nextTick()

View File

@@ -0,0 +1,185 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' 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('@/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()
})
})

View File

@@ -0,0 +1,179 @@
<template>
<div
class="overflow-hidden transition-all duration-300"
:class="{
'max-h-[500px]': isExpanded,
'max-h-0 p-0 m-0': !isExpanded
}"
>
<div
ref="sectionsContainerRef"
class="px-6 py-4 overflow-y-auto max-h-[450px] scroll-container"
: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 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
:icon="
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
text
class="text-neutral-300"
@click="togglePanel(index)"
/>
</template>
<div
:ref="
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 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-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/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>

View File

@@ -43,11 +43,11 @@
<script setup lang="ts">
import Message from 'primevue/message'
import { compare } from 'semver'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
@@ -68,7 +68,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compare(b, a) // Reversed for descending order
return compareVersions(b, a) // Reversed for descending order
})
})

View File

@@ -148,7 +148,7 @@ import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'

View File

@@ -17,8 +17,7 @@
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import { ref } from 'vue'

View File

@@ -0,0 +1,542 @@
<template>
<div
class="h-full flex flex-col mx-auto overflow-hidden"
:aria-label="$t('manager.title')"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
severity="secondary"
filled
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
v-model:selectedTab="selectedTab"
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
:class="{
'transition-all duration-300': isSmallScreen
}"
>
<div class="px-6 flex flex-col h-full">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-500/20 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2 flex-1">
<p class="text-sm font-bold m-0">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="text-xs m-0">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="text-sm font-bold m-0 cursor-pointer"
@click="onClickWarningLink"
>
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<IconButton
class="absolute top-0 right-0"
type="transparent"
@click="dismissWarningBanner"
>
<i
class="pi pi-times text-neutral-900 dark-theme:text-white text-xs"
></i>
</IconButton>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading"
class="w-full h-full overflow-auto scrollbar-hide"
>
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
:title="
comfyManagerStore.error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
comfyManagerStore.error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="4"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
</div>
</div>
</div>
</div>
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="w-full flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'es-toolkit/compat'
import Button from 'primevue/button'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const conflictAcknowledgment = useConflictAcknowledgment()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
} as const
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict acknowledgment state from composable
const {
shouldShowManagerBanner,
dismissWarningBanner,
dismissRedDotNotification
} = conflictAcknowledgment
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
{
id: ManagerTab.Workflow,
label: t('manager.inWorkflow'),
icon: 'pi-folder'
},
{
id: ManagerTab.Missing,
label: t('g.missing'),
icon: 'pi-exclamation-circle'
},
{
id: ManagerTab.UpdateAvailable,
label: t('g.updateAvailable'),
icon: 'pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
)
const {
searchQuery,
pageNumber,
isLoading: isSearchLoading,
searchResults,
searchMode,
sortField,
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
initialSearchQuery: initialState.searchQuery
})
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
}
const isInitialLoad = computed(
() => searchResults.value.length === 0 && searchQuery.value === ''
)
const isEmptySearch = computed(() => searchQuery.value === '')
const displayPacks = ref<components['schemas']['Node'][]>([])
const {
startFetchInstalled,
filterInstalledPack,
installedPacks,
isLoading: isLoadingInstalled,
isReady: installedPacksReady
} = useInstalledPacks()
const {
startFetchWorkflowPacks,
filterWorkflowPack,
workflowPacks,
isLoading: isLoadingWorkflow,
isReady: workflowPacksReady
} = useWorkflowPacks()
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
const isInstalledTab = computed(
() => selectedTab.value?.id === ManagerTab.Installed
)
const isMissingTab = computed(
() => selectedTab.value?.id === ManagerTab.Missing
)
const isWorkflowTab = computed(
() => selectedTab.value?.id === ManagerTab.Workflow
)
const isAllTab = computed(() => selectedTab.value?.id === ManagerTab.All)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const { isUpdateAvailable } = usePackUpdateStatus(pack)
return isUpdateAvailable.value === true
}
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
watch(
[isUpdateAvailableTab, installedPacks],
async () => {
if (!isUpdateAvailableTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
await startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
}
},
{ immediate: true }
)
watch(
[isInstalledTab, installedPacks],
async () => {
if (!isInstalledTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterInstalledPack(searchResults.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
await startFetchInstalled()
} else {
displayPacks.value = installedPacks.value
}
},
{ immediate: true }
)
watch(
[isMissingTab, isWorkflowTab, workflowPacks, installedPacks],
async () => {
if (!isWorkflowTab.value && !isMissingTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = isMissingTab.value
? filterMissingPacks(filterWorkflowPack(searchResults.value))
: filterWorkflowPack(searchResults.value)
} else if (
!workflowPacks.value.length &&
!isLoadingWorkflow.value &&
!workflowPacksReady.value
) {
await startFetchWorkflowPacks()
if (isMissingTab.value) {
await startFetchInstalled()
}
} else {
displayPacks.value = isMissingTab.value
? filterMissingPacks(workflowPacks.value)
: workflowPacks.value
}
},
{ immediate: true }
)
watch([isAllTab, searchResults], () => {
if (!isAllTab.value) return
displayPacks.value = searchResults.value
})
const onClickWarningLink = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = isEmptySearch.value
? installedPacks.value
: filterInstalledPack(searchResults.value)
break
case ManagerTab.Workflow:
displayPacks.value = isEmptySearch.value
? workflowPacks.value
: filterWorkflowPack(searchResults.value)
break
case ManagerTab.Missing:
if (!isEmptySearch.value) {
displayPacks.value = filterMissingPacks(
filterWorkflowPack(searchResults.value)
)
}
break
case ManagerTab.UpdateAvailable:
displayPacks.value = isEmptySearch.value
? filterOutdatedPacks(installedPacks.value)
: filterOutdatedPacks(searchResults.value)
break
default:
displayPacks.value = searchResults.value
}
}
watch(searchResults, onResultsChange, { flush: 'post' })
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)
const isLoading = computed(() => {
if (isSearchLoading.value) return searchResults.value.length === 0
if (selectedTab.value?.id === ManagerTab.Installed) {
return isLoadingInstalled.value
}
if (
selectedTab.value?.id === ManagerTab.Workflow ||
selectedTab.value?.id === ManagerTab.Missing
) {
return isLoadingWorkflow.value
}
return isInitialLoad.value
})
const resultsWithKeys = computed(
() =>
displayPacks.value.map((item) => ({
...item,
key: item.id || item.name
})) as (components['schemas']['Node'] & { key: string })[]
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const getLoadingCount = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
return comfyManagerStore.installedPacksIds?.size
case ManagerTab.Workflow:
return workflowPacks.value?.length
case ManagerTab.Missing:
return workflowPacks.value?.filter?.(
(pack) => !comfyManagerStore.isPackInstalled(pack.id)
)?.length
default:
return searchResults.value.length
}
}
const skeletonCardCount = computed(() => {
const loadingCount = getLoadingCount()
if (loadingCount) return loadingCount
return isSmallScreen.value ? 12 : 16
})
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
const unSelectItems = () => {
selectedNodePacks.value = []
}
const handleGridContainerClick = (event: MouseEvent) => {
const targetElement = event.target as HTMLElement
if (targetElement && !targetElement.closest('[data-virtual-grid-item]')) {
unSelectItems()
}
}
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
// Track the last pack ID for which we've fetched full registry data
const lastFetchedPackId = ref<string | null>(null)
// Whenever a single pack is selected, fetch its full info once
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
// If only a single node pack is selected, fetch full node pack info from registry
const pack = selectedNodePack.value
if (!pack?.id) return
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
// Update the pack in current selection without changing selection state
const packIndex = selectedNodePacks.value.findIndex(
(p) => p.id === mergedPack.id
)
if (packIndex !== -1) {
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
}
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
displayPacks.value.splice(idx, 1, mergedPack)
}
}
})
let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch([searchQuery, selectedTab], () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
pageNumber.value = 0
gridContainer.scrollTop = 0
}
})
watchEffect(() => {
dismissRedDotNotification()
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,
searchQuery: searchQuery.value,
searchMode: searchMode.value,
sortField: sortField.value
})
})
onUnmounted(() => {
getPackById.cancel()
})
</script>

View File

@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n],
directives: {
tooltip: Tooltip
},
components: {
Tag
}
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('displays the legacy manager UI tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
})
it('applies info severity to the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('p-tag-info')
})
it('displays info icon in the tag', () => {
const wrapper = createWrapper()
const icon = wrapper.find('.pi-info-circle')
expect(icon.exists()).toBe(true)
})
it('has cursor-help class on the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('cursor-help')
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
expect(flexContainer.exists()).toBe(true)
const tag = flexContainer.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,25 @@
<template>
<div class="w-full">
<div class="flex items-center">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
<div class="flex justify-end ml-auto pr-4 pl-2">
<Tag
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
severity="info"
icon="pi pi-info-circle"
:value="$t('manager.legacyManagerUI')"
class="cursor-help ml-2"
:pt="{
root: { class: 'text-xs' }
}"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
</script>

View File

@@ -0,0 +1,42 @@
<template>
<aside
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
>
<ScrollPanel class="flex-1">
<Listbox
v-model="selectedTab"
:options="tabs"
option-label="label"
list-style="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" :width="0.3" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
}>()
const selectedTab = defineModel<TabItem>('selectedTab')
</script>

View File

@@ -0,0 +1,244 @@
<template>
<div class="w-[552px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<div v-if="showAfterWhatsNew">
<p
class="text-sm leading-4 text-neutral-800 dark-theme:text-white m-0 mb-4"
>
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</div>
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleImportFailedPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ importFailedConflicts.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
>
</div>
<div>
<Button
:icon="
importFailedExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Import failed list -->
<div
v-if="importFailedExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ packageName }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Conflict List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleConflictsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ allConflictDetails.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.conflicts') }}</span
>
</div>
<div>
<Button
:icon="
conflictsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Conflicts list -->
<div
v-if="conflictsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
>{{ getConflictMessage(conflict, t) }}</span
>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Extension List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleExtensionsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ conflictData.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
>
</div>
<div>
<Button
:icon="
extensionsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Extension list -->
<div
v-if="extensionsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ conflictResult.package_name }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
</div>
<ContentDivider :width="1" />
</div>
</template>
<script setup lang="ts">
import { filter, flatMap, map, some } from 'es-toolkit/compat'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import {
ConflictDetail,
ConflictDetectionResult
} from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { showAfterWhatsNew = false, conflictedPackages } = defineProps<{
showAfterWhatsNew?: boolean
conflictedPackages?: ConflictDetectionResult[]
}>()
const { t } = useI18n()
const { conflictedPackages: globalConflictPackages } = useConflictDetection()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
const importFailedExpanded = ref<boolean>(false)
const conflictData = computed(
() => conflictedPackages || globalConflictPackages.value
)
const allConflictDetails = computed(() => {
const allConflicts = flatMap(
conflictData.value,
(result: ConflictDetectionResult) => result.conflicts
)
return filter(
allConflicts,
(conflict: ConflictDetail) => conflict.type !== 'import_failed'
)
})
const packagesWithImportFailed = computed(() => {
return filter(conflictData.value, (result: ConflictDetectionResult) =>
some(
result.conflicts,
(conflict: ConflictDetail) => conflict.type === 'import_failed'
)
)
})
const importFailedConflicts = computed(() => {
return map(
packagesWithImportFailed.value,
(result: ConflictDetectionResult) =>
result.package_name || result.package_id
)
})
const toggleImportFailedPanel = () => {
importFailedExpanded.value = !importFailedExpanded.value
conflictsExpanded.value = false
extensionsExpanded.value = false
}
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
importFailedExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
importFailedExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgba(0, 122, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="flex items-center justify-between w-full px-3 py-4">
<div class="w-full flex items-center justify-between gap-2 pr-1">
<Button
:label="$t('manager.conflicts.conflictInfoTitle')"
text
severity="secondary"
size="small"
icon="pi pi-info-circle"
:pt="{
label: { class: 'text-sm' }
}"
@click="handleConflictInfoClick"
/>
<Button
v-if="props.buttonText"
:label="props.buttonText"
severity="secondary"
size="small"
@click="handleButtonClick"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useDialogStore } from '@/stores/dialogStore'
interface Props {
buttonText?: string
onButtonClick?: () => void
}
const props = withDefaults(defineProps<Props>(), {
buttonText: undefined,
onButtonClick: undefined
})
const dialogStore = useDialogStore()
const handleConflictInfoClick = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const handleButtonClick = () => {
// Close the conflict dialog
dialogStore.closeDialog({ key: 'global-node-conflict' })
// Execute the custom button action if provided
if (props.onButtonClick) {
props.onButtonClick()
}
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="h-12 flex items-center justify-between w-full pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="pi pi-exclamation-triangle text-lg"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<template>
<Message
:severity="statusSeverity"
class="p-0 flex items-center rounded-xl break-words w-fit"
:pt="{
text: { class: 'text-xs' },
content: { class: 'px-2 py-0.5' }
}"
>
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
/>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed, inject } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
type Status = PackVersionStatus | PackStatus
type MessageProps = InstanceType<typeof Message>['$props']
type MessageSeverity = MessageProps['severity']
type StatusProps = {
label: string
severity: MessageSeverity
}
const { statusType, hasCompatibilityIssues } = defineProps<{
statusType: Status
hasCompatibilityIssues?: boolean
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
severity: 'success'
},
NodeStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeStatusBanned: {
label: 'banned',
severity: 'error'
},
NodeVersionStatusActive: {
label: 'active',
severity: 'success'
},
NodeVersionStatusPending: {
label: 'pending',
severity: 'warn'
},
NodeVersionStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeVersionStatusFlagged: {
label: 'flagged',
severity: 'error'
},
NodeVersionStatusBanned: {
label: 'banned',
severity: 'error'
}
}
const statusLabel = computed(() => {
if (importFailed?.value) return 'importFailed'
if (hasCompatibilityIssues) return 'conflicting'
return statusPropsMap[statusType]?.label || 'unknown'
})
const statusSeverity = computed(() => {
if (hasCompatibilityIssues || importFailed?.value) return 'error'
return statusPropsMap[statusType]?.severity || 'secondary'
})
</script>

View File

@@ -0,0 +1,299 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
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 PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0'
}
}
const mockInstalledPacks = {
'test-pack': { ver: '1.5.0' },
'installed-pack': { ver: '2.0.0' }
}
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
isPackEnabled: mockIsPackEnabled
}))
}))
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
}))
const mockToggle = vi.fn()
const mockHide = vi.fn()
const PopoverStub = {
name: 'Popover',
template: '<div><slot></slot></div>',
methods: {
toggle: mockToggle,
hide: mockHide
}
}
describe('PackVersionBadge', () => {
beforeEach(() => {
mockToggle.mockReset()
mockHide.mockReset()
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
isSelected: false,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
directives: {
tooltip: Tooltip
},
stubs: {
Popover: PopoverStub,
PackVersionSelectorPopover: true
}
}
})
}
it('renders with installed version from store', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
})
it('falls back to latest_version when not installed', () => {
// Use a nodePack that's not in the installedPacks
const uninstalledPack = {
id: 'uninstalled-pack',
name: 'Uninstalled Pack',
latest_version: {
version: '3.0.0'
}
}
const wrapper = mountComponent({
props: { nodePack: uninstalledPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
})
it('falls back to NIGHTLY when no latest_version and not installed', () => {
// Use a nodePack with no latest_version and not in installedPacks
const noVersionPack = {
id: 'no-version-pack',
name: 'No Version Pack'
}
const wrapper = mountComponent({
props: { nodePack: noVersionPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('nightly')
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
const invalidPack = {
name: 'Invalid Pack'
}
const wrapper = mountComponent({
props: { nodePack: invalidPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('nightly')
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Click the badge
await wrapper.find('[role="button"]').trigger('click')
// Verify that the toggle method was called
expect(mockToggle).toHaveBeenCalled()
})
it('closes the popover when cancel is emitted', async () => {
const wrapper = mountComponent()
// Simulate the popover emitting a cancel event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel')
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('closes the popover when submit is emitted', async () => {
const wrapper = mountComponent()
// Simulate the popover emitting a submit event
wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('submit')
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
describe('selection state changes', () => {
it('closes the popover when card is deselected', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to false
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('does not close the popover when card is selected', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to true
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains false', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to false (no change)
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains true', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to true (no change)
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
})
describe('disabled state', () => {
beforeEach(() => {
mockIsPackEnabled.mockReturnValue(false) // Set all packs as disabled for these tests
})
it('adds disabled styles when pack is disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.classes()).toContain('cursor-not-allowed')
expect(badge.classes()).toContain('opacity-60')
})
it('does not show chevron icon when disabled', () => {
const wrapper = mountComponent()
const chevronIcon = wrapper.find('.pi-chevron-right')
expect(chevronIcon.exists()).toBe(false)
})
it('does not show update arrow when disabled', () => {
const wrapper = mountComponent()
const updateIcon = wrapper.find('.pi-arrow-circle-up')
expect(updateIcon.exists()).toBe(false)
})
it('does not toggle popover when clicked while disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('click')
// Since it's disabled, the popover should not be toggled
expect(mockToggle).not.toHaveBeenCalled()
})
it('has correct tabindex when disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.attributes('tabindex')).toBe('-1')
})
it('does not respond to keyboard events when disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('keydown.enter')
await badge.trigger('keydown.space')
expect(mockToggle).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,104 @@
<template>
<div>
<div
v-tooltip.top="
isDisabled ? $t('manager.enablePackToChangeVersion') : null
"
class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
:class="{
'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
'cursor-pointer': !isDisabled,
'cursor-not-allowed opacity-60': isDisabled
}"
:aria-haspopup="!isDisabled"
:role="isDisabled ? 'text' : 'button'"
:tabindex="isDisabled ? -1 : 0"
@click="!isDisabled && toggleVersionSelector($event)"
@keydown.enter="!isDisabled && toggleVersionSelector($event)"
@keydown.space="!isDisabled && toggleVersionSelector($event)"
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
</div>
<Popover
ref="popoverRef"
:pt="{
content: { class: 'p-0 shadow-lg' }
}"
>
<PackVersionSelectorPopover
:installed-version="installedVersion"
:node-pack="nodePack"
@cancel="closeVersionSelector"
@submit="closeVersionSelector"
/>
</Popover>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
const {
nodePack,
isSelected,
fill = true
} = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
)
const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly'
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
})
const toggleVersionSelector = (event: Event) => {
popoverRef.value.toggle(event)
}
const closeVersionSelector = () => {
popoverRef.value.hide()
}
// If the card is unselected, automatically close the version selector popover
watch(
() => isSelected,
(isSelected, wasSelected) => {
if (wasSelected && !isSelected) {
closeVersionSelector()
}
}
)
</script>

View File

@@ -0,0 +1,708 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import Select from 'primevue/select'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{
version: '1.0.0',
createdAt: '2023-01-01',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
repository: 'https://github.com/user/repo',
has_registry_data: true
}
// Create mock functions
const mockGetPackVersions = vi.fn()
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
const mockCheckNodeCompatibility = vi.fn()
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn(() => ({
getPackVersions: mockGetPackVersions
}))
}))
// Mock the manager store
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installPack: {
call: mockInstallPack,
clear: vi.fn()
},
isPackInstalled: vi.fn(() => false),
getInstalledPackVersion: vi.fn(() => undefined)
}))
}))
// Mock the conflict detection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
}
describe('PackVersionSelectorPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckNodeCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
}
it('fetches versions on mount', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
mountComponent()
await waitForPromises()
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Loading versions...')
})
it('displays special options and version options in the listbox', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
// Check that we have both special options and version options
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
// Check that special options exist
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist (excluding latest version 1.0.0)
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
it('emits cancel event when cancel button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
const cancelButton = wrapper.findAllComponents(Button)[0]
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('calls installPack and emits submit when install button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set the selected version
await wrapper.findComponent(Listbox).setValue('0.9.0')
const installButton = wrapper.findAllComponents(Button)[1]
await installButton.trigger('click')
// Check that installPack was called with the correct parameters
expect(mockInstallPack).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
repository: mockNodePack.repository,
version: '0.9.0',
selected_version: '0.9.0'
})
)
// Check that submit was emitted
expect(wrapper.emitted('submit')).toBeTruthy()
})
it('is reactive to nodePack prop changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set up the mock for the second fetch after prop change
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('nodePack.id changes', () => {
it('re-fetches versions when nodePack.id changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
// Set up the mock for the second fetch
const newVersions = [
{ version: '2.0.0', createdAt: '2023-06-01' },
{ version: '1.9.0', createdAt: '2023-05-01' }
]
mockGetPackVersions.mockResolvedValueOnce(newVersions)
// Update the nodePack with a new ID
const newNodePack = {
...mockNodePack,
id: 'different-pack',
name: 'Different Pack'
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
// Check that new versions are displayed
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
})
it('does not re-fetch when nodePack changes but id remains the same', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
// Update the nodePack with same ID but different properties
const updatedNodePack = {
...mockNodePack,
name: 'Updated Test Pack',
description: 'New description'
}
await wrapper.setProps({ nodePack: updatedNodePack })
await waitForPromises()
// Should NOT fetch versions again
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
})
it('maintains selected version when switching to a new pack', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Select a specific version
const listbox = wrapper.findComponent(Listbox)
await listbox.setValue('0.9.0')
expect(listbox.props('modelValue')).toBe('0.9.0')
// Set up the mock for the second fetch
mockGetPackVersions.mockResolvedValueOnce([
{ version: '3.0.0', createdAt: '2023-07-01' },
{ version: '0.9.0', createdAt: '2023-04-01' }
])
// Update to a new pack that also has version 0.9.0
const newNodePack = {
id: 'another-pack',
name: 'Another Pack',
latest_version: { version: '3.0.0' }
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Selected version should remain the same if available
expect(listbox.props('modelValue')).toBe('0.9.0')
})
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when no versions exist', async () => {
// Set up the mock to return versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const packWithRepo = {
...mockNodePack,
latest_version: undefined
}
const wrapper = mountComponent({
props: {
nodePack: packWithRepo
}
})
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
// Set up the mock to return versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const unclaimedNodePack = {
...mockNodePack,
publisher: { name: 'Unclaimed' }
}
const wrapper = mountComponent({
props: {
nodePack: unclaimedNodePack
}
})
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
describe('version compatibility checking', () => {
it('shows warning icon for incompatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return conflict for specific version
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.supported_os?.includes('linux')) {
return {
hasConflict: true,
conflicts: [
{
type: 'os',
current_value: 'windows',
required_value: 'linux'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['linux'],
supported_accelerators: ['CUDA']
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows verified icon for compatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return no conflicts
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The verified icon should be shown for compatible versions
// Look for the VerifiedIcon component or SVG elements
const verifiedIcons = wrapper.findAll('svg')
expect(verifiedIcons.length).toBeGreaterThan(0)
})
it('calls checkVersionCompatibility with correct version data', async () => {
// Set up the mock for versions with specific supported data
const versionsWithCompatibility = [
{
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CUDA', 'CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
]
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
vm.getVersionCompatibility('1.0.0')
// Verify that checkNodeCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
})
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return version conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
const conflicts = []
if (versionData.supported_comfyui_version) {
conflicts.push({
type: 'comfyui_version',
current_value: '0.5.0',
required_value: versionData.supported_comfyui_version
})
}
if (versionData.supported_comfyui_frontend_version) {
conflicts.push({
type: 'frontend_version',
current_value: '1.0.0',
required_value: versionData.supported_comfyui_frontend_version
})
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
})
const nodePackWithVersionRequirements = {
...mockNodePack,
supported_comfyui_version: '>=1.0.0',
supported_comfyui_frontend_version: '>=2.0.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithVersionRequirements }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('handles latest and nightly versions using nodePack data', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
...mockNodePack.latest_version,
supported_os: ['windows'], // Match nodePack data for test consistency
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
const vm = wrapper.vm as any
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
// Clear for next test call
mockCheckNodeCompatibility.mockClear()
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
id: 'test-pack',
name: 'Test Pack',
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
repository: 'https://github.com/user/repo',
has_registry_data: true,
latest_version: {
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0',
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
})
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.is_banned === true) {
return {
hasConflict: true,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const bannedNodePack = {
...mockNodePack,
is_banned: true,
latest_version: {
...mockNodePack.latest_version,
is_banned: true
}
}
const wrapper = mountComponent({
props: { nodePack: bannedNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// Open the dropdown to see the options
const select = wrapper.find('.p-select')
if (!select.exists()) {
// Try alternative selector
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
if (selectButton.exists()) {
await selectButton.trigger('click')
}
} else {
await select.trigger('click')
}
await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows security pending warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return security pending conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
hasConflict: true,
conflicts: [
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const securityPendingNodePack = {
...mockNodePack,
has_registry_data: false,
latest_version: {
...mockNodePack.latest_version,
has_registry_data: false
}
}
const wrapper = mountComponent({
props: { nodePack: securityPendingNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,314 @@
<template>
<div class="w-64 pt-1">
<div class="py-2">
<span class="pl-3 text-md font-semibold text-neutral-500">
{{ $t('manager.selectVersion') }}
</span>
</div>
<div
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
>
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="versionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
icon="pi pi-exclamation-circle"
class="p-0"
/>
</div>
<Listbox
v-else
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="processedVersionOptions"
:highlight-on-select="false"
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
:pt="{
listContainer: { class: 'scrollbar-hide' }
}"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<div class="flex items-center gap-2">
<template v-if="slotProps.option.value === 'nightly'">
<div class="w-4"></div>
</template>
<template v-else>
<i
v-if="slotProps.option.hasConflict"
v-tooltip="{
value: slotProps.option.conflictMessage,
showDelay: 300
}"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<VerifiedIcon v-else :size="20" class="relative right-0.5" />
</template>
<span>{{ slotProps.option.label }}</span>
</div>
<i
v-if="slotProps.option.isSelected"
class="pi pi-check text-highlight"
/>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 py-1 px-3">
<Button
text
class="text-sm"
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@click="emit('cancel')"
/>
<Button
severity="secondary"
:label="$t('g.install')"
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
:disabled="isQueueing"
@click="handleSubmit"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'
type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
type ManagerDatabaseSource =
ManagerComponents['schemas']['ManagerDatabaseSource']
type SelectedVersion = ManagerComponents['schemas']['SelectedVersion']
// Enum values for runtime use
const SelectedVersionValues = {
LATEST: 'latest' as SelectedVersion,
NIGHTLY: 'nightly' as SelectedVersion
}
const ManagerChannelValues: Record<string, ManagerChannel> = {
DEFAULT: 'default',
DEV: 'dev'
}
const ManagerDatabaseSourceValues: Record<string, ManagerDatabaseSource> = {
CACHE: 'cache',
REMOTE: 'remote',
LOCAL: 'local'
}
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const emit = defineEmits<{
cancel: []
submit: []
}>()
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersionValues.LATEST)
onMounted(() => {
const initialVersion =
getInitialSelectedVersion() ?? SelectedVersionValues.LATEST
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
})
const getInitialSelectedVersion = () => {
if (!nodePack.id) return
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed')
return SelectedVersionValues.NIGHTLY
// If node pack is installed, set selected version to the installed version
if (managerStore.isPackInstalled(nodePack.id))
return managerStore.getInstalledPackVersion(nodePack.id)
// If node pack is not installed, set selected version to latest
return nodePack.latest_version?.version
}
const fetchVersions = async () => {
if (!nodePack?.id) return []
return (await registryService.getPackVersions(nodePack.id)) || []
}
const versionOptions = ref<
{
value: string
label: string
}[]
>([])
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
isLoadingVersions.value = true
// Fetch versions from the registry
const versions = await fetchVersions()
fetchedVersions.value = versions
const latestVersionNumber = nodePack.latest_version?.version
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value && option.value !== latestVersionNumber) // Exclude latest version from the list
// Add Latest option with actual version number
const latestLabel = latestVersionNumber
? `${t('manager.latestVersion')} (${latestVersionNumber})`
: t('manager.latestVersion')
// Add Latest option
const defaultVersions = [
{
value: SelectedVersionValues.LATEST,
label: latestLabel
}
]
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: SelectedVersionValues.NIGHTLY,
label: t('manager.nightlyVersion')
})
}
versionOptions.value = [...defaultVersions, ...availableVersionOptions]
isLoadingVersions.value = false
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
void onNodePackChange()
}
},
{ deep: true, immediate: true }
)
const handleSubmit = async () => {
isQueueing.value = true
if (!nodePack.id) {
throw new Error('Node ID is required for installation')
}
// Convert 'latest' to actual version number for installation
const actualVersion =
selectedVersion.value === 'latest'
? nodePack.latest_version?.version ?? 'latest'
: selectedVersion.value
await managerStore.installPack.call({
id: nodePack.id,
repository: nodePack.repository ?? '',
channel: ManagerChannelValues.DEFAULT,
mode: ManagerDatabaseSourceValues.CACHE,
version: actualVersion,
selected_version: selectedVersion.value
})
isQueueing.value = false
emit('submit')
}
const getVersionData = (version: string) => {
const latestVersionNumber = nodePack.latest_version?.version
const useLatestVersionData =
version === 'latest' || version === latestVersionNumber
if (useLatestVersionData) {
const latestVersionData = nodePack.latest_version
return {
...latestVersionData
}
}
const versionData = fetchedVersions.value.find((v) => v.version === version)
if (versionData) {
return {
...versionData
}
}
// Fallback to nodePack data
return {
...nodePack
}
}
// Main function to get version compatibility info
const getVersionCompatibility = (version: string) => {
const versionData = getVersionData(version)
const compatibility = checkNodeCompatibility(versionData)
const conflictMessage = compatibility.hasConflict
? getJoinedConflictMessages(compatibility.conflicts, t)
: ''
return {
hasConflict: compatibility.hasConflict,
conflictMessage
}
}
// Helper to determine if an option is selected.
const isOptionSelected = (optionValue: string) => {
if (selectedVersion.value === optionValue) {
return true
}
if (
optionValue === 'latest' &&
selectedVersion.value === nodePack.latest_version?.version
) {
return true
}
return false
}
// Checks if an option is selected, treating 'latest' as an alias for the actual latest version number.
const processedVersionOptions = computed(() => {
return versionOptions.value.map((option) => {
const compatibility = getVersionCompatibility(option.value)
const isSelected = isOptionSelected(option.value)
return {
...option,
hasConflict: compatibility.hasConflict,
conflictMessage: compatibility.conflictMessage,
isSelected: isSelected
}
})
})
</script>

View File

@@ -0,0 +1,165 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
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 { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('es-toolkit/compat', async () => {
const actual = await vi.importActual('es-toolkit/compat')
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
}
})
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: {
version: '1.0.0',
createdAt: '2023-01-01T00:00:00Z'
}
}
const mockIsPackEnabled = vi.fn()
const mockEnablePack = { call: vi.fn().mockResolvedValue(undefined) }
const mockDisablePack = vi.fn().mockResolvedValue(undefined)
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackEnabled: mockIsPackEnabled,
enablePack: mockEnablePack,
disablePack: mockDisablePack,
installedPacks: {}
}))
}))
describe('PackEnableToggle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPackEnabled.mockReset()
mockEnablePack.call.mockReset().mockResolvedValue(undefined)
mockDisablePack.mockReset().mockResolvedValue(undefined)
})
const mountComponent = ({
props = {},
installedPacks = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
vi.mocked(useComfyManagerStore).mockReturnValue({
isPackEnabled: mockIsPackEnabled,
enablePack: mockEnablePack,
disablePack: mockDisablePack,
installedPacks
} as any)
return mount(PackEnableToggle, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n]
}
})
}
it('renders a toggle switch', () => {
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
expect(toggleSwitch.exists()).toBe(true)
})
it('checks if pack is enabled on mount', () => {
mockIsPackEnabled.mockReturnValue(true)
mountComponent()
expect(mockIsPackEnabled).toHaveBeenCalledWith(mockNodePack.id)
})
it('sets toggle to on when pack is enabled', () => {
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
expect(toggleSwitch.props('modelValue')).toBe(true)
})
it('sets toggle to off when pack is disabled', () => {
mockIsPackEnabled.mockReturnValue(false)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
expect(toggleSwitch.props('modelValue')).toBe(false)
})
it('calls enablePack when toggle is switched on', async () => {
mockIsPackEnabled.mockReturnValue(false)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
await toggleSwitch.vm.$emit('update:modelValue', true)
expect(mockEnablePack.call).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
version: mockNodePack.latest_version.version
})
)
})
it('calls disablePack when toggle is switched off', async () => {
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
await toggleSwitch.vm.$emit('update:modelValue', false)
expect(mockDisablePack).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
version: mockNodePack.latest_version.version
})
)
})
it('disables toggle while loading', async () => {
const pendingPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 1000)
})
mockEnablePack.call.mockReturnValue(pendingPromise)
mockIsPackEnabled.mockReturnValue(false)
const wrapper = mountComponent()
// Trigger the toggle
const toggleSwitch = wrapper.findComponent(ToggleSwitch)
await toggleSwitch.vm.$emit('update:modelValue', true)
// Check that the toggle is disabled during loading
await nextTick()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(true)
// Resolve the promise to simulate the operation completing
await pendingPromise
// Check that the toggle is enabled after the operation completes
await nextTick()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(false)
})
})

View File

@@ -0,0 +1,161 @@
<template>
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
}"
class="flex items-center justify-center w-6 h-6 cursor-pointer"
@click="showConflictModal(true)"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
<ToggleSwitch
v-if="!canToggleDirectly"
:model-value="isEnabled"
:disabled="isLoading"
:readonly="!canToggleDirectly"
aria-label="Enable or disable pack"
@focus="handleToggleInteraction"
/>
<ToggleSwitch
v-else
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@update:model-value="onToggle"
/>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'es-toolkit/compat'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack, hasConflict } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const isLoading = ref(false)
const isEnabled = computed(() => isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
return (
installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
('nightly' as ManagerComponents['schemas']['SelectedVersion'])
)
})
const packageConflict = computed(() =>
getConflictsForPackageByID(nodePack.id || '')
)
const canToggleDirectly = computed(() => {
return !(
hasConflict &&
!acknowledgmentState.value.modal_dismissed &&
packageConflict.value
)
})
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
}
const handleEnable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
return enablePack.call({
id: nodePack.id,
version:
version.value ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
selected_version:
version.value ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
})
}
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
id: nodePack.id,
version:
version.value ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
})
}
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
isLoading.value = true
if (enable) {
await handleEnable()
} else {
await handleDisable()
}
isLoading.value = false
}
const onToggle = debounce(
(enable: boolean) => {
void handleToggle(enable)
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
const handleToggleInteraction = async (event: Event) => {
if (!canToggleDirectly.value) {
event.preventDefault()
showConflictModal(false)
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<IconTextButton
v-bind="$attrs"
type="transparent"
:label="computedLabel"
:border="true"
:size="size"
:disabled="isLoading || isInstalling"
@click="installAllPacks"
>
<template #icon>
<i
v-if="hasConflict && !isInstalling && !isLoading"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<DotSpinner
v-else-if="isLoading || isInstalling"
duration="1s"
:size="size === 'sm' ? 12 : 16"
/>
</template>
</IconTextButton>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import {
type ConflictDetail,
ConflictDetectionResult
} from '@/types/conflictDetectionTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const {
nodePacks,
isLoading = false,
label = 'Install',
size = 'sm',
hasConflict,
conflictInfo
} = defineProps<{
nodePacks: NodePack[]
isLoading?: boolean
label?: string
size?: ButtonSize
hasConflict?: boolean
conflictInfo?: ConflictDetail[]
}>()
const managerStore = useComfyManagerStore()
const { showNodeConflictDialog } = useDialogService()
// Check if any of the packs are currently being installed
const isInstalling = computed(() => {
if (!nodePacks?.length) return false
return nodePacks.some((pack) => managerStore.isPackInstalling(pack.id))
})
const createPayload = (installItem: NodePack) => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
return {
id: installItem.id,
repository: installItem.repository ?? '',
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall,
version: versionToInstall
}
}
const installPack = (item: NodePack) =>
managerStore.installPack.call(createPayload(item))
const installAllPacks = async () => {
if (!nodePacks?.length) return
if (hasConflict && conflictInfo) {
// Check each package individually for conflicts
const { checkNodeCompatibility } = useConflictDetection()
const conflictedPackages: ConflictDetectionResult[] = nodePacks
.map((pack) => {
const compatibilityCheck = checkNodeCompatibility(pack)
return {
package_id: pack.id || '',
package_name: pack.name || '',
has_conflict: compatibilityCheck.hasConflict,
conflicts: compatibilityCheck.conflicts,
is_compatible: !compatibilityCheck.hasConflict
}
})
.filter((result) => result.has_conflict) // Only show packages with conflicts
showNodeConflictDialog({
conflictedPackages,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
// Proceed with installation of uninstalled packages
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
})
return
}
// No conflicts or conflicts acknowledged - proceed with installation
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await performInstallation(uninstalledPacks)
}
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
const computedLabel = computed(() =>
isInstalling.value
? t('g.installing')
: label ??
(nodePacks.length > 1 ? t('manager.installSelected') : t('g.install'))
)
</script>

View File

@@ -0,0 +1,53 @@
<template>
<IconTextButton
v-bind="$attrs"
type="transparent"
:label="
nodePacks.length > 1
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
:border="true"
:size="size"
class="border-red-500"
@click="uninstallItems"
/>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, size } = defineProps<{
nodePacks: NodePack[]
size?: ButtonSize
}>()
const managerStore = useComfyManagerStore()
const createPayload = (
uninstallItem: NodePack
): ManagerComponents['schemas']['ManagerPackInfo'] => {
if (!uninstallItem.id) {
throw new Error('Node ID is required for uninstallation')
}
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version || 'unknown'
}
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<IconTextButton
v-tooltip.top="
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
"
v-bind="$attrs"
type="transparent"
:label="$t('manager.updateAll')"
:border="true"
size="sm"
:disabled="isUpdating"
@click="updateAllPacks"
>
<template v-if="isUpdating" #icon>
<DotSpinner duration="1s" :size="12" />
</template>
</IconTextButton>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
nodePacks: NodePack[]
hasDisabledUpdatePacks?: boolean
}>()
const isUpdating = ref<boolean>(false)
const managerStore = useComfyManagerStore()
const createPayload = (updateItem: NodePack) => {
return {
id: updateItem.id!,
version: updateItem.latest_version!.version!
}
}
const updatePack = async (item: NodePack) => {
if (!item.id || !item.latest_version?.version) {
console.warn('Pack missing required id or version:', item)
return
}
await managerStore.updatePack.call(createPayload(item))
}
const updateAllPacks = async () => {
if (!nodePacks?.length) {
console.warn('No packs provided for update')
return
}
isUpdating.value = true
const updatablePacks = nodePacks.filter((pack) =>
managerStore.isPackInstalled(pack.id)
)
if (!updatablePacks.length) {
console.info('No installed packs available for update')
isUpdating.value = false
return
}
console.info(`Starting update of ${updatablePacks.length} packs`)
try {
await Promise.all(updatablePacks.map(updatePack))
managerStore.updatePack.clear()
console.info('All packs updated successfully')
} catch (error) {
console.error('Pack update failed:', error)
console.error(
'Failed packs info:',
updatablePacks.map((p) => p.id)
)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -0,0 +1,183 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
>
<div class="mb-6">
<MetadataRow
v-if="!importFailed && isPackInstalled(nodePack.id)"
:label="t('manager.filter.enabled')"
class="flex"
style="align-items: center"
>
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow
v-for="item in infoItems"
v-show="item.value !== undefined && item.value !== null"
:key="item.key"
:label="item.label"
:value="item.value"
/>
<MetadataRow :label="t('g.status')">
<PackStatusMessage
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
:has-compatibility-issues="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
</div>
</div>
</div>
</template>
<template v-else>
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
interface InfoItem {
key: string
label: string
value: string | number | undefined
}
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { t, d, n } = useI18n()
// Check compatibility once and pass to children
const conflictResult = computed((): ConflictDetectionResult | null => {
// For installed packages, use stored conflict data
if (isInstalled.value && nodePack.id) {
return getConflictsForPackageByID(nodePack.id) || null
}
// For non-installed packages, perform compatibility check
const compatibility = checkNodeCompatibility(nodePack)
if (compatibility.hasConflict) {
return {
package_id: nodePack.id || '',
package_name: nodePack.name || '',
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
})
const hasCompatibilityIssues = computed(() => {
return conflictResult.value?.has_conflict
})
const packageId = computed(() => nodePack.id || '')
const { importFailed, showImportFailedDialog } =
useImportFailedDetection(packageId)
provide(ImportFailedKey, {
importFailed,
showImportFailedDialog
})
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: t('manager.createdBy'),
value: nodePack.publisher?.name ?? nodePack.publisher?.id
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? n(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',
label: t('manager.lastUpdated'),
value: nodePack.latest_version?.createdAt
? d(nodePack.latest_version.createdAt, {
dateStyle: 'medium'
})
: undefined
}
])
const { y } = useScroll(scrollContainer, {
eventListenerOptions: {
passive: true
}
})
const onNodePackChange = () => {
y.value = 0
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
onNodePackChange()
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col items-center">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
</slot>
</h2>
<div
v-if="!importFailed"
class="mt-2 mb-4 w-full max-w-xs flex justify-center"
>
<slot name="install-button">
<PackUninstallButton
v-if="isAllInstalled"
v-bind="$attrs"
size="md"
:node-packs="nodePacks"
/>
<PackInstallButton
v-else
v-bind="$attrs"
size="md"
:node-packs="nodePacks"
:has-conflict="hasConflict || computedHasConflict"
:conflict-info="conflictInfo"
/>
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUninstallButton from '@/components/dialog/content/manager/button/PackUninstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const isAllInstalled = ref(false)
watch(
[() => nodePacks, () => managerStore.installedPacks],
() => {
isAllInstalled.value = nodePacks.every((nodePack) =>
managerStore.isPackInstalled(nodePack.id)
)
},
{ immediate: true }
)
// Add conflict detection for install button dialog
const { checkNodeCompatibility } = useConflictDetection()
// Compute conflict info for all node packs
const conflictInfo = computed<ConflictDetail[]>(() => {
if (!nodePacks?.length) return []
const allConflicts: ConflictDetail[] = []
for (const nodePack of nodePacks) {
const compatibilityCheck = checkNodeCompatibility(nodePack)
if (compatibilityCheck.conflicts) {
allConflicts.push(...compatibilityCheck.conflicts)
}
}
return allConflicts
})
const computedHasConflict = computed(() => conflictInfo.value.length > 0)
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<InfoPanelHeader :node-packs>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
<div class="mt-5">
<span class="inline-block mr-2 text-blue-500 text-base">{{
nodePacks.length
}}</span>
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
</div>
</template>
<template #install-button>
<!-- Mixed: Don't show any button -->
<div v-if="isMixed" class="text-sm text-neutral-500">
{{ $t('manager.mixedSelectionMessage') }}
</div>
<!-- All installed: Show uninstall button -->
<PackUninstallButton
v-else-if="isAllInstalled"
size="md"
:node-packs="installedPacks"
/>
<!-- None installed: Show install button -->
<PackInstallButton
v-else-if="isNoneInstalled"
size="md"
:node-packs="notInstalledPacks"
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
</template>
</InfoPanelHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage
:status-type="overallStatus"
:has-compatibility-issues="hasConflicts"
/>
</MetadataRow>
<MetadataRow
:label="$t('manager.totalNodes')"
:value="totalNodesCount"
/>
</div>
</div>
</div>
<div v-else class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, onUnmounted, provide, toRef } from 'vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUninstallButton from '@/components/dialog/content/manager/button/PackUninstallButton.vue'
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { usePacksSelection } from '@/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const nodePacksRef = toRef(() => nodePacks)
// Use new composables for cleaner code
const {
installedPacks,
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed
} = usePacksSelection(nodePacksRef)
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacksRef)
const { checkNodeCompatibility } = useConflictDetection()
const { getNodeDefs } = useComfyRegistryStore()
// Provide import failed context for PackStatusMessage
provide(ImportFailedKey, {
importFailed: hasImportFailed,
showImportFailedDialog: () => {} // No-op for multi-selection
})
// Check for conflicts in not-installed packages - keep original logic but simplified
const packageConflicts = computed(() => {
const conflictsByPackage = new Map<string, ConflictDetail[]>()
for (const pack of notInstalledPacks.value) {
const compatibilityCheck = checkNodeCompatibility(pack)
if (compatibilityCheck.hasConflict && pack.id) {
conflictsByPackage.set(pack.id, compatibilityCheck.conflicts)
}
}
return conflictsByPackage
})
// Aggregate all unique conflicts for display
const conflictInfo = computed<ConflictDetail[]>(() => {
const conflictMap = new Map<string, ConflictDetail>()
packageConflicts.value.forEach((conflicts) => {
conflicts.forEach((conflict) => {
const key = `${conflict.type}-${conflict.current_value}-${conflict.required_value}`
if (!conflictMap.has(key)) {
conflictMap.set(key, conflict)
}
})
})
return Array.from(conflictMap.values())
})
const hasConflicts = computed(() => conflictInfo.value.length > 0)
const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!pack.latest_version?.version) return []
const nodeDefs = await getNodeDefs.call({
packId: pack.id,
version: pack.latest_version?.version,
// Fetch all nodes.
// TODO: Render all nodes previews and handle pagination.
// For determining length, use the `totalNumberOfPages` field of response
limit: 8192
})
return nodeDefs?.comfy_nodes ?? []
}
const { state: allNodeDefs } = useAsyncState(
() => Promise.all(nodePacks.map(getPackNodes)),
[],
{
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),
0
)
)
onUnmounted(() => {
getNodeDefs.cancel()
})
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList class="overflow-x-auto scrollbar-hide">
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
<div class="flex items-center gap-1">
<span></span>
{{ importFailed ? $t('g.error') : $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes" class="p-2">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<TabPanels class="overflow-auto py-4 px-2">
<TabPanel
v-if="hasCompatibilityIssues"
value="warning"
class="bg-transparent"
>
<WarningTabPanel
:node-pack="nodePack"
:conflict-result="conflictResult"
/>
</TabPanel>
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" :node-names="nodeNames" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, inject, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const nodeNames = computed(() => {
// @ts-expect-error comfy_nodes is an Algolia-specific field
const { comfy_nodes } = nodePack
return comfy_nodes ?? []
})
const activeTab = ref('description')
// Watch for compatibility issues and automatically switch to warning tab
watchEffect(
() => {
if (hasCompatibilityIssues) {
activeTab.value = 'warning'
} else if (activeTab.value === 'warning') {
// If currently on warning tab but no issues, switch to description
activeTab.value = 'description'
}
},
{ flush: 'post' }
)
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-3">
{{ section.title }}
</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
:href="section.text"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2"
>
<i v-if="isGitHubLink(section.text)" class="pi pi-github text-base" />
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownText from '@/components/dialog/content/manager/infoPanel/MarkdownText.vue'
export interface TextSection {
title: string
text: string
isUrl?: boolean
}
defineProps<{
sections: TextSection[]
}>()
const isGitHubLink = (url: string): boolean => url.includes('github.com')
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div>
<div v-if="!hasMarkdown" class="break-words" v-text="text" />
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
v-if="segment.type === 'link' && 'url' in segment"
:href="segment.url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
>
<span class="text-blue-600">{{ segment.text }}</span>
</a>
<strong v-else-if="segment.type === 'bold'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { text } = defineProps<{
text: string
}>()
type MarkdownSegment = {
type: 'text' | 'link' | 'bold' | 'italic' | 'code'
text: string
url?: string
}
const hasMarkdown = computed(() => {
const hasMarkdown =
/(\[.*?\]\(.*?\)|(\*\*|__)(.*?)(\*\*|__)|(\*|_)(.*?)(\*|_)|`(.*?)`)/.test(
text
)
return hasMarkdown
})
const parsedSegments = computed(() => {
if (!hasMarkdown.value) return [{ type: 'text', text }]
const segments: MarkdownSegment[] = []
const remainingText = text
let lastIndex: number = 0
const linkRegex = /\[(.*?)\]\((.*?)\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(remainingText)) !== null) {
// Add text before the match
if (linkMatch.index > lastIndex) {
segments.push({
type: 'text',
text: remainingText.substring(lastIndex, linkMatch.index)
})
}
// Add the link
segments.push({
type: 'link',
text: linkMatch[1],
url: linkMatch[2]
})
lastIndex = linkMatch.index + linkMatch[0].length
}
// Add remaining text after all links
if (lastIndex < remainingText.length) {
let rest = remainingText.substring(lastIndex)
// Process bold text
rest = rest.replace(/(\*\*|__)(.*?)(\*\*|__)/g, (_, __, p2) => {
segments.push({ type: 'bold', text: p2 })
return ''
})
// Process italic text
rest = rest.replace(/(\*|_)(.*?)(\*|_)/g, (_, __, p2) => {
segments.push({ type: 'italic', text: p2 })
return ''
})
// Process code
rest = rest.replace(/`(.*?)`/g, (_, p1) => {
segments.push({ type: 'code', text: p1 })
return ''
})
// Add any remaining text
if (rest) {
segments.push({ type: 'text', text: rest })
}
}
return segments
})
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const { value = 'N/A', label = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()
</script>

View File

@@ -0,0 +1,179 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from './DescriptionTabPanel.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
const TRANSLATIONS = {
description: 'Description',
repository: 'Repository',
license: 'License',
noDescription: 'No description available'
}
describe('DescriptionTabPanel', () => {
const mountComponent = (props: {
nodePack: Partial<components['schemas']['Node']>
}) => {
return mount(DescriptionTabPanel, {
props,
global: {
plugins: [i18n]
}
})
}
const getSectionByTitle = (
wrapper: ReturnType<typeof mountComponent>,
title: string
) => {
const sections = wrapper
.findComponent({ name: 'InfoTextSection' })
.props('sections')
return sections.find((s: any) => s.title === title)
}
const createNodePack = (
overrides: Partial<components['schemas']['Node']> = {}
) => ({
description: 'Test description',
...overrides
})
const licenseTests = [
{
name: 'handles plain text license',
nodePack: createNodePack({
license: 'MIT License',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'MIT License',
isUrl: false
}
},
{
name: 'handles license file names',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE',
isUrl: true
}
},
{
name: 'handles license.md file names',
nodePack: createNodePack({
license: 'license.md',
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/license.md',
isUrl: true
}
},
{
name: 'handles JSON license objects with text property',
nodePack: createNodePack({
license: JSON.stringify({ text: 'GPL-3.0' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'GPL-3.0',
isUrl: false
}
},
{
name: 'handles JSON license objects with file property',
nodePack: createNodePack({
license: JSON.stringify({ file: 'LICENSE.md' }),
repository: 'https://github.com/user/repo'
}),
expected: {
text: 'https://github.com/user/repo/blob/main/LICENSE.md',
isUrl: true
}
},
{
name: 'handles missing repository URL',
nodePack: createNodePack({
license: 'LICENSE'
}),
expected: {
text: 'LICENSE',
isUrl: false
}
},
{
name: 'handles non-GitHub repository URLs',
nodePack: createNodePack({
license: 'LICENSE',
repository: 'https://gitlab.com/user/repo'
}),
expected: {
text: 'https://gitlab.com/user/repo/blob/main/LICENSE',
isUrl: true
}
}
]
describe('license formatting', () => {
licenseTests.forEach((test) => {
it(test.name, () => {
const wrapper = mountComponent({ nodePack: test.nodePack })
const licenseSection = getSectionByTitle(wrapper, TRANSLATIONS.license)
expect(licenseSection).toBeDefined()
expect(licenseSection.text).toBe(test.expected.text)
expect(licenseSection.isUrl).toBe(test.expected.isUrl)
})
})
})
describe('description sections', () => {
it('shows description section', () => {
const wrapper = mountComponent({
nodePack: createNodePack()
})
const descriptionSection = getSectionByTitle(
wrapper,
TRANSLATIONS.description
)
expect(descriptionSection).toBeDefined()
expect(descriptionSection.text).toBe('Test description')
})
it('shows repository section when available', () => {
const wrapper = mountComponent({
nodePack: createNodePack({
repository: 'https://github.com/user/repo'
})
})
const repoSection = getSectionByTitle(wrapper, TRANSLATIONS.repository)
expect(repoSection).toBeDefined()
expect(repoSection.text).toBe('https://github.com/user/repo')
expect(repoSection.isUrl).toBe(true)
})
it('shows fallback text when description is missing', () => {
const wrapper = mountComponent({
nodePack: {
description: undefined
}
})
expect(wrapper.find('p').text()).toBe(TRANSLATIONS.noDescription)
})
})
})

View File

@@ -0,0 +1,151 @@
<template>
<div class="overflow-hidden">
<InfoTextSection
v-if="nodePack?.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
<div v-if="nodePack?.latest_version?.dependencies?.length">
<p class="mb-1">
{{ $t('manager.dependencies') }}
</p>
<div
v-for="(dep, index) in nodePack.latest_version.dependencies"
:key="index"
class="text-muted break-words"
>
{{ dep }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import InfoTextSection, {
type TextSection
} from '@/components/dialog/content/manager/infoPanel/InfoTextSection.vue'
import { components } from '@/types/comfyRegistryTypes'
import { isValidUrl } from '@/utils/formatUtil'
const { t } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isLicenseFile = (filename: string): boolean => {
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
const licensePattern = /^license(\.md|\.txt)?$/i
return licensePattern.test(filename)
}
const extractBaseRepoUrl = (repoUrl: string): string => {
const githubRepoPattern = /^(https?:\/\/github\.com\/[^/]+\/[^/]+)/i
const match = repoUrl.match(githubRepoPattern)
return match ? match[1] : repoUrl
}
const createLicenseUrl = (filename: string, repoUrl: string): string => {
if (!repoUrl || !filename) return ''
const licenseFile = isLicenseFile(filename) ? filename : 'LICENSE'
const baseRepoUrl = extractBaseRepoUrl(repoUrl)
return `${baseRepoUrl}/blob/main/${licenseFile}`
}
const parseLicenseObject = (
licenseObj: any
): { text: string; isUrl: boolean } => {
const licenseFile = licenseObj.file || licenseObj.text
if (
typeof licenseFile === 'string' &&
isLicenseFile(licenseFile) &&
nodePack.repository
) {
const url = createLicenseUrl(licenseFile, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
} else if (licenseObj.text) {
return {
text: licenseObj.text,
isUrl: false
}
} else if (typeof licenseFile === 'string') {
// Return the license file name if repository is missing
return {
text: licenseFile,
isUrl: false
}
}
return {
text: JSON.stringify(licenseObj),
isUrl: false
}
}
const formatLicense = (
license: string
): { text: string; isUrl: boolean } | null => {
// Treat "{}" JSON string as undefined
if (license === '{}') return null
try {
const licenseObj = JSON.parse(license)
// Handle empty object case
if (Object.keys(licenseObj).length === 0) {
return null
}
return parseLicenseObject(licenseObj)
} catch (e) {
if (isLicenseFile(license) && nodePack.repository) {
const url = createLicenseUrl(license, nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
return {
text: license,
isUrl: false
}
}
}
const descriptionSections = computed<TextSection[]>(() => {
const sections: TextSection[] = [
{
title: t('g.description'),
text: nodePack.description || t('manager.noDescription')
}
]
if (nodePack.repository) {
sections.push({
title: t('manager.repository'),
text: nodePack.repository,
isUrl: isValidUrl(nodePack.repository)
})
}
if (nodePack.license) {
const licenseInfo = formatLicense(nodePack.license)
if (licenseInfo && licenseInfo.text) {
sections.push({
title: t('manager.license'),
text: licenseInfo.text,
isUrl: licenseInfo.isUrl
})
}
}
return sections
})
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<template v-if="mappedNodeDefs?.length">
<div
v-for="nodeDef in mappedNodeDefs"
:key="createNodeDefKey(nodeDef)"
class="border rounded-lg p-4"
>
<NodePreview :node-def="nodeDef" class="text-[.625rem]! min-w-full!" />
</div>
</template>
<template v-else-if="isLoading">
<ProgressSpinner />
</template>
<template v-else-if="nodeNames.length">
<div v-for="node in nodeNames" :key="node" class="text-muted truncate">
{{ node }}
</div>
</template>
<template v-else>
<NoResultsPlaceholder
:title="$t('manager.noNodesFound')"
:message="$t('manager.noNodesFoundDescription')"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, shallowRef, useId } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { components, operations } from '@/types/comfyRegistryTypes'
import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil'
type ListComfyNodesResponse =
operations['ListComfyNodes']['responses'][200]['content']['application/json']['comfy_nodes']
const { nodePack, nodeNames } = defineProps<{
nodePack: components['schemas']['Node']
nodeNames: string[]
}>()
const { getNodeDefs } = useComfyRegistryStore()
const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
getNodeDefs.cancel()
isLoading.value = true
const { id: packId } = nodePack
const version = nodePack.latest_version?.version
if (!packId || !version) {
registryNodeDefs.value = null
} else {
const response = await getNodeDefs.call({
packId,
version,
page: 1,
limit: 256
})
registryNodeDefs.value = response?.comfy_nodes ?? null
}
isLoading.value = false
}
whenever(() => nodePack, fetchNodeDefs, { immediate: true, deep: true })
const toFrontendNodeDef = (nodeDef: components['schemas']['ComfyNode']) => {
try {
return registryToFrontendV2NodeDef(nodeDef, nodePack)
} catch (error) {
return null
}
}
const mappedNodeDefs = computed(() => {
if (!registryNodeDefs.value) return null
return registryNodeDefs.value
.map(toFrontendNodeDef)
.filter((nodeDef) => nodeDef !== null)
})
const createNodeDefKey = (nodeDef: components['schemas']['ComfyNode']) =>
`${nodeDef.category}${nodeDef.comfy_node_name ?? useId()}`
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="cursor-pointer outline-none border-none inline-flex items-center justify-end bg-transparent gap-1"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="dark-theme:text-white text-sm">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="flex justify-between items-center">
<div class="text-sm break-words flex-1">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { t } from '@/i18n'
import { components } from '@/types/comfyRegistryTypes'
import { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult: ConflictDetectionResult | null | undefined
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="w-full aspect-7/3 overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -0,0 +1,149 @@
<template>
<Card
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
:class="{
'selected-card': isSelected,
'opacity-60': isDisabled
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: {
class: 'p-0 m-0 flex flex-col gap-0',
style: {
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
}
}
}"
>
<template #title>
<PackBanner :node-pack="nodePack" />
</template>
<template #content>
<div class="pt-4 px-4 pb-3 w-full h-full">
<div class="flex flex-col gap-y-1 w-full h-full">
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
<div class="flex-1 flex items-center gap-2">
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
:fill="false"
:class="isInstalling ? 'pointer-events-none' : ''"
/>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<PackCardFooter :node-pack="nodePack" :is-installing="isInstalling" />
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
IsInstallingKey,
type MergedNodePack,
type RegistryPack,
isMergedNodePack
} from '@/types/comfyManagerTypes'
const { nodePack, isSelected = false } = defineProps<{
nodePack: MergedNodePack | RegistryPack
isSelected?: boolean
}>()
const { d } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const { isPackInstalled, isPackEnabled, isPackInstalling } =
useComfyManagerStore()
const isInstalling = computed(() => isPackInstalling(nodePack?.id))
provide(IsInstallingKey, isInstalling)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedLatestVersionDate = computed(() => {
if (!nodePack.latest_version?.createdAt) return null
return d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
})
</script>
<style scoped>
.selected-card {
position: relative;
}
.selected-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 4px solid var(--p-primary-color);
border-radius: 0.5rem;
pointer-events: none;
z-index: 100;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton
v-if="!isInstalled"
:node-packs="[nodePack]"
:is-installing="isInstalling"
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
<PackEnableToggle
v-else
:has-conflict="hasConflicts"
:node-pack="nodePack"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isInstalling = inject(IsInstallingKey)
const { n } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
// Add conflict detection for the card button
const { checkNodeCompatibility } = useConflictDetection()
// Check for conflicts with this specific node pack
const conflictInfo = computed<ConflictDetail[]>(() => {
if (!nodePack) return []
const compatibilityCheck = checkNodeCompatibility(nodePack)
return compatibilityCheck.conflicts || []
})
const hasConflicts = computed(() => conflictInfo.value.length > 0)
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="w-full max-w-[204] aspect-[2/1] rounded-lg overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="relative w-[224px] h-[104px] shadow-xl">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute w-[210px] h-[90px]"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg shadow-lg p-0.5">
<PackIcon :node-pack="pack" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
const {
nodePacks,
maxVisible = 3,
offset = 8
} = defineProps<{
nodePacks: components['schemas']['Node'][]
maxVisible?: number
offset?: number
}>()
</script>

View File

@@ -0,0 +1,133 @@
<template>
<div class="relative w-full p-6">
<div class="h-12 flex items-center gap-1 justify-between">
<div class="flex items-center w-5/12">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model:modelValue="searchMode"
:options="filterOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults?.length || 0 }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { stubTrue } from 'es-toolkit/compat'
import AutoComplete, {
AutoCompleteOptionSelectEvent
} from 'primevue/autocomplete'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
default: SortableAlgoliaField.Downloads
})
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const {
hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
:model-value="modelValue"
:options="options"
option-label="label"
option-value="id"
class="min-w-[6rem] border-none bg-transparent shadow-none"
:pt="{
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
@update:model-value="$emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup lang="ts" generic="T">
// eslint-disable-next-line no-restricted-imports -- TODO: Migrate to Select component
import Dropdown from 'primevue/dropdown'
import type { SearchOption } from '@/types/comfyManagerTypes'
defineProps<{
options: SearchOption<T>[]
label: string
modelValue: T
}>()
defineEmits<{
'update:modelValue': [value: T]
}>()
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div :style="gridStyle">
<PackCardSkeleton v-for="n in skeletonCardCount" :key="n" />
</div>
</template>
<script setup lang="ts">
import PackCardSkeleton from '@/components/dialog/content/manager/skeleton/PackCardSkeleton.vue'
const { skeletonCardCount = 12, gridStyle } = defineProps<{
skeletonCardCount?: number
gridStyle: {
display: string
gridTemplateColumns: string
padding: string
gap: string
}
}>()
</script>

View File

@@ -0,0 +1,79 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import GridSkeleton from './GridSkeleton.vue'
import PackCardSkeleton from './PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(GridSkeleton, {
props: {
gridStyle: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
},
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
stubs: {
PackCardSkeleton: true
}
}
})
}
it('renders with default props', () => {
const wrapper = mountComponent()
expect(wrapper.exists()).toBe(true)
})
it('applies the provided grid style', () => {
const customGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '1rem',
gap: '1rem'
}
const wrapper = mountComponent({
props: { gridStyle: customGridStyle }
})
const gridElement = wrapper.element
expect(gridElement.style.display).toBe('grid')
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(gridElement.style.padding).toBe('1rem')
expect(gridElement.style.gap).toBe('1rem')
})
it('renders the specified number of skeleton cards', async () => {
const cardCount = 5
const wrapper = mountComponent({
props: { skeletonCardCount: cardCount }
})
await nextTick()
const skeletonCards = wrapper.findAllComponents(PackCardSkeleton)
expect(skeletonCards.length).toBe(5)
})
})

View File

@@ -0,0 +1,54 @@
<template>
<div
class="rounded-lg shadow-sm h-full overflow-hidden flex flex-col"
data-virtual-grid-item
>
<!-- Card header - flush with top, approximately 15% of height -->
<div class="w-full px-4 py-3 flex justify-between items-center">
<div class="flex items-center">
<div class="w-6 h-6 flex items-center justify-center">
<Skeleton shape="circle" width="1.5rem" height="1.5rem" />
</div>
<Skeleton width="5rem" height="1rem" class="ml-2" />
</div>
<Skeleton width="4rem" height="1.75rem" border-radius="0.75rem" />
</div>
<!-- Card content with icon on left and text on right -->
<div class="flex-1 p-4 flex">
<!-- Left icon - 64x64 -->
<div class="shrink-0 mr-4">
<Skeleton width="4rem" height="4rem" border-radius="0.5rem" />
</div>
<!-- Right content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Title -->
<Skeleton width="80%" height="1rem" class="mb-2" />
<!-- Description -->
<div class="mb-3">
<Skeleton width="100%" height="0.75rem" class="mb-1" />
<Skeleton width="95%" height="0.75rem" class="mb-1" />
<Skeleton width="90%" height="0.75rem" />
</div>
<!-- Tags/Badges -->
<div class="flex gap-2">
<Skeleton width="4rem" height="1.5rem" border-radius="0.75rem" />
<Skeleton width="5rem" height="1.5rem" border-radius="0.75rem" />
</div>
</div>
</div>
<!-- Card footer - similar to header -->
<div class="w-full px-5 py-4 flex justify-between items-center">
<Skeleton width="4rem" height="0.8rem" />
<Skeleton width="6rem" height="0.8rem" />
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -96,8 +96,8 @@ import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import type { AuditLog } from '@/services/customerEventsService'
import {
AuditLog,
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'

View File

@@ -13,7 +13,7 @@
import Tag from 'primevue/tag'
import { computed } from 'vue'
import type { KeyComboImpl } from '@/stores/keybindingStore'
import { KeyComboImpl } from '@/stores/keybindingStore'
const { keyCombo, isModified = false } = defineProps<{
keyCombo: KeyComboImpl

View File

@@ -79,8 +79,7 @@
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'

View File

@@ -1,6 +1,5 @@
import { Form } from '@primevue/forms'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'

View File

@@ -71,8 +71,7 @@
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'

View File

@@ -59,8 +59,7 @@
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form, FormField } from '@primevue/forms'
import { Form, FormField, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'

View File

@@ -0,0 +1,190 @@
<template>
<div
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
: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"
rounded
outlined
class="mr-4 rounded-md border-2 px-3 text-neutral-600 border-neutral-900 hover:bg-neutral-100 !dark-theme:bg-transparent dark-theme:text-white dark-theme:border-white dark-theme:hover:bg-neutral-800"
@click="handleRestart"
>
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
: 'pi pi-chevron-down'
"
text
rounded
size="small"
class="font-bold"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
/>
<Button
icon="pi pi-times"
text
rounded
size="small"
class="font-bold"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { performConflictDetection } = 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 after restart completion
await performConflictDetection()
} 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>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="progressDialogContent.isExpanded"
class="px-4 py-2 flex items-center"
>
<TabMenu
v-model:activeIndex="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 '@/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>

View File

@@ -34,7 +34,7 @@ const updateWidgets = () => {
const widget = widgetState.widget
// Early exit for non-visible widgets
if (!widget.isVisible() || !widgetState.active) {
if (!widget.isVisible()) {
widgetState.visible = false
continue
}

View File

@@ -33,7 +33,7 @@
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@@ -43,6 +43,8 @@
v-for="nodeData in allNodes"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
@@ -51,6 +53,9 @@
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/>
</TransformPane>
@@ -71,9 +76,9 @@
import { useEventListener, whenever } from '@vueuse/core'
import {
computed,
nextTick,
onMounted,
onUnmounted,
provide,
ref,
shallowRef,
watch,
@@ -91,6 +96,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -111,11 +117,12 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -162,33 +169,44 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle()
const viewportCulling = useViewportCulling()
const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick()
vueNodeLifecycle.initializeNodeManager()
}
}
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
watch(
() => canvasStore.isInSubgraph,
async (newValue, oldValue) => {
if (oldValue && !newValue) {
useWorkflowStore().updateActiveGraph()
}
await handleVueNodeLifecycleReset()
}
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
const viewportCulling = useViewportCulling(
isVueNodesEnabled,
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
)
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const allNodes = viewportCulling.allNodes
const handleTransformUpdate = viewportCulling.handleTransformUpdate
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -386,7 +404,6 @@ onMounted(async () => {
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
attachSlotLinkPreviewRenderer(comfyApp.canvas)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false

View File

@@ -124,11 +124,11 @@ import ButtonGroup from 'primevue/buttongroup'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useZoomControls } from '@/composables/useZoomControls'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -33,11 +33,9 @@ const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
function hideTooltip() {
return (tooltipText.value = '')
}
const hideTooltip = () => (tooltipText.value = '')
async function showTooltip(tooltip: string | null | undefined) {
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
left.value = comfyApp.canvas.mouse[0] + 'px'
@@ -58,9 +56,9 @@ async function showTooltip(tooltip: string | null | undefined) {
}
}
function onIdle() {
const onIdle = () => {
const { canvas } = comfyApp
const node = canvas?.node_over
const node = canvas.node_over
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
@@ -70,7 +68,7 @@ function onIdle() {
ctor.title_mode !== LiteGraph.NO_TITLE &&
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
) {
return showTooltip(nodeDef?.description)
return showTooltip(nodeDef.description)
}
if (node.flags?.collapsed) return
@@ -85,7 +83,7 @@ function onIdle() {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef?.inputs[inputName]?.tooltip ?? ''
nodeDef.inputs[inputName]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -99,7 +97,7 @@ function onIdle() {
if (outputSlot !== -1) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
nodeDef.outputs[outputSlot]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -109,7 +107,7 @@ function onIdle() {
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef?.inputs[widget.name]?.tooltip ?? ''
nodeDef.inputs[widget.name]?.tooltip ?? ''
)
// Widget tooltip can be set dynamically, current translation collection does not support this.
return showTooltip(widget.tooltip ?? translatedTooltip)

View File

@@ -5,12 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
vi.mock('@/composables/graph/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
handleWheel: vi.fn()
}))

View File

@@ -11,7 +11,7 @@
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
:pt="{
header: 'hidden',
content: 'p-1 h-10 flex flex-row gap-1'
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
}"
@wheel="canvasInteractions.handleWheel"
>
@@ -60,9 +60,9 @@ import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButt
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'

View File

@@ -135,8 +135,7 @@
</template>
<script setup lang="ts">
import type { InputNumberInputEvent } from 'primevue'
import { Button, InputNumber } from 'primevue'
import { Button, InputNumber, InputNumberInputEvent } from 'primevue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -7,7 +7,7 @@
severity="secondary"
text
data-testid="bypass-button"
class="hover:dark-theme:bg-charcoal-600 hover:bg-[#E7E6E6]"
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
@click="toggleBypass"
>
<template #icon>

View File

@@ -50,8 +50,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import type { Raw } from 'vue'
import { computed, ref, watch } from 'vue'
import { Raw, computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {

View File

@@ -20,7 +20,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { Positionable } from '@/lib/litegraph/src/interfaces'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()

View File

@@ -17,8 +17,7 @@
import Button from 'primevue/button'
import { st } from '@/i18n'
import type { ComfyCommand } from '@/stores/commandStore'
import { useCommandStore } from '@/stores/commandStore'
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{

View File

@@ -48,9 +48,9 @@
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import type {
MenuOption,
SubMenuOption
import {
type MenuOption,
type SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'

View File

@@ -56,10 +56,10 @@ import { computed, nextTick, ref, watch } from 'vue'
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue'
import type { ComponentWidget } from '@/scripts/domWidget'
import { ComponentWidget } from '@/scripts/domWidget'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const { widget, history } = defineProps<{
const { widget, history = '[]' } = defineProps<{
widget?: ComponentWidget<string>
history: string
}>()

View File

@@ -19,15 +19,14 @@
<script setup lang="ts">
import { useElementBounding, useEventListener } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { DomWidgetState } from '@/stores/domWidgetStore'
const { widgetState } = defineProps<{
widgetState: DomWidgetState

View File

@@ -15,7 +15,7 @@
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref, watch } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/litegraph'
import { NodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'

View File

@@ -142,14 +142,14 @@ import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useManagerState } from '@/composables/useManagerState'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { type ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Types
interface MenuItem {

View File

@@ -3,7 +3,7 @@ import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
import { type SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
interface ExtendedProps extends Partial<MultiSelectProps> {

View File

@@ -3,7 +3,7 @@ import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
import { type SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available

View File

@@ -106,8 +106,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import MultiSelect, {
MultiSelectPassThroughMethodOptions
} from 'primevue/multiselect'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -116,7 +117,7 @@ import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import TextButton from '../button/TextButton.vue'
import type { SelectOption } from './types'
import { type SelectOption } from './types'
type Option = SelectOption

View File

@@ -58,14 +58,13 @@
</template>
<script setup lang="ts">
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
import { type SelectOption } from './types'
defineOptions({
inheritAttrs: false

View File

@@ -34,16 +34,16 @@
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import {
TorchDeviceType,
TorchMirrorUrl
} from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import type { ModelRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ModelRef, computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'

View File

@@ -23,7 +23,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { UVMirror } from '@/constants/uvMirrors'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'

Some files were not shown because too many files have changed in this diff Show More