mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
48 Commits
v1.40.10
...
pysssss/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6b7437db | ||
|
|
bad9642fad | ||
|
|
ab1b72a8df | ||
|
|
615f5724f4 | ||
|
|
67af4415a8 | ||
|
|
3d2699c07c | ||
|
|
a597050d7c | ||
|
|
c44155bcb3 | ||
|
|
99d6f87f00 | ||
|
|
c545c9d5d0 | ||
|
|
7d73f01588 | ||
|
|
663efeff35 | ||
|
|
f88a42619e | ||
|
|
ca86212d66 | ||
|
|
2e3a77567e | ||
|
|
d85db6367e | ||
|
|
c8f1e0395a | ||
|
|
7d00323935 | ||
|
|
cc85df9ae4 | ||
|
|
def0def247 | ||
|
|
4754be7567 | ||
|
|
b52785a8a0 | ||
|
|
1b16b0f6f8 | ||
|
|
fedce61238 | ||
|
|
726ae23cfd | ||
|
|
7a4f7dc4de | ||
|
|
824f8bb281 | ||
|
|
a4975a0e62 | ||
|
|
9a4aff542c | ||
|
|
34dd961561 | ||
|
|
d78e4b92cb | ||
|
|
41d4adaf45 | ||
|
|
99d31a7376 | ||
|
|
193d4827af | ||
|
|
594c2497fd | ||
|
|
1a94e34c0c | ||
|
|
1aa10e7c8f | ||
|
|
91ad183c24 | ||
|
|
e637471353 | ||
|
|
98532a8217 | ||
|
|
70b514a48d | ||
|
|
f80b87b46c | ||
|
|
bde6678cd7 | ||
|
|
37477b2e43 | ||
|
|
3f07dd255f | ||
|
|
2ed895c3dc | ||
|
|
f5b0b70676 | ||
|
|
9058d3ec54 |
35
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
35
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -34,13 +34,10 @@ jobs:
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
DISTRIBUTION: localhost
|
||||
|
||||
- name: Scan dist for GTM telemetry references
|
||||
- name: Scan dist for telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Google Tag Manager references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
@@ -49,33 +46,7 @@ jobs:
|
||||
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
|
||||
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Google Tag Manager references found in dist assets!'
|
||||
echo 'GTM must be properly tree-shaken from OSS builds.'
|
||||
echo 'Telemetry references found in dist assets.'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No GTM references found'
|
||||
|
||||
- name: Scan dist for Mixpanel telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Mixpanel references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)mixpanel\.init' \
|
||||
-e '(?i)mixpanel\.identify' \
|
||||
-e 'MixpanelTelemetryProvider' \
|
||||
-e 'mp\.comfy\.org' \
|
||||
-e 'mixpanel-browser' \
|
||||
-e '(?i)mixpanel\.track\(' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Mixpanel references found in dist assets!'
|
||||
echo 'Mixpanel must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Mixpanel references found'
|
||||
echo 'No telemetry references found in dist assets.'
|
||||
|
||||
@@ -100,8 +100,7 @@ const config: StorybookConfig = {
|
||||
rolldownOptions: {
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true,
|
||||
strictExecutionOrder: true
|
||||
keepNames: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
|
||||
@@ -244,9 +244,21 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.serialize()
|
||||
})
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const workflow = localStorage.getItem('workflow')
|
||||
if (!workflow) return null
|
||||
try {
|
||||
const data = JSON.parse(workflow)
|
||||
return Array.isArray(data?.nodes) ? data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).jsonValue()
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
|
||||
test('@mobile confirm dialog buttons are visible with long unbreakable text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const longFilename = 'workflow_checkpoint_' + 'a'.repeat(200) + '.json'
|
||||
|
||||
await comfyPage.page.evaluate((msg) => {
|
||||
window
|
||||
.app!.extensionManager.dialog.confirm({
|
||||
title: 'Confirm',
|
||||
type: 'default',
|
||||
message: msg
|
||||
})
|
||||
.catch(() => {})
|
||||
}, longFilename)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
|
||||
await expect(confirmButton).toBeVisible()
|
||||
await expect(confirmButton).toBeInViewport()
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await expect(cancelButton).toBeVisible()
|
||||
await expect(cancelButton).toBeInViewport()
|
||||
})
|
||||
})
|
||||
@@ -61,21 +61,16 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
})
|
||||
|
||||
test.describe('Execution error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should display an error message when an execution error occurs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await comfyPage.queueButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the error overlay to be visible
|
||||
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
// Wait for the element with the .comfy-execution-error selector to be visible
|
||||
const executionError = comfyPage.page.locator('.comfy-error-report')
|
||||
await expect(executionError).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -345,23 +340,17 @@ test.describe('Support', () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Prevent loading the external page
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.route('https://support.comfy.org/**', (route) =>
|
||||
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
|
||||
)
|
||||
|
||||
const popupPromise = comfyPage.page.waitForEvent('popup')
|
||||
const pagePromise = comfyPage.page.context().waitForEvent('page')
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
|
||||
const popup = await popupPromise
|
||||
const newPage = await pagePromise
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
await newPage.waitForLoadState('networkidle')
|
||||
await expect(newPage).toHaveURL(/.*support\.comfy\.org.*/)
|
||||
|
||||
const url = new URL(newPage.url())
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
|
||||
await popup.close()
|
||||
await newPage.close()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,29 +7,22 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
})
|
||||
|
||||
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test(
|
||||
'Report error on unconnected slot',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.disconnectEdge()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.getByRole('button', { name: 'Dismiss' })
|
||||
.locator('.p-dialog')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.waitFor({ state: 'hidden' })
|
||||
await comfyPage.page.locator('.comfy-error-report').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 97 KiB |
@@ -10,7 +10,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
})
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
// Wait for the legacy menu to appear and canvas to settle after layout shift.
|
||||
await comfyPage.page.locator('.comfy-menu').waitFor({ state: 'visible' })
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
|
||||
@@ -736,25 +733,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'single_ksampler_modified.png'
|
||||
)
|
||||
// Wait for V2 persistence debounce to save the modified workflow
|
||||
const start = Date.now()
|
||||
await comfyPage.page.waitForFunction((since) => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (!json) continue
|
||||
try {
|
||||
const index = JSON.parse(json)
|
||||
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, start)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'single_ksampler_modified.png'
|
||||
@@ -777,17 +755,10 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for sessionStorage to persist the workflow paths before reloading
|
||||
// V2 persistence uses sessionStorage with client-scoped keys
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.sessionStorage.length; i++) {
|
||||
const key = window.sessionStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Wait for localStorage to persist the workflow paths before reloading
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
|
||||
)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -27,7 +27,6 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Properties panel position', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
// Open a sidebar tab to ensure sidebar is visible
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
@@ -53,7 +53,6 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
@@ -5,7 +5,6 @@ import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
test.describe('Node library sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeLibrary.BookmarksCustomization',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.40.10",
|
||||
"version": "1.40.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1192,7 +1192,7 @@ button.comfy-queue-btn {
|
||||
.graphdialog {
|
||||
min-height: 1em;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
z-index: 1500;
|
||||
z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */
|
||||
}
|
||||
|
||||
.graphdialog .name {
|
||||
|
||||
@@ -339,7 +339,7 @@ describe('TopMenuSection', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activeJobId = 'job-1'
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
@@ -429,7 +429,7 @@ describe('TopMenuSection', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activeJobId = 'job-1'
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -157,7 +156,6 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
@@ -292,12 +290,12 @@ const showQueueContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
TaskItemImpl,
|
||||
useQueueSettingsStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/utils/graphHasMissingNodes', () => ({
|
||||
graphHasMissingNodes: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
shiftDown: false
|
||||
})
|
||||
}))
|
||||
|
||||
const SplitButtonStub = defineComponent({
|
||||
name: 'SplitButton',
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
severity: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<button
|
||||
data-testid="split-button"
|
||||
:data-label="label"
|
||||
:data-severity="severity"
|
||||
>
|
||||
<slot name="icon" />
|
||||
</button>
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: {
|
||||
run: 'Run',
|
||||
disabledTooltip: 'Disabled tooltip',
|
||||
onChange: 'On Change',
|
||||
onChangeTooltip: 'On change tooltip',
|
||||
instant: 'Instant',
|
||||
instantTooltip: 'Instant tooltip',
|
||||
stopRunInstant: 'Stop Run (Instant)',
|
||||
stopRunInstantTooltip: 'Stop running',
|
||||
runWorkflow: 'Run workflow',
|
||||
runWorkflowFront: 'Run workflow front',
|
||||
runWorkflowDisabled: 'Run workflow disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
const job: JobListItem = {
|
||||
id,
|
||||
status,
|
||||
create_time: Date.now(),
|
||||
priority: 1
|
||||
}
|
||||
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
|
||||
return mount(ComfyQueueButton, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
},
|
||||
stubs: {
|
||||
SplitButton: SplitButtonStub,
|
||||
BatchCountEdit: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyQueueButton', () => {
|
||||
it('keeps the run instant presentation while idle even with active jobs', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
||||
|
||||
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
|
||||
expect(splitButton.attributes('data-severity')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to stop presentation when instant mode is armed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-running'
|
||||
await nextTick()
|
||||
|
||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
||||
|
||||
expect(splitButton.attributes('data-label')).toBe('Stop Run (Instant)')
|
||||
expect(splitButton.attributes('data-severity')).toBe('danger')
|
||||
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disarms instant mode without interrupting even when jobs are active', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-running'
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
const splitButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(splitButtonWhileStopping.attributes('data-label')).toBe(
|
||||
'Run (Instant)'
|
||||
)
|
||||
expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
|
||||
expect(commandStore.execute).not.toHaveBeenCalled()
|
||||
|
||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
|
||||
expect(splitButton.attributes('data-severity')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('activates instant running mode when queueing again', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-running')
|
||||
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.QueuePrompt', {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,8 @@
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
:label="queueButtonLabel"
|
||||
:severity="queueButtonSeverity"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@@ -22,7 +22,7 @@
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
|
||||
:variant="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="sm"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
@@ -48,11 +48,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
isInstantMode,
|
||||
isInstantRunningMode,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
@@ -67,12 +63,6 @@ const hasMissingNodes = computed(() =>
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
|
||||
|
||||
const selectedQueueMode = computed<QueueModeMenuKey>(() =>
|
||||
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
|
||||
)
|
||||
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
const items: Record<string, MenuItem> = {
|
||||
disabled: {
|
||||
@@ -96,15 +86,15 @@ const queueModeMenuItemLookup = computed(() => {
|
||||
}
|
||||
}
|
||||
if (!isCloud) {
|
||||
items['instant-idle'] = {
|
||||
key: 'instant-idle',
|
||||
items.instant = {
|
||||
key: 'instant',
|
||||
label: `${t('menu.run')} (${t('menu.instant')})`,
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
})
|
||||
queueMode.value = 'instant-idle'
|
||||
queueMode.value = 'instant'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +104,7 @@ const queueModeMenuItemLookup = computed(() => {
|
||||
const activeQueueModeMenuItem = computed(() => {
|
||||
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
|
||||
return (
|
||||
queueModeMenuItemLookup.value[selectedQueueMode.value] ||
|
||||
queueModeMenuItemLookup.value[queueMode.value] ||
|
||||
queueModeMenuItemLookup.value.disabled
|
||||
)
|
||||
})
|
||||
@@ -122,24 +112,7 @@ const queueModeMenuItems = computed(() =>
|
||||
Object.values(queueModeMenuItemLookup.value)
|
||||
)
|
||||
|
||||
const isStopInstantAction = computed(() =>
|
||||
isInstantRunningMode(queueMode.value)
|
||||
)
|
||||
|
||||
const queueButtonLabel = computed(() =>
|
||||
isStopInstantAction.value
|
||||
? t('menu.stopRunInstant')
|
||||
: String(activeQueueModeMenuItem.value?.label ?? '')
|
||||
)
|
||||
|
||||
const queueButtonSeverity = computed(() =>
|
||||
isStopInstantAction.value ? 'danger' : 'primary'
|
||||
)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (isStopInstantAction.value) {
|
||||
return 'icon-[lucide--square]'
|
||||
}
|
||||
if (hasMissingNodes.value) {
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
@@ -149,7 +122,7 @@ const iconClass = computed(() => {
|
||||
if (queueMode.value === 'disabled') {
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
if (isInstantMode(queueMode.value)) {
|
||||
if (queueMode.value === 'instant') {
|
||||
return 'icon-[lucide--fast-forward]'
|
||||
}
|
||||
if (queueMode.value === 'change') {
|
||||
@@ -159,9 +132,6 @@ const iconClass = computed(() => {
|
||||
})
|
||||
|
||||
const queueButtonTooltip = computed(() => {
|
||||
if (isStopInstantAction.value) {
|
||||
return t('menu.stopRunInstantTooltip')
|
||||
}
|
||||
if (hasMissingNodes.value) {
|
||||
return t('menu.runWorkflowDisabled')
|
||||
}
|
||||
@@ -173,20 +143,11 @@ const queueButtonTooltip = computed(() => {
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
if (isStopInstantAction.value) {
|
||||
queueMode.value = 'instant-idle'
|
||||
return
|
||||
}
|
||||
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
if (isInstantMode(queueMode.value)) {
|
||||
queueMode.value = 'instant-running'
|
||||
}
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
|
||||
describe(MarqueeLine, () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
slots: { default: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('renders content inside a span within the container', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
slots: { default: 'Test Text' }
|
||||
})
|
||||
const span = wrapper.find('span')
|
||||
expect(span.exists()).toBe(true)
|
||||
expect(span.text()).toBe('Test Text')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes marquee-scroll {
|
||||
0%,
|
||||
20% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: translateX(var(--_marquee-end));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,12 +6,10 @@
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
<div class="font-medium">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div :class="cn(isOutdated(col) && 'text-danger-100')">
|
||||
{{ getDisplayValue(col) }}
|
||||
</div>
|
||||
<div>{{ getDisplayValue(col) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +47,6 @@ import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -79,8 +76,7 @@ const localColumns: ColumnDef[] = [
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
|
||||
{ field: 'installed_templates_version', header: 'Templates Version' }
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
@@ -101,13 +97,6 @@ const cloudColumns: ColumnDef[] = [
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
|
||||
function isOutdated(column: ColumnDef): boolean {
|
||||
if (column.field !== 'installed_templates_version') return false
|
||||
const installed = props.stats.system.installed_templates_version
|
||||
const required = props.stats.system.required_templates_version
|
||||
return !!installed && !!required && installed !== required
|
||||
}
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import TextTickerMultiLine from './TextTickerMultiLine.vue'
|
||||
|
||||
type Callback = () => void
|
||||
|
||||
const resizeCallbacks: Callback[] = []
|
||||
const mutationCallbacks: Callback[] = []
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useResizeObserver: (_target: unknown, cb: Callback) => {
|
||||
resizeCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
},
|
||||
useMutationObserver: (_target: unknown, cb: Callback) => {
|
||||
mutationCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mockElementSize(
|
||||
el: HTMLElement,
|
||||
clientWidth: number,
|
||||
scrollWidth: number
|
||||
) {
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'scrollWidth', {
|
||||
value: scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe(TextTickerMultiLine, () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
resizeCallbacks.length = 0
|
||||
mutationCallbacks.length = 0
|
||||
})
|
||||
|
||||
function mountComponent(text: string) {
|
||||
wrapper = mount(TextTickerMultiLine, {
|
||||
slots: { default: text }
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function getMeasureEl(): HTMLElement {
|
||||
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
|
||||
}
|
||||
|
||||
async function triggerSplitLines() {
|
||||
resizeCallbacks.forEach((cb) => cb())
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
mountComponent('Load Checkpoint')
|
||||
expect(wrapper.text()).toContain('Load Checkpoint')
|
||||
})
|
||||
|
||||
it('renders a single MarqueeLine when text fits', async () => {
|
||||
mountComponent('Short')
|
||||
mockElementSize(getMeasureEl(), 200, 100)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders two MarqueeLines when text overflows', async () => {
|
||||
mountComponent('Load Checkpoint Loader Simple')
|
||||
mockElementSize(getMeasureEl(), 100, 300)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('splits text at word boundary when overflowing', async () => {
|
||||
mountComponent('Load Checkpoint Loader')
|
||||
mockElementSize(getMeasureEl(), 100, 200)
|
||||
await triggerSplitLines()
|
||||
|
||||
const lines = wrapper.findAllComponents(MarqueeLine)
|
||||
expect(lines[0].text()).toBe('Load')
|
||||
expect(lines[1].text()).toBe('Checkpoint Loader')
|
||||
})
|
||||
|
||||
it('has hidden measurement element with aria-hidden', () => {
|
||||
mountComponent('Test')
|
||||
const measureEl = wrapper.find('[aria-hidden="true"]')
|
||||
expect(measureEl.exists()).toBe(true)
|
||||
expect(measureEl.classes()).toContain('invisible')
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hidden single-line measurement element for overflow detection -->
|
||||
<div
|
||||
ref="measureRef"
|
||||
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<MarqueeLine v-if="!secondLine">
|
||||
<slot />
|
||||
</MarqueeLine>
|
||||
|
||||
<div v-else class="flex flex-col w-full">
|
||||
<MarqueeLine>{{ firstLine }}</MarqueeLine>
|
||||
<MarqueeLine>{{ secondLine }}</MarqueeLine>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
|
||||
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const firstLine = ref('')
|
||||
const secondLine = ref('')
|
||||
|
||||
function splitLines() {
|
||||
const el = measureRef.value
|
||||
const text = el?.textContent?.trim()
|
||||
if (!el || !text) {
|
||||
firstLine.value = ''
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = el.clientWidth
|
||||
const textWidth = el.scrollWidth
|
||||
|
||||
if (textWidth <= containerWidth) {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const [first, second] = splitTextAtWordBoundary(
|
||||
text,
|
||||
containerWidth / textWidth
|
||||
)
|
||||
firstLine.value = first
|
||||
secondLine.value = second
|
||||
}
|
||||
|
||||
useResizeObserver(measureRef, splitLines)
|
||||
useMutationObserver(measureRef, splitLines, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
})
|
||||
</script>
|
||||
@@ -1,41 +1,37 @@
|
||||
<template>
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger :disabled="!showContextMenu" as-child>
|
||||
<TreeRoot
|
||||
:expanded="[...expandedKeys]"
|
||||
:items="root.children ?? []"
|
||||
:get-key="(item) => item.key"
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
<TreeRoot
|
||||
:expanded="[...expandedKeys]"
|
||||
:items="root.children ?? []"
|
||||
:get-key="(item) => item.key"
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
>
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
:estimate-size="36"
|
||||
:text-content="(item) => item.value.label ?? ''"
|
||||
>
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
:estimate-size="36"
|
||||
:text-content="(item) => item.value.label ?? ''"
|
||||
<TreeExplorerV2Node
|
||||
:item="
|
||||
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
"
|
||||
@node-click="
|
||||
(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>, e: MouseEvent) =>
|
||||
emit('nodeClick', node, e)
|
||||
"
|
||||
>
|
||||
<TreeExplorerV2Node
|
||||
:item="
|
||||
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
"
|
||||
@node-click="
|
||||
(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
|
||||
e: MouseEvent
|
||||
) => emit('nodeClick', node, e)
|
||||
"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node" />
|
||||
</template>
|
||||
</TreeExplorerV2Node>
|
||||
</TreeVirtualizer>
|
||||
</TreeRoot>
|
||||
</ContextMenuTrigger>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node" />
|
||||
</template>
|
||||
</TreeExplorerV2Node>
|
||||
</TreeVirtualizer>
|
||||
</TreeRoot>
|
||||
|
||||
<ContextMenuPortal v-if="showContextMenu">
|
||||
<ContextMenuContent
|
||||
@@ -53,11 +49,7 @@
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
{{
|
||||
isCurrentNodeBookmarked
|
||||
? $t('sideToolbar.nodeLibraryTab.sections.unfavoriteNode')
|
||||
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
|
||||
}}
|
||||
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
@@ -71,7 +63,6 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuTrigger,
|
||||
TreeRoot,
|
||||
TreeVirtualizer
|
||||
} from 'reka-ui'
|
||||
|
||||
@@ -80,6 +80,10 @@ describe('TreeExplorerV2Node', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
ContextMenuTrigger: {
|
||||
name: 'ContextMenuTrigger',
|
||||
template: '<div data-testid="context-menu-trigger"><slot /></div>'
|
||||
},
|
||||
Teleport: { template: '<div />' }
|
||||
},
|
||||
provide: {
|
||||
@@ -141,12 +145,36 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
|
||||
describe('context menu', () => {
|
||||
it('sets contextMenuNode when contextmenu event is triggered on node', async () => {
|
||||
it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node'),
|
||||
showContextMenu: true
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="context-menu-trigger"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render ContextMenuTrigger for folder items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="context-menu-trigger"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('sets contextMenuNode when contextmenu event is triggered', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const nodeItem = createMockItem('node')
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: nodeItem },
|
||||
{
|
||||
item: nodeItem,
|
||||
showContextMenu: true
|
||||
},
|
||||
{
|
||||
provide: {
|
||||
[InjectKeyContextMenuNode as symbol]: contextMenuNode
|
||||
@@ -159,24 +187,6 @@ describe('TreeExplorerV2Node', () => {
|
||||
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
|
||||
it('does not set contextMenuNode for folder items', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
{
|
||||
provide: {
|
||||
[InjectKeyContextMenuNode as symbol]: contextMenuNode
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('contextmenu')
|
||||
|
||||
expect(contextMenuNode.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
|
||||
@@ -5,26 +5,27 @@
|
||||
:level="item.level"
|
||||
as-child
|
||||
>
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
<slot name="node" :node="item.value">
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Node with context menu -->
|
||||
<ContextMenuTrigger v-if="item.value.type === 'node'" as-child>
|
||||
<div
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
<slot name="node" :node="item.value">
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<!-- Folder -->
|
||||
<div
|
||||
@@ -68,7 +69,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FlattenedItem } from 'reka-ui'
|
||||
import { TreeItem } from 'reka-ui'
|
||||
import { ContextMenuTrigger, TreeItem } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
|
||||
@@ -73,10 +73,6 @@ function getDialogPt(item: {
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col break-words px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
{{ promptTextReal }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="w-full flex flex-wrap gap-2 justify-end px-2 pb-2">
|
||||
<section class="w-full flex gap-2 justify-end px-2 pb-2">
|
||||
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
|
||||
{{ cancelTextX }}
|
||||
</Button>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
describe('ConfirmationDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function mountComponent(props: Partial<Props> = {}) {
|
||||
return mount(ConfirmationDialogContent, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
},
|
||||
props: {
|
||||
message: 'Test message',
|
||||
type: 'default',
|
||||
onConfirm: vi.fn(),
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
}
|
||||
|
||||
it('renders long messages without breaking layout', () => {
|
||||
const longFilename =
|
||||
'workflow_checkpoint_' + 'a'.repeat(200) + '.safetensors'
|
||||
const wrapper = mountComponent({ message: longFilename })
|
||||
expect(wrapper.text()).toContain(longFilename)
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,21 @@
|
||||
<template>
|
||||
<section class="m-2 mt-4 flex flex-col gap-6 whitespace-pre-wrap break-words">
|
||||
<div>
|
||||
<span>{{ message }}</span>
|
||||
<ul v-if="itemList?.length" class="m-0 mt-2 flex flex-col gap-2 pl-4">
|
||||
<li v-for="item of itemList" :key="item">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
v-if="hint"
|
||||
class="mt-2"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-4">
|
||||
<section class="prompt-dialog-content m-2 mt-4 flex flex-col gap-6">
|
||||
<span>{{ message }}</span>
|
||||
<ul v-if="itemList?.length" class="m-0 flex flex-col gap-2 pl-4">
|
||||
<li v-for="item of itemList" :key="item">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
v-if="hint"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex justify-end gap-4">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex flex-col justify-start gap-1"
|
||||
@@ -154,3 +151,9 @@ const onConfirm = () => {
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.prompt-dialog-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
class="about-badge inline-flex items-center no-underline"
|
||||
:title="badge.url"
|
||||
>
|
||||
<Tag class="mr-2" :severity="badge.severity">
|
||||
<Tag class="mr-2">
|
||||
<template #icon>
|
||||
<i :class="[badge.icon, 'mr-2 text-xl']" />
|
||||
</template>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="-translate-y-3 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
>
|
||||
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ errorCountLabel }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3">
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="(message, idx) in groupedErrorMessages"
|
||||
:key="idx"
|
||||
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground min-w-0"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
<span class="break-words line-clamp-3 whitespace-pre-wrap">{{
|
||||
message
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button variant="muted-textonly" size="unset" @click="dismiss">
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
@@ -1,80 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import GradientSlider from './GradientSlider.vue'
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
import { interpolateStops, stopsToGradient } from './gradients'
|
||||
|
||||
const TEST_STOPS: ColorStop[] = [
|
||||
{ offset: 0, color: [0, 0, 0] },
|
||||
{ offset: 1, color: [255, 255, 255] }
|
||||
]
|
||||
|
||||
function mountSlider(props: {
|
||||
stops?: ColorStop[]
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}) {
|
||||
return mount(GradientSlider, {
|
||||
props: { stops: TEST_STOPS, ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GradientSlider', () => {
|
||||
it('passes min, max, step to SliderRoot', () => {
|
||||
const wrapper = mountSlider({
|
||||
modelValue: 50,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 5
|
||||
})
|
||||
const thumb = wrapper.find('[role="slider"]')
|
||||
expect(thumb.attributes('aria-valuemin')).toBe('-100')
|
||||
expect(thumb.attributes('aria-valuemax')).toBe('100')
|
||||
})
|
||||
|
||||
it('renders slider root with track and thumb', () => {
|
||||
const wrapper = mountSlider({ modelValue: 0 })
|
||||
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render SliderRange', () => {
|
||||
const wrapper = mountSlider({ modelValue: 50 })
|
||||
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopsToGradient', () => {
|
||||
it('returns transparent for empty stops', () => {
|
||||
expect(stopsToGradient([])).toBe('transparent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('interpolateStops', () => {
|
||||
it('returns transparent for empty stops', () => {
|
||||
expect(interpolateStops([], 0.5)).toBe('transparent')
|
||||
})
|
||||
|
||||
it('returns start color at t=0', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)')
|
||||
})
|
||||
|
||||
it('returns end color at t=1', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)')
|
||||
})
|
||||
|
||||
it('returns midpoint color at t=0.5', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)')
|
||||
})
|
||||
|
||||
it('clamps values below 0', () => {
|
||||
expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)')
|
||||
})
|
||||
|
||||
it('clamps values above 1', () => {
|
||||
expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)')
|
||||
})
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
interpolateStops,
|
||||
stopsToGradient
|
||||
} from '@/components/gradientslider/gradients'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
stops,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
ariaLabel
|
||||
} = defineProps<{
|
||||
stops: ColorStop[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => [modelValue.value],
|
||||
set: (v: number[]) => {
|
||||
if (v.length) modelValue.value = v[0]
|
||||
}
|
||||
})
|
||||
|
||||
const gradient = computed(() => stopsToGradient(stops))
|
||||
|
||||
const thumbColor = computed(() => {
|
||||
const t = max === min ? 0 : (modelValue.value - min) / (max - min)
|
||||
return interpolateStops(stops, t)
|
||||
})
|
||||
|
||||
const pressed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderRoot
|
||||
v-model="sliderValue"
|
||||
:min
|
||||
:max
|
||||
:step
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full touch-none items-center select-none',
|
||||
'data-[disabled]:opacity-50'
|
||||
)
|
||||
"
|
||||
:style="{ '--reka-slider-thumb-transform': 'translate(-50%, -50%)' }"
|
||||
@slide-start="pressed = true"
|
||||
@slide-move="pressed = true"
|
||||
@slide-end="pressed = false"
|
||||
>
|
||||
<SliderTrack
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2.5 w-full grow cursor-pointer overflow-visible rounded-full',
|
||||
'before:absolute before:-inset-2 before:block before:bg-transparent'
|
||||
)
|
||||
"
|
||||
:style="{ background: gradient }"
|
||||
>
|
||||
<SliderThumb
|
||||
:class="
|
||||
cn(
|
||||
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25 top-1/2',
|
||||
'transition-[color,box-shadow,background-color]',
|
||||
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: thumbColor }"
|
||||
:aria-label
|
||||
/>
|
||||
</SliderTrack>
|
||||
</SliderRoot>
|
||||
</template>
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
export function stopsToGradient(stops: ColorStop[]): string {
|
||||
if (!stops.length) return 'transparent'
|
||||
const colors = stops.map(
|
||||
({ offset, color: [r, g, b] }) => `rgb(${r},${g},${b}) ${offset * 100}%`
|
||||
)
|
||||
return `linear-gradient(to right, ${colors.join(', ')})`
|
||||
}
|
||||
|
||||
export function interpolateStops(stops: ColorStop[], t: number): string {
|
||||
if (!stops.length) return 'transparent'
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
|
||||
if (clamped <= stops[0].offset) {
|
||||
const [r, g, b] = stops[0].color
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const {
|
||||
offset: o1,
|
||||
color: [r1, g1, b1]
|
||||
} = stops[i]
|
||||
const {
|
||||
offset: o2,
|
||||
color: [r2, g2, b2]
|
||||
} = stops[i + 1]
|
||||
if (clamped >= o1 && clamped <= o2) {
|
||||
const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1)
|
||||
const r = Math.round(r1 + (r2 - r1) * f)
|
||||
const g = Math.round(g1 + (g2 - g1) * f)
|
||||
const b = Math.round(b1 + (b2 - b1) * f)
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
}
|
||||
|
||||
const [r, g, b] = stops[stops.length - 1].color
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
@@ -152,7 +152,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<Popover :show-arrow="false">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex min-w-[14rem] flex-col items-stretch font-inter">
|
||||
<Button
|
||||
data-testid="docked-job-history-action"
|
||||
class="w-full justify-between text-sm font-light"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click="onToggleDockedJobHistory"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
|
||||
/>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
|
||||
}}</span>
|
||||
</span>
|
||||
<i
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
class="icon-[lucide--check] size-4"
|
||||
/>
|
||||
</Button>
|
||||
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
|
||||
<template v-if="showClearHistoryAction">
|
||||
<div class="my-1 border-t border-interface-stroke" />
|
||||
<Button
|
||||
data-testid="clear-history-action"
|
||||
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click="onClearHistoryFromMenu(close)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
|
||||
/>
|
||||
<span
|
||||
class="flex flex-col items-start break-words text-left leading-tight"
|
||||
>
|
||||
<span class="text-sm font-light">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
|
||||
</span>
|
||||
<span class="text-xs text-text-secondary font-light">
|
||||
{{
|
||||
t(
|
||||
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const showClearHistoryAction = computed(() => !isCloud)
|
||||
|
||||
const onClearHistoryFromMenu = (close: () => void) => {
|
||||
close()
|
||||
emit('clearHistory')
|
||||
}
|
||||
|
||||
const onToggleDockedJobHistory = async () => {
|
||||
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
|
||||
}
|
||||
</script>
|
||||
@@ -51,7 +51,6 @@ import type {
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
@@ -84,7 +83,6 @@ const emit = defineEmits<{
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { jobMenuEntries } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
@@ -104,9 +102,9 @@ const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
const onJobMenuAction = async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const popoverCloseSpy = vi.fn()
|
||||
const popoverToggleSpy = vi.fn()
|
||||
const popoverHideSpy = vi.fn()
|
||||
|
||||
vi.mock('@/components/ui/Popover.vue', () => {
|
||||
vi.mock('primevue/popover', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('div', [
|
||||
slots.button?.(),
|
||||
slots.default?.({
|
||||
close: () => {
|
||||
popoverCloseSpy()
|
||||
}
|
||||
})
|
||||
])
|
||||
setup(_, { slots, expose }) {
|
||||
const toggle = (event: Event) => {
|
||||
popoverToggleSpy(event)
|
||||
}
|
||||
const hide = () => {
|
||||
popoverHideSpy()
|
||||
}
|
||||
expose({ toggle, hide })
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
const mockGetSetting = vi.fn((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const mockSetSetting = vi.fn()
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockGetSetting,
|
||||
set: mockSetSetting
|
||||
})
|
||||
}))
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
@@ -55,8 +43,7 @@ const i18n = createI18n({
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history',
|
||||
dockedJobHistory: 'Docked Job History'
|
||||
clearHistory: 'Clear history'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,11 +66,6 @@ const mountHeader = (props = {}) =>
|
||||
})
|
||||
|
||||
describe('QueueOverlayHeader', () => {
|
||||
beforeEach(() => {
|
||||
popoverCloseSpy.mockClear()
|
||||
mockSetSetting.mockClear()
|
||||
})
|
||||
|
||||
it('renders header title and concurrent indicator when enabled', () => {
|
||||
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
|
||||
|
||||
@@ -120,33 +102,19 @@ describe('QueueOverlayHeader', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('emits clear history from the menu', async () => {
|
||||
it('toggles popover and emits clear history', async () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
const wrapper = mountHeader()
|
||||
|
||||
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
const moreButton = wrapper.get('button[aria-label="More options"]')
|
||||
await moreButton.trigger('click')
|
||||
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
|
||||
expect(spy).toHaveBeenCalledWith('More')
|
||||
|
||||
const clearHistoryButton = wrapper.get(
|
||||
'[data-testid="clear-history-action"]'
|
||||
)
|
||||
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
|
||||
await clearHistoryButton.trigger('click')
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggles docked job history setting from the menu', async () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearAllJobsTooltip"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@@ -37,17 +36,62 @@
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<JobHistoryActionsMenu @clear-history="$emit('clearHistory')" />
|
||||
<div v-if="!isCloud" class="flex items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</Button>
|
||||
<Popover
|
||||
ref="morePopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<Button
|
||||
class="w-full justify-start"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
@click="onClearHistoryFromMenu"
|
||||
>
|
||||
<i class="icon-[lucide--file-x-2] size-4 text-muted" />
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.clearHistory')
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Popover from 'primevue/popover'
|
||||
import type { PopoverMethods } from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
@@ -56,13 +100,21 @@ defineProps<{
|
||||
queuedCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const clearAllJobsTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
|
||||
)
|
||||
|
||||
const morePopoverRef = ref<PopoverMethods | null>(null)
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const onMoreClick = (event: MouseEvent) => {
|
||||
morePopoverRef.value?.toggle(event)
|
||||
}
|
||||
const onClearHistoryFromMenu = () => {
|
||||
morePopoverRef.value?.hide()
|
||||
emit('clearHistory')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,6 @@ import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
@@ -21,12 +20,7 @@ const QueueOverlayExpandedStub = defineComponent({
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div data-testid="expanded-title">{{ headerTitle }}</div>
|
||||
<button data-testid="show-assets-button" @click="$emit('show-assets')" />
|
||||
</div>
|
||||
`
|
||||
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
@@ -47,11 +41,10 @@ const mountComponent = (
|
||||
stubActions: false
|
||||
})
|
||||
const queueStore = useQueueStore(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
queueStore.runningTasks = runningTasks
|
||||
queueStore.pendingTasks = pendingTasks
|
||||
|
||||
const wrapper = mount(QueueProgressOverlay, {
|
||||
return mount(QueueProgressOverlay, {
|
||||
props: {
|
||||
expanded: true
|
||||
},
|
||||
@@ -67,8 +60,6 @@ const mountComponent = (
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { wrapper, sidebarTabStore }
|
||||
}
|
||||
|
||||
describe('QueueProgressOverlay', () => {
|
||||
@@ -77,7 +68,7 @@ describe('QueueProgressOverlay', () => {
|
||||
})
|
||||
|
||||
it('shows expanded header with running and queued labels', () => {
|
||||
const { wrapper } = mountComponent(
|
||||
const wrapper = mountComponent(
|
||||
[
|
||||
createTask('running-1', 'in_progress'),
|
||||
createTask('running-2', 'in_progress')
|
||||
@@ -91,10 +82,7 @@ describe('QueueProgressOverlay', () => {
|
||||
})
|
||||
|
||||
it('shows only running label when queued count is zero', () => {
|
||||
const { wrapper } = mountComponent(
|
||||
[createTask('running-1', 'in_progress')],
|
||||
[]
|
||||
)
|
||||
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'1 running'
|
||||
@@ -102,22 +90,10 @@ describe('QueueProgressOverlay', () => {
|
||||
})
|
||||
|
||||
it('shows job queue title when there are no active jobs', () => {
|
||||
const { wrapper } = mountComponent([], [])
|
||||
const wrapper = mountComponent([], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'Job Queue'
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the assets sidebar tab when show-assets is clicked', async () => {
|
||||
const { wrapper, sidebarTabStore } = mountComponent([], [])
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
|
||||
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
|
||||
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:queued-count="queuedCount"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="toggleAssetsSidebar"
|
||||
@show-assets="openAssetsSidebar"
|
||||
@clear-history="onClearHistoryFromMenu"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@cancel-item="onCancelItem"
|
||||
@@ -59,10 +59,10 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
@@ -71,6 +71,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -96,9 +97,9 @@ const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetSelectionStore = useAssetSelectionStore()
|
||||
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const {
|
||||
@@ -203,22 +204,22 @@ const {
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
const promptId = item.taskRef?.promptId
|
||||
if (!promptId) return
|
||||
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
await api.deleteItem('queue', promptId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
await api.interrupt(promptId)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
executionStore.clearInitializationByPromptId(promptId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await api.deleteItem('queue', promptId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
@@ -242,21 +243,17 @@ const viewAllJobs = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const toggleAssetsSidebar = () => {
|
||||
sidebarTabStore.toggleSidebarTab('assets')
|
||||
}
|
||||
|
||||
const openAssetsSidebar = () => {
|
||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||
}
|
||||
|
||||
const focusAssetInSidebar = async (item: JobListItem) => {
|
||||
const task = item.taskRef
|
||||
const jobId = task?.jobId
|
||||
const promptId = task?.promptId
|
||||
const preview = task?.previewOutput
|
||||
if (!jobId || !preview) return
|
||||
if (!promptId || !preview) return
|
||||
|
||||
const assetId = String(jobId)
|
||||
const assetId = String(promptId)
|
||||
openAssetsSidebar()
|
||||
await nextTick()
|
||||
await assetsStore.updateHistory()
|
||||
@@ -278,41 +275,62 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
)
|
||||
|
||||
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
// Capture pending jobIds before clearing
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
// Capture pending promptIds before clearing
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
// Clear initialization state for removed jobs
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
// Clear initialization state for removed prompts
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
})
|
||||
|
||||
const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
const tasks = queueStore.runningTasks
|
||||
const jobIds = tasks
|
||||
.map((task) => task.jobId)
|
||||
const promptIds = tasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
if (!jobIds.length) return
|
||||
if (!promptIds.length) return
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByPromptIds(promptIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await Promise.all(promptIds.map((id) => api.interrupt(id)))
|
||||
executionStore.clearInitializationByPromptIds(promptIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
const showClearHistoryDialog = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 bg-transparent'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onClearHistoryFromMenu = () => {
|
||||
showQueueClearHistoryDialog()
|
||||
showClearHistoryDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-xs leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<AssetsListItem
|
||||
v-for="job in group.items"
|
||||
:key="job.id"
|
||||
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@mouseenter="hoveredJobId = job.id"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="$emit('cancelItem', job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="$emit('deleteItem', job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="$emit('viewItem', job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
|
||||
const onJobLeave = (jobId: string) => {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const isCancelable = (job: JobListItem) =>
|
||||
job.showClear !== false && isActiveJobState(job.state)
|
||||
|
||||
const isFailedDeletable = (job: JobListItem) =>
|
||||
job.showClear !== false && job.state === 'failed'
|
||||
|
||||
const getJobIconClass = (job: JobListItem): string | undefined => {
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
return 'animate-spin'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
@@ -1,81 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="button-stub"
|
||||
:data-disabled="String(disabled)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const createEntries = (): MenuEntry[] => [
|
||||
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
},
|
||||
{ kind: 'divider', key: 'divider-1' }
|
||||
]
|
||||
|
||||
const mountComponent = (entries: MenuEntry[]) =>
|
||||
mount(JobContextMenu, {
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div class="popover-stub"><slot /></div>'
|
||||
},
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
const wrapper = mountComponent([
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,6 @@
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="entry.label"
|
||||
:disabled="entry.disabled"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
@@ -69,7 +68,6 @@ function hide() {
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
if (entry.kind === 'divider' || entry.disabled) return
|
||||
emit('action', entry)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function resetStores() {
|
||||
queue.runningTasks = []
|
||||
queue.historyTasks = []
|
||||
|
||||
exec.nodeProgressStatesByJob = {}
|
||||
exec.nodeProgressStatesByPrompt = {}
|
||||
}
|
||||
|
||||
function makeTask(
|
||||
@@ -145,10 +145,10 @@ export const Queued: Story = {
|
||||
makePendingTask('job-older-2', 101, Date.now() - 30_000)
|
||||
)
|
||||
|
||||
// Queued at (in metadata on job tuple)
|
||||
// Queued at (in metadata on prompt[4])
|
||||
|
||||
// One running workflow
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
@@ -198,7 +198,7 @@ export const QueuedParallel: Story = {
|
||||
]
|
||||
|
||||
// Two parallel workflows running
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
@@ -248,7 +248,7 @@ export const Running: Story = {
|
||||
makeHistoryTask('hist-r3', 252, 60, true)
|
||||
]
|
||||
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 5,
|
||||
@@ -293,7 +293,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
|
||||
|
||||
queue.runningTasks = [makeRunningTaskWithStart('running-1', 505, 20)]
|
||||
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 1,
|
||||
@@ -341,7 +341,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
|
||||
makeRunningTaskWithStart('running-b', 507, 10)
|
||||
]
|
||||
|
||||
exec.nodeProgressStatesByJob = {
|
||||
exec.nodeProgressStatesByPrompt = {
|
||||
p1: {
|
||||
'1': {
|
||||
value: 2,
|
||||
|
||||
@@ -139,7 +139,7 @@ const copyJobId = () => void copyToClipboard(jobIdValue.value)
|
||||
const taskForJob = computed(() => {
|
||||
const pid = props.jobId
|
||||
const findIn = (arr: TaskItemImpl[]) =>
|
||||
arr.find((t) => String(t.jobId ?? '') === String(pid))
|
||||
arr.find((t) => String(t.promptId ?? '') === String(pid))
|
||||
return (
|
||||
findIn(queueStore.pendingTasks) ||
|
||||
findIn(queueStore.runningTasks) ||
|
||||
@@ -151,7 +151,9 @@ const taskForJob = computed(() => {
|
||||
const jobState = computed(() => {
|
||||
const task = taskForJob.value
|
||||
if (!task) return null
|
||||
const isInitializing = executionStore.isJobInitializing(String(task?.jobId))
|
||||
const isInitializing = executionStore.isPromptInitializing(
|
||||
String(task?.promptId)
|
||||
)
|
||||
return jobStateFromTask(task, isInitializing)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SearchBox
|
||||
v-if="showSearch"
|
||||
:model-value="searchQuery"
|
||||
class="min-w-0 flex-1"
|
||||
:placeholder="searchPlaceholderText"
|
||||
@update:model-value="onSearchQueryUpdate"
|
||||
/>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
:class="{ 'ml-2': !showSearch }"
|
||||
>
|
||||
<Popover :show-arrow="false">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.top="filterTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter] size-4" />
|
||||
<span
|
||||
v-if="selectedWorkflowFilter !== 'all'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex min-w-[12rem] flex-col items-stretch">
|
||||
<Button
|
||||
class="w-full justify-between"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click="onSelectWorkflowFilter('all', close)"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
}}</span>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'all'"
|
||||
class="icon-[lucide--check] size-4"
|
||||
/>
|
||||
</Button>
|
||||
<div class="mx-2 mt-1 h-px" />
|
||||
<Button
|
||||
class="w-full justify-between"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click="onSelectWorkflowFilter('current', close)"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
}}</span>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'current'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<Popover :show-arrow="false">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.top="sortTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
<span
|
||||
v-if="selectedSortMode !== 'mostRecent'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex min-w-[12rem] flex-col items-stretch">
|
||||
<template v-for="(mode, index) in jobSortModes" :key="mode">
|
||||
<Button
|
||||
class="w-full justify-between"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
@click="onSelectSortMode(mode, close)"
|
||||
>
|
||||
<span>{{ sortLabel(mode) }}</span>
|
||||
<i
|
||||
v-if="selectedSortMode === mode"
|
||||
class="icon-[lucide--check] size-4 text-text-secondary"
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
v-if="index < jobSortModes.length - 1"
|
||||
class="mx-2 mt-1 h-px"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<Button
|
||||
v-if="showAssetsAction"
|
||||
v-tooltip.top="showAssetsTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
|
||||
@click="emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobSortModes } from '@/composables/queue/useJobList'
|
||||
import type { JobSortMode } from '@/composables/queue/useJobList'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
const {
|
||||
hideShowAssetsAction = false,
|
||||
showSearch = false,
|
||||
searchPlaceholder
|
||||
} = defineProps<{
|
||||
hideShowAssetsAction?: boolean
|
||||
showSearch?: boolean
|
||||
searchPlaceholder?: string
|
||||
}>()
|
||||
|
||||
const selectedWorkflowFilter = defineModel<'all' | 'current'>(
|
||||
'selectedWorkflowFilter',
|
||||
{ required: true }
|
||||
)
|
||||
const selectedSortMode = defineModel<JobSortMode>('selectedSortMode', {
|
||||
required: true
|
||||
})
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const filterTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
|
||||
)
|
||||
const sortTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
|
||||
)
|
||||
const showAssetsTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
|
||||
)
|
||||
const showAssetsAction = computed(() => !hideShowAssetsAction)
|
||||
const searchPlaceholderText = computed(
|
||||
() => searchPlaceholder ?? t('sideToolbar.queueProgressOverlay.searchJobs')
|
||||
)
|
||||
|
||||
const selectWorkflowFilter = (value: 'all' | 'current') => {
|
||||
selectedWorkflowFilter.value = value
|
||||
}
|
||||
|
||||
const onSelectWorkflowFilter = (
|
||||
value: 'all' | 'current',
|
||||
close: () => void
|
||||
) => {
|
||||
selectWorkflowFilter(value)
|
||||
close()
|
||||
}
|
||||
|
||||
const selectSortMode = (value: JobSortMode) => {
|
||||
selectedSortMode.value = value
|
||||
}
|
||||
|
||||
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
|
||||
selectSortMode(value)
|
||||
close()
|
||||
}
|
||||
|
||||
const onSearchQueryUpdate = (value: string | undefined) => {
|
||||
searchQuery.value = value ?? ''
|
||||
}
|
||||
|
||||
const sortLabel = (mode: JobSortMode) => {
|
||||
if (mode === 'mostRecent') {
|
||||
return t('queue.jobList.sortMostRecent')
|
||||
}
|
||||
if (mode === 'totalGenerationTime') {
|
||||
return t('queue.jobList.sortTotalGenerationTime')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div class="min-w-0 flex-1 overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<Button
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
|
||||
size="sm"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobTab } from '@/composables/queue/useJobList'
|
||||
|
||||
const { selectedJobTab, hasFailedJobs } = defineProps<{
|
||||
selectedJobTab: JobTab
|
||||
hasFailedJobs: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visibleJobTabs = computed(() =>
|
||||
hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
|
||||
)
|
||||
|
||||
const tabLabel = (tab: JobTab) => {
|
||||
if (tab === 'All') return t('g.all')
|
||||
if (tab === 'Completed') return t('g.completed')
|
||||
return t('g.failed')
|
||||
}
|
||||
</script>
|
||||
@@ -76,24 +76,4 @@ describe('JobFiltersBar', () => {
|
||||
|
||||
expect(wrapper.emitted('showAssets')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides the assets icon button when hideShowAssetsAction is true', () => {
|
||||
const wrapper = mount(JobFiltersBar, {
|
||||
props: {
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false,
|
||||
hideShowAssetsAction: true
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => undefined }
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('button[aria-label="Show assets panel"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,225 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-2 px-3">
|
||||
<JobFilterTabs
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
/>
|
||||
<JobFilterActions
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:hide-show-assets-action="hideShowAssetsAction"
|
||||
@show-assets="$emit('showAssets')"
|
||||
@update:selected-workflow-filter="
|
||||
$emit('update:selectedWorkflowFilter', $event)
|
||||
"
|
||||
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<Button
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
|
||||
size="sm"
|
||||
class="px-3"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="showWorkflowFilter"
|
||||
v-tooltip.top="filterTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
|
||||
@click="onFilterClick"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter] size-4" />
|
||||
<span
|
||||
v-if="selectedWorkflowFilter !== 'all'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="showWorkflowFilter"
|
||||
ref="filterPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<Button
|
||||
class="w-full justify-between"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click="selectWorkflowFilter('all')"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
}}</span>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'all'"
|
||||
class="icon-[lucide--check] size-4"
|
||||
/>
|
||||
</Button>
|
||||
<div class="mx-2 mt-1 h-px" />
|
||||
<Button
|
||||
class="w-full justify-between"
|
||||
variant="textonly"
|
||||
@click="selectWorkflowFilter('current')"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
}}</span>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'current'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
v-tooltip.top="sortTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
|
||||
@click="onSortClick"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
<span
|
||||
v-if="selectedSortMode !== 'mostRecent'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<Popover
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<template v-for="(mode, index) in jobSortModes" :key="mode">
|
||||
<Button
|
||||
class="w-full justify-between"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click="selectSortMode(mode)"
|
||||
>
|
||||
<span>{{ sortLabel(mode) }}</span>
|
||||
<i
|
||||
v-if="selectedSortMode === mode"
|
||||
class="icon-[lucide--check] size-4 text-text-secondary"
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
v-if="index < jobSortModes.length - 1"
|
||||
class="mx-2 mt-1 h-px"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
v-tooltip.top="showAssetsTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
|
||||
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
hideShowAssetsAction
|
||||
} = defineProps<{
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
selectedSortMode: JobSortMode
|
||||
hasFailedJobs: boolean
|
||||
hideShowAssetsAction?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const sortPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const filterTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
|
||||
)
|
||||
const sortTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
|
||||
)
|
||||
const showAssetsTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
|
||||
)
|
||||
|
||||
// This can be removed when cloud implements /jobs and we switch to it.
|
||||
const showWorkflowFilter = !isCloud
|
||||
|
||||
const visibleJobTabs = computed(() =>
|
||||
props.hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
|
||||
)
|
||||
|
||||
const onFilterClick = (event: Event) => {
|
||||
if (filterPopoverRef.value) {
|
||||
filterPopoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
const selectWorkflowFilter = (value: 'all' | 'current') => {
|
||||
filterPopoverRef.value?.hide()
|
||||
emit('update:selectedWorkflowFilter', value)
|
||||
}
|
||||
|
||||
const onSortClick = (event: Event) => {
|
||||
if (sortPopoverRef.value) {
|
||||
sortPopoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
const selectSortMode = (value: JobSortMode) => {
|
||||
sortPopoverRef.value?.hide()
|
||||
emit('update:selectedSortMode', value)
|
||||
}
|
||||
|
||||
const tabLabel = (tab: JobTab) => {
|
||||
if (tab === 'All') return t('g.all')
|
||||
if (tab === 'Completed') return t('g.completed')
|
||||
return t('g.failed')
|
||||
}
|
||||
|
||||
const sortLabel = (mode: JobSortMode) => {
|
||||
if (mode === 'mostRecent') {
|
||||
return t('queue.jobList.sortMostRecent')
|
||||
}
|
||||
if (mode === 'totalGenerationTime') {
|
||||
return t('queue.jobList.sortTotalGenerationTime')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,13 +8,13 @@ import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReportin
|
||||
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const createTaskWithError = (
|
||||
jobId: string,
|
||||
promptId: string,
|
||||
errorMessage?: string,
|
||||
executionError?: ExecutionError,
|
||||
createTime?: number
|
||||
): TaskItemImpl =>
|
||||
({
|
||||
jobId,
|
||||
promptId,
|
||||
errorMessage,
|
||||
executionError,
|
||||
createTime: createTime ?? Date.now()
|
||||
|
||||
@@ -19,8 +19,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
|
||||
import TabError from './TabError.vue'
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
@@ -41,7 +41,7 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -96,31 +96,30 @@ type RightSidePanelTabList = Array<{
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
const hasDirectNodeError = computed(() =>
|
||||
selectedNodes.value.some((node) =>
|
||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
)
|
||||
//FIXME all errors if nothing selected?
|
||||
const selectedNodeErrors = computed(() =>
|
||||
selectedNodes.value
|
||||
.map((node) => executionStore.getNodeErrors(`${node.id}`))
|
||||
.filter((nodeError) => !!nodeError)
|
||||
)
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
return executionStore.hasInternalErrorForNode(node.id)
|
||||
})
|
||||
})
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (
|
||||
selectedNodeErrors.value.length &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
hasRelevantErrors.value
|
||||
hasAnyError.value &&
|
||||
!hasSelection.value &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.errors'),
|
||||
@@ -316,9 +315,9 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<template v-if="!hasSelection">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
@@ -327,6 +326,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
|
||||
30
src/components/rightSidePanel/TabError.vue
Normal file
30
src/components/rightSidePanel/TabError.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
errors: NodeError[]
|
||||
}>()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
|
||||
:key="index"
|
||||
class="px-2"
|
||||
>
|
||||
<h3 class="text-error" v-text="error.message" />
|
||||
<div class="text-muted-foreground" v-text="error.details" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<!-- Card Header (Node ID & Actions) -->
|
||||
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
@@ -22,7 +19,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
@@ -30,8 +27,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
@click.stop="emit('locateNode', card.nodeId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
@@ -47,8 +43,8 @@
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message && !compact"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5 max-h-[4lh] overflow-y-auto"
|
||||
v-if="error.message"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
@@ -73,7 +69,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-xs"
|
||||
class="w-full justify-center gap-2 h-8 text-[11px]"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
@@ -92,15 +88,9 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false
|
||||
} = defineProps<{
|
||||
const { card, showNodeIdBadge = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -111,18 +101,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
emit('locateNode', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn()
|
||||
}))
|
||||
|
||||
|
||||
@@ -55,9 +55,8 @@
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@locate-node="focusNode"
|
||||
@enter-subgraph="enterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,16 +97,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
@@ -120,10 +119,10 @@ const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
@@ -131,30 +130,17 @@ const showNodeIdBadge = computed(
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
const { filteredGroups } = useErrorGroups(searchQuery, t)
|
||||
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
for (const group of filteredGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
(card) => card.graphNodeId === graphNodeId
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
@@ -163,14 +149,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
@@ -184,6 +162,6 @@ async function contactSupport() {
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
useCommandStore().execute('Comfy.ContactSupport')
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'prompt_no_outputs',
|
||||
'no_prompt',
|
||||
'server_error'
|
||||
])
|
||||
|
||||
interface GroupEntry {
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
@@ -43,26 +25,20 @@ interface ErrorSearchItem {
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
*/
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
function resolveNodeInfo(nodeId: string): {
|
||||
title: string
|
||||
graphNodeId: string | undefined
|
||||
} {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
const parentNode = getRootParentNode(app.rootGraph, nodeId)
|
||||
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
|
||||
|
||||
return {
|
||||
title: isParentGroupNode
|
||||
? parentNode?.title || ''
|
||||
: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined,
|
||||
isParentGroupNode
|
||||
title: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,56 +55,93 @@ function getOrCreateGroup(
|
||||
return entry.cards
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
return {
|
||||
id: `${idPrefix}-${nodeId}`,
|
||||
title: classType,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
function processPromptError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const group = getOrCreateGroup(messageMap, error.message, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
if (!executionStore.lastPromptError) return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
cards.set('__prompt__', {
|
||||
id: '__prompt__',
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `node-${nodeId}`,
|
||||
title: nodeError.class_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) continue
|
||||
card.errors.push(
|
||||
...nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const nodeId = String(e.node_id)
|
||||
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
|
||||
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `exec-${nodeId}`,
|
||||
title: e.node_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push({
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
})
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
@@ -144,14 +157,27 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
})
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
function buildErrorGroups(
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
): ErrorGroup[] {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap, executionStore, t)
|
||||
processNodeErrors(groupsMap, executionStore)
|
||||
processExecutionError(groupsMap, executionStore)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
|
||||
if (!query) return groups
|
||||
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]
|
||||
const group = groups[gi]!
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]
|
||||
const card = group.cards[ci]!
|
||||
searchableList.push({
|
||||
groupIndex: gi,
|
||||
cardIndex: ci,
|
||||
@@ -193,194 +219,18 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
const selectedNodeInfo = computed(() => {
|
||||
const items = canvasStore.selectedItems
|
||||
const nodeIds = new Set<string>()
|
||||
const containerIds = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (!isLGraphNode(item)) continue
|
||||
nodeIds.add(String(item.id))
|
||||
if (item instanceof SubgraphNode || isGroupNode(item)) {
|
||||
containerIds.add(String(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
||||
containerIds
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerIds.size === 0
|
||||
const errorGroups = computed<ErrorGroup[]>(() =>
|
||||
buildErrorGroups(executionStore, t)
|
||||
)
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionStore.allErrorExecutionIds) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (node) map.set(execId, node)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
const graphNode = errorNodeCache.value.get(executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
|
||||
for (const containerId of selectedNodeInfo.value.containerIds) {
|
||||
if (executionNodeId.startsWith(`${containerId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
errors: ErrorItem[],
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
cards.get(nodeId)?.errors.push(...errors)
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
||||
return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
// For server_error, resolve the i18n key based on the environment
|
||||
let errorTypeKey = error.type
|
||||
if (error.type === 'server_error') {
|
||||
errorTypeKey = isCloud ? 'server_error_cloud' : 'server_error_local'
|
||||
}
|
||||
const i18nKey = `rightSidePanel.promptErrors.${errorTypeKey}.desc`
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
cards.set(PROMPT_CARD_ID, {
|
||||
id: PROMPT_CARD_ID,
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown ? t(i18nKey) : error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
})),
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
return isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
return searchErrorGroups(errorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
groupedErrorMessages
|
||||
errorGroups,
|
||||
filteredGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -108,24 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const hasDirectError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
const isContainer =
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
if (canvasStore.selectedItems.length === 1) return false
|
||||
return hasDirectError.value || hasContainerInternalError.value
|
||||
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
@@ -220,10 +207,7 @@ defineExpose({
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
nodeHasError &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
"
|
||||
v-if="nodeHasError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@@ -233,9 +217,9 @@ defineExpose({
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEmpty"
|
||||
variant="muted-textonly"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 size-8 hover:text-base-foreground"
|
||||
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.resetAllParameters')"
|
||||
:aria-label="t('rightSidePanel.resetAllParameters')"
|
||||
@click.stop="handleResetAllWidgets"
|
||||
@@ -244,9 +228,9 @@ defineExpose({
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="muted-textonly"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 mr-3 size-8 hover:text-base-foreground"
|
||||
class="subbutton shrink-0 mr-3 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.locateNode')"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import {
|
||||
demoteWidget,
|
||||
@@ -123,6 +123,12 @@ function handleResetToDefault() {
|
||||
if (!hasDefault.value) return
|
||||
emit('resetToDefault', defaultValue.value)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
'cursor-pointer transition-all hover:bg-secondary-background-hover active:scale-95'
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,10 +137,8 @@ function handleResetToDefault() {
|
||||
class="text-muted-foreground bg-transparent hover:text-base-foreground hover:bg-secondary-background-hover active:scale-95 transition-all"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleRename()
|
||||
@@ -144,13 +148,11 @@ function handleResetToDefault() {
|
||||
>
|
||||
<i class="icon-[lucide--edit] size-4" />
|
||||
<span>{{ t('g.rename') }}</span>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
<button
|
||||
v-if="hasParents"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
@@ -167,12 +169,10 @@ function handleResetToDefault() {
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleFavorite()
|
||||
@@ -181,20 +181,18 @@ function handleResetToDefault() {
|
||||
"
|
||||
>
|
||||
<template v-if="isFavorited">
|
||||
<i class="icon-[lucide--star] size-4" />
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.removeFavorite') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--star] size-4" />
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
<button
|
||||
v-if="hasDefault"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
|
||||
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
|
||||
:disabled="isCurrentValueDefault"
|
||||
@click="
|
||||
() => {
|
||||
@@ -205,7 +203,7 @@ function handleResetToDefault() {
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
|
||||
</Button>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<ActiveMediaAssetCard
|
||||
v-for="job in activeJobItems"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Assets Header -->
|
||||
<div v-if="assets.length" class="px-2 2xl:px-4">
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
@@ -43,18 +59,25 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
isInFolderView = false,
|
||||
assetType = 'output',
|
||||
showOutputCount,
|
||||
getOutputCount
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isInFolderView?: boolean
|
||||
assetType?: 'input' | 'output'
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
@@ -69,9 +92,19 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
|
||||
)
|
||||
|
||||
const assetItems = computed<AssetGridItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
|
||||
@@ -80,7 +80,7 @@ const sampleAssets: AssetItem[] = [
|
||||
size: 1887437,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
jobId: 'job-running-1',
|
||||
promptId: 'job-running-1',
|
||||
nodeId: 12,
|
||||
executionTimeInSeconds: 1.84
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
@@ -13,63 +10,141 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useJobActions', () => ({
|
||||
useJobActions: () => ({
|
||||
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
|
||||
canCancelJob: ref(false),
|
||||
runCancelJob: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockJobItems = ref<
|
||||
Array<{
|
||||
id: string
|
||||
title: string
|
||||
meta: string
|
||||
state: string
|
||||
createTime?: number
|
||||
}>
|
||||
>([])
|
||||
|
||||
vi.mock('@/composables/queue/useJobList', () => ({
|
||||
useJobList: () => ({
|
||||
jobItems: mockJobItems
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
isAssetDeleting: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
const VirtualGridStub = defineComponent({
|
||||
name: 'VirtualGrid',
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
template:
|
||||
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
|
||||
})
|
||||
|
||||
const buildAsset = (id: string, name: string): AssetItem =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
tags: []
|
||||
}) satisfies AssetItem
|
||||
|
||||
const buildOutputItem = (asset: AssetItem): OutputStackListItem => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
})
|
||||
|
||||
const mountListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
mount(AssetsSidebarListView, {
|
||||
props: {
|
||||
assetItems,
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {},
|
||||
assetType: 'output'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
VirtualGrid: VirtualGridStub
|
||||
}
|
||||
}
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => key === 'Comfy.Queue.QPOV2'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/queueUtil', () => ({
|
||||
isActiveJobState: (state: string) =>
|
||||
state === 'pending' || state === 'running'
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/queueDisplay', () => ({
|
||||
iconForJobState: () => 'pi pi-spinner'
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/mediaIconUtil', () => ({
|
||||
iconForMediaType: () => 'pi pi-file'
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
formatDuration: (d: number) => `${d}s`,
|
||||
formatSize: (s: number) => `${s}B`,
|
||||
getMediaTypeFromFilename: () => 'image',
|
||||
truncateFilename: (name: string) => name
|
||||
}))
|
||||
|
||||
describe('AssetsSidebarListView', () => {
|
||||
it('shows generated assets header when there are assets', () => {
|
||||
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
|
||||
|
||||
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockJobItems.value = []
|
||||
})
|
||||
|
||||
it('does not show assets header when there are no assets', () => {
|
||||
const wrapper = mountListView([])
|
||||
const defaultProps = {
|
||||
assetItems: [],
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {}
|
||||
}
|
||||
|
||||
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
|
||||
it('displays active jobs in oldest-first order (FIFO)', () => {
|
||||
mockJobItems.value = [
|
||||
{
|
||||
id: 'newest',
|
||||
title: 'Newest Job',
|
||||
meta: '',
|
||||
state: 'pending',
|
||||
createTime: 3000
|
||||
},
|
||||
{
|
||||
id: 'middle',
|
||||
title: 'Middle Job',
|
||||
meta: '',
|
||||
state: 'running',
|
||||
createTime: 2000
|
||||
},
|
||||
{
|
||||
id: 'oldest',
|
||||
title: 'Oldest Job',
|
||||
meta: '',
|
||||
state: 'pending',
|
||||
createTime: 1000
|
||||
}
|
||||
]
|
||||
|
||||
const wrapper = mount(AssetsSidebarListView, {
|
||||
props: defaultProps,
|
||||
shallow: true
|
||||
})
|
||||
|
||||
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
expect(jobListItems).toHaveLength(3)
|
||||
|
||||
const displayedTitles = jobListItems.map((item) =>
|
||||
item.props('primaryText')
|
||||
)
|
||||
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
|
||||
})
|
||||
|
||||
it('excludes completed and failed jobs from active jobs section', () => {
|
||||
mockJobItems.value = [
|
||||
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
|
||||
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
|
||||
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
|
||||
{ id: 'running', title: 'Running', meta: '', state: 'running' }
|
||||
]
|
||||
|
||||
const wrapper = mount(AssetsSidebarListView, {
|
||||
props: defaultProps,
|
||||
shallow: true
|
||||
})
|
||||
|
||||
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
expect(jobListItems).toHaveLength(2)
|
||||
|
||||
const displayedTitles = jobListItems.map((item) =>
|
||||
item.props('primaryText')
|
||||
)
|
||||
expect(displayedTitles).toContain('Running')
|
||||
expect(displayedTitles).toContain('Pending')
|
||||
expect(displayedTitles).not.toContain('Completed')
|
||||
expect(displayedTitles).not.toContain('Failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div v-if="assetItems.length" class="px-2">
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
v-for="job in activeJobItems"
|
||||
:key="job.id"
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-default'
|
||||
)
|
||||
"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@mouseenter="onJobEnter(job.id)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="canCancelJob"
|
||||
:variant="cancelAction.variant"
|
||||
size="icon"
|
||||
:aria-label="cancelAction.label"
|
||||
@click.stop="runCancelJob()"
|
||||
>
|
||||
<i :class="cancelAction.icon" class="size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assetItems.length"
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
@@ -77,25 +119,31 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getMediaTypeFromFilename,
|
||||
truncateFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assetItems,
|
||||
@@ -122,7 +170,24 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
|
||||
)
|
||||
const hoveredJob = computed(() =>
|
||||
hoveredJobId.value
|
||||
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
||||
null)
|
||||
: null
|
||||
)
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
@@ -175,6 +240,16 @@ function getAssetCardClass(selected: boolean): string {
|
||||
)
|
||||
}
|
||||
|
||||
function onJobEnter(jobId: string) {
|
||||
hoveredJobId.value = jobId
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onAssetEnter(assetId: string) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
@@ -184,4 +259,13 @@ function onAssetLeave(assetId: string) {
|
||||
hoveredAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
const classes = []
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
classes.push('animate-spin')
|
||||
}
|
||||
return classes.length ? classes.join(' ') : undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template #alt-title>
|
||||
<div
|
||||
@@ -10,7 +9,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ $t('assetBrowser.jobId') }}:</span>
|
||||
<span class="text-sm">{{ folderJobId?.substring(0, 8) }}</span>
|
||||
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
|
||||
<button
|
||||
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
|
||||
role="button"
|
||||
@@ -53,7 +52,31 @@
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled && !isInFolderView"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="handleClearQueue"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
@@ -102,6 +125,7 @@
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:is-in-folder-view="isInFolderView"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@@ -199,9 +223,9 @@ import {
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
useStorage
|
||||
} from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -233,25 +257,36 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderJobId = ref<string | null>(null)
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const expectedFolderCount = ref(0)
|
||||
const isInFolderView = computed(() => folderJobId.value !== null)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = useStorage<'list' | 'grid'>(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
'grid'
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
@@ -283,6 +318,16 @@ const formattedExecutionTime = computed(() => {
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobs',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
@@ -408,12 +453,17 @@ const isFolderLoading = computed(
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
|
||||
(loading.value || isFolderLoading.value) &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
|
||||
!loading.value &&
|
||||
!isFolderLoading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
@@ -480,20 +530,10 @@ watch(
|
||||
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
const assetList = assets ?? visibleAssets.value
|
||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||
emit('assetSelected', asset)
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
|
||||
() => {
|
||||
contextMenuAsset.value = null
|
||||
},
|
||||
0,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
cancelCleanup()
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
@@ -501,7 +541,10 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
scheduleCleanup()
|
||||
// Delay clearing to allow command callbacks to emit before component unmounts
|
||||
requestAnimationFrame(() => {
|
||||
contextMenuAsset.value = null
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
@@ -515,6 +558,16 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||
await addMultipleToWorkflow(assets)
|
||||
clearSelection()
|
||||
@@ -575,14 +628,14 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
return
|
||||
}
|
||||
|
||||
const { jobId, executionTimeInSeconds } = metadata
|
||||
const { promptId, executionTimeInSeconds } = metadata
|
||||
|
||||
if (!jobId) {
|
||||
if (!promptId) {
|
||||
console.warn('Missing required folder view data')
|
||||
return
|
||||
}
|
||||
|
||||
folderJobId.value = jobId
|
||||
folderPromptId.value = promptId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
expectedFolderCount.value = metadata.outputCount ?? 0
|
||||
|
||||
@@ -600,7 +653,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
folderJobId.value = null
|
||||
folderPromptId.value = null
|
||||
folderExecutionTime.value = undefined
|
||||
expectedFolderCount.value = 0
|
||||
folderAssets.value = []
|
||||
@@ -626,9 +679,9 @@ const handleEmptySpaceClick = () => {
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
if (folderJobId.value) {
|
||||
if (folderPromptId.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(folderJobId.value)
|
||||
await navigator.clipboard.writeText(folderPromptId.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('mediaAsset.jobIdToast.copied'),
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('queue.jobHistory')">
|
||||
<template #alt-title>
|
||||
<div class="ml-auto flex shrink-0 items-center">
|
||||
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
|
||||
</div>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2 pb-1">
|
||||
<div class="px-3 py-2">
|
||||
<JobFilterTabs
|
||||
v-model:selected-job-tab="selectedJobTab"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
/>
|
||||
</div>
|
||||
<JobFilterActions
|
||||
v-model:selected-workflow-filter="selectedWorkflowFilter"
|
||||
v-model:selected-sort-mode="selectedSortMode"
|
||||
v-model:search-query="searchQuery"
|
||||
class="px-3"
|
||||
:hide-show-assets-action="true"
|
||||
:show-search="true"
|
||||
:search-placeholder="t('sideToolbar.queueProgressOverlay.searchJobs')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 pb-1 text-xs leading-none text-text-primary"
|
||||
>
|
||||
<span class="text-text-secondary">{{ activeQueueSummary }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="clearQueuedWorkflows"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
|
||||
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
searchQuery,
|
||||
hasFailedJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems
|
||||
} = useJobList()
|
||||
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
const runningCount = computed(() => queueStore.runningTasks.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
|
||||
const runningJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
|
||||
count: n(runningCount.value)
|
||||
})
|
||||
)
|
||||
const queuedJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
|
||||
count: n(queuedCount.value)
|
||||
})
|
||||
)
|
||||
const activeQueueSummary = computed(() => {
|
||||
if (runningCount.value === 0 && queuedCount.value === 0) {
|
||||
return t('sideToolbar.queueProgressOverlay.noActiveJobs')
|
||||
}
|
||||
if (queuedCount.value === 0) {
|
||||
return runningJobsLabel.value
|
||||
}
|
||||
if (runningCount.value === 0) {
|
||||
return queuedJobsLabel.value
|
||||
}
|
||||
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
|
||||
running: runningJobsLabel.value,
|
||||
queued: queuedJobsLabel.value
|
||||
})
|
||||
})
|
||||
|
||||
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
})
|
||||
|
||||
const {
|
||||
galleryActiveIndex,
|
||||
galleryItems,
|
||||
onViewItem: openResultGallery
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await openResultGallery(item)
|
||||
})
|
||||
|
||||
const onInspectAsset = (item: JobListItem) => {
|
||||
void onViewItem(item)
|
||||
}
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
const { jobMenuEntries, cancelJob } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
onInspectAsset
|
||||
)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await cancelJob(item)
|
||||
})
|
||||
|
||||
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
if (!item.taskRef) return
|
||||
await queueStore.delete(item.taskRef)
|
||||
})
|
||||
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
</script>
|
||||
@@ -113,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const triggers = wrapper.findAllComponents(TabsTrigger)
|
||||
expect(triggers).toHaveLength(3)
|
||||
expect(triggers.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should render search box', () => {
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
@@ -114,7 +114,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -149,7 +148,15 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
||||
custom: 'alphabetical'
|
||||
}
|
||||
)
|
||||
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
|
||||
const sortOrder = computed({
|
||||
get: () => sortOrderByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
sortOrderByTab.value = {
|
||||
...sortOrderByTab.value,
|
||||
[selectedTab.value]: value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sortingOptions = computed(() =>
|
||||
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
|
||||
@@ -167,7 +174,12 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
all: [],
|
||||
custom: []
|
||||
})
|
||||
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
|
||||
const expandedKeys = computed({
|
||||
get: () => expandedKeysByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
expandedKeysByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="favoritesRoot"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
@@ -27,7 +26,6 @@
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="section.root"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
|
||||
:data-node-name="node.data?.display_name"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
|
||||
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
|
||||
'aspect-square'
|
||||
)
|
||||
"
|
||||
:data-node-name="nodeDef?.display_name"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@@ -12,12 +18,11 @@
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
|
||||
</div>
|
||||
|
||||
<TextTickerMultiLine
|
||||
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
|
||||
<span
|
||||
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
|
||||
>
|
||||
{{ node.data?.display_name }}
|
||||
</TextTickerMultiLine>
|
||||
{{ nodeDef?.display_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="showPreview" to="body">
|
||||
@@ -25,10 +30,7 @@
|
||||
:ref="(el) => (previewRef = el as HTMLElement)"
|
||||
:style="nodePreviewStyle"
|
||||
>
|
||||
<NodePreviewCard
|
||||
:node-def="node.data!"
|
||||
:show-inputs-and-outputs="false"
|
||||
/>
|
||||
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -37,7 +39,6 @@
|
||||
import { kebabCase } from 'es-toolkit/string'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
@@ -53,9 +54,10 @@ const emit = defineEmits<{
|
||||
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const panelRef = inject(SidebarContainerKey, undefined)
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const panelRef = inject(SidebarContainerKey, undefined)
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
@@ -67,13 +69,13 @@ const {
|
||||
} = useNodePreviewAndDrag(nodeDef, { panelRef })
|
||||
|
||||
const nodeIcon = computed(() => {
|
||||
const nodeName = node.data?.name
|
||||
const nodeName = nodeDef.value?.name
|
||||
const iconName = nodeName ? kebabCase(nodeName) : 'node'
|
||||
return `icon-[comfy--${iconName}]`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (!node.data) return
|
||||
if (!nodeDef.value) return
|
||||
emit('click', node)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
@@ -15,16 +14,10 @@ defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
entries,
|
||||
icon,
|
||||
to,
|
||||
showArrow = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
defineProps<{
|
||||
entries?: { label: string; action?: () => void; icon?: string }[][]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
showArrow?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -45,42 +38,38 @@ const {
|
||||
v-bind="$attrs"
|
||||
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
>
|
||||
<slot :close>
|
||||
<slot>
|
||||
<div class="flex flex-col p-1">
|
||||
<template v-for="item in entries ?? []" :key="item.label">
|
||||
<section
|
||||
v-for="(entryGroup, index) in entries ?? []"
|
||||
:key="index"
|
||||
class="flex flex-col border-b-2 last:border-none border-border-subtle"
|
||||
>
|
||||
<div
|
||||
v-if="item.separator"
|
||||
class="border-b w-full border-border-subtle"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-for="{ label, action, icon } in entryGroup"
|
||||
:key="label"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-row gap-4 p-2 rounded-sm my-1',
|
||||
item.disabled
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: item.command &&
|
||||
'cursor-pointer hover:bg-secondary-background-hover'
|
||||
action &&
|
||||
'cursor-pointer hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="
|
||||
(e) => {
|
||||
if (!item.command || item.disabled) return
|
||||
item.command({ originalEvent: e, item })
|
||||
() => {
|
||||
if (!action) return
|
||||
action()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i v-if="icon" :class="icon" />
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</slot>
|
||||
<PopoverArrow
|
||||
v-if="showArrow"
|
||||
class="fill-base-background stroke-border-subtle"
|
||||
/>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
|
||||
@@ -52,17 +51,6 @@ export const useFirebaseAuthActions = () => {
|
||||
}
|
||||
|
||||
const logout = wrapWithErrorHandlingAsync(async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
if (workflowStore.modifiedWorkflows.length > 0) {
|
||||
const dialogService = useDialogService()
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.signOut.unsavedChangesTitle'),
|
||||
message: t('auth.signOut.unsavedChangesMessage'),
|
||||
type: 'dirtyClose'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -2,16 +2,8 @@ import { nextTick } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
@@ -37,40 +29,20 @@ async function navigateToGraph(targetGraph: LGraph) {
|
||||
export function useFocusNode() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
/* Locate and focus a node on the canvas by its execution ID. */
|
||||
async function focusNode(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
async function focusNode(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
// For group node internals, locate the root parent group node instead
|
||||
const parentNode = getRootParentNode(app.rootGraph, nodeId)
|
||||
|
||||
if (parentNode && isGroupNode(parentNode) && parentNode.graph) {
|
||||
await navigateToGraph(parentNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(parentNode.boundingRect)
|
||||
return
|
||||
}
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
async function enterSubgraph(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert and unpack but hides add to library when multiple items with subgraphs are selected', () => {
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
@@ -109,11 +109,16 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
const labels = options.map((option) => option.label)
|
||||
|
||||
expect(labels).toContain('contextMenu.Convert to Subgraph')
|
||||
expect(labels).not.toContain('contextMenu.Add Subgraph to Library')
|
||||
expect(labels).toContain('contextMenu.Add Subgraph to Library')
|
||||
expect(labels).toContain('contextMenu.Unpack Subgraph')
|
||||
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('shows add to library and unpack when a single subgraph is selected', () => {
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
@@ -126,10 +131,5 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
'contextMenu.Add Subgraph to Library',
|
||||
'contextMenu.Unpack Subgraph'
|
||||
])
|
||||
|
||||
const addToLibraryOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Add Subgraph to Library'
|
||||
)
|
||||
expect(addToLibraryOption?.action).toBe(mocks.addSubgraphToLibrary)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,18 +85,18 @@ export function useSelectionMenuOptions() {
|
||||
}
|
||||
|
||||
if (hasSubgraphs) {
|
||||
if (!hasMultipleSelection) {
|
||||
options.push({
|
||||
options.push(
|
||||
{
|
||||
label: t('contextMenu.Add Subgraph to Library'),
|
||||
icon: 'icon-[lucide--folder-plus]',
|
||||
action: addSubgraphToLibrary
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label: t('contextMenu.Unpack Subgraph'),
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Unpack Subgraph'),
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
selectedItems: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: () => ({
|
||||
getSelectedNodes: vi.fn(() => [])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: vi.fn(),
|
||||
get selectedItems() {
|
||||
return mocks.selectedItems
|
||||
},
|
||||
updateSelectedItems: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokeSubgraphPreviews: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({
|
||||
publishSubgraph: mocks.publishSubgraph
|
||||
})
|
||||
}))
|
||||
|
||||
function createSubgraphNode(): SubgraphNode {
|
||||
const node = Object.create(SubgraphNode.prototype)
|
||||
return node
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedItems = []
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
|
||||
mocks.selectedItems = [createSubgraphNode()]
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary does not call publishSubgraph when no items selected', async () => {
|
||||
mocks.selectedItems = []
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary does not call publishSubgraph when multiple items selected', async () => {
|
||||
mocks.selectedItems = [createSubgraphNode(), createSubgraphNode()]
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,9 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling subgraph-related operations
|
||||
@@ -13,7 +15,8 @@ export function useSubgraphOperations() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const convertToSubgraph = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
@@ -70,13 +73,48 @@ export function useSubgraphOperations() {
|
||||
|
||||
const addSubgraphToLibrary = async () => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
const subgraphNodes = selectedItems.filter(
|
||||
(item): item is SubgraphNode => item instanceof SubgraphNode
|
||||
)
|
||||
if (subgraphNodes.length !== 1) {
|
||||
|
||||
// Handle single node selection like BookmarkButton.vue
|
||||
if (selectedItems.length === 1) {
|
||||
const item = selectedItems[0]
|
||||
if (isLGraphNode(item)) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(item)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple nodes - convert to subgraph first then bookmark
|
||||
const selectedNodes = getSelectedNodes()
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
await subgraphStore.publishSubgraph()
|
||||
|
||||
// Check if selection contains subgraph nodes
|
||||
const hasSubgraphs = selectedNodes.some(
|
||||
(node) => node instanceof SubgraphNode
|
||||
)
|
||||
|
||||
if (!hasSubgraphs) {
|
||||
// Convert regular nodes to subgraph first
|
||||
convertToSubgraph()
|
||||
return
|
||||
}
|
||||
|
||||
// For subgraph nodes, bookmark them
|
||||
let bookmarkedCount = 0
|
||||
for (const node of selectedNodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
bookmarkedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSubgraphSelected = (): boolean => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
export type JobAction = {
|
||||
@@ -19,7 +18,7 @@ export function useJobActions(
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { cancelJob, removeFailedJob } = useJobMenu()
|
||||
const { cancelJob } = useJobMenu()
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
@@ -27,12 +26,6 @@ export function useJobActions(
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const deleteAction: JobAction = {
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
label: t('queue.jobMenu.removeJob'),
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const jobRef = computed(() => toValue(job) ?? null)
|
||||
|
||||
const canCancelJob = computed(() => {
|
||||
@@ -44,15 +37,6 @@ export function useJobActions(
|
||||
return currentJob.showClear !== false && isActiveJobState(currentJob.state)
|
||||
})
|
||||
|
||||
const canDeleteJob = computed(() => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentJob.state === 'failed'
|
||||
})
|
||||
|
||||
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
@@ -62,22 +46,9 @@ export function useJobActions(
|
||||
await cancelJob(currentJob)
|
||||
})
|
||||
|
||||
const runDeleteJob = wrapWithErrorHandlingAsync(async () => {
|
||||
const currentJob = jobRef.value
|
||||
const task = currentJob?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
|
||||
await removeFailedJob(task)
|
||||
})
|
||||
|
||||
return {
|
||||
cancelAction,
|
||||
canCancelJob,
|
||||
runCancelJob,
|
||||
deleteAction,
|
||||
canDeleteJob,
|
||||
runDeleteJob
|
||||
runCancelJob
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import type { Ref } from 'vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type TestTask = {
|
||||
jobId: string
|
||||
promptId: string
|
||||
queueIndex: number
|
||||
mockState: JobState
|
||||
executionTime?: number
|
||||
@@ -70,7 +69,7 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({
|
||||
vi.mock('@/utils/queueDisplay', () => ({
|
||||
buildJobDisplay: vi.fn(
|
||||
(task: TaskItemImpl, state: JobState, options: BuildJobDisplayCtx) => ({
|
||||
primary: `Job ${task.jobId}`,
|
||||
primary: `Job ${task.promptId}`,
|
||||
secondary: `${state} meta`,
|
||||
iconName: `${state}-icon`,
|
||||
iconImageUrl: undefined,
|
||||
@@ -109,21 +108,21 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
}))
|
||||
|
||||
let executionStoreMock: {
|
||||
activeJobId: string | null
|
||||
activePromptId: string | null
|
||||
executingNode: null | { title?: string; type?: string }
|
||||
isJobInitializing: (jobId?: string | number) => boolean
|
||||
isPromptInitializing: (promptId?: string | number) => boolean
|
||||
}
|
||||
let isJobInitializingMock: (jobId?: string | number) => boolean
|
||||
let isPromptInitializingMock: (promptId?: string | number) => boolean
|
||||
const ensureExecutionStore = () => {
|
||||
if (!isJobInitializingMock) {
|
||||
isJobInitializingMock = vi.fn(() => false)
|
||||
if (!isPromptInitializingMock) {
|
||||
isPromptInitializingMock = vi.fn(() => false)
|
||||
}
|
||||
if (!executionStoreMock) {
|
||||
executionStoreMock = reactive({
|
||||
activeJobId: null as string | null,
|
||||
activePromptId: null as string | null,
|
||||
executingNode: null as null | { title?: string; type?: string },
|
||||
isJobInitializing: (jobId?: string | number) =>
|
||||
isJobInitializingMock(jobId)
|
||||
isPromptInitializing: (promptId?: string | number) =>
|
||||
isPromptInitializingMock(promptId)
|
||||
})
|
||||
}
|
||||
return executionStoreMock
|
||||
@@ -173,7 +172,8 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
const createTask = (
|
||||
overrides: Partial<TestTask> & { mockState?: JobState } = {}
|
||||
): TestTask => ({
|
||||
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
|
||||
promptId:
|
||||
overrides.promptId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
|
||||
queueIndex: overrides.queueIndex ?? 0,
|
||||
mockState: overrides.mockState ?? 'pending',
|
||||
executionTime: overrides.executionTime,
|
||||
@@ -201,7 +201,7 @@ const resetStores = () => {
|
||||
queueStore.historyTasks = []
|
||||
|
||||
const executionStore = ensureExecutionStore()
|
||||
executionStore.activeJobId = null
|
||||
executionStore.activePromptId = null
|
||||
executionStore.executingNode = null
|
||||
|
||||
const jobPreviewStore = ensureJobPreviewStore()
|
||||
@@ -219,9 +219,9 @@ const resetStores = () => {
|
||||
localeRef.value = 'en-US'
|
||||
tMock.mockClear()
|
||||
|
||||
if (isJobInitializingMock) {
|
||||
vi.mocked(isJobInitializingMock).mockReset()
|
||||
vi.mocked(isJobInitializingMock).mockReturnValue(false)
|
||||
if (isPromptInitializingMock) {
|
||||
vi.mocked(isPromptInitializingMock).mockReset()
|
||||
vi.mocked(isPromptInitializingMock).mockReturnValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,82 +255,10 @@ describe('useJobList', () => {
|
||||
return api!
|
||||
}
|
||||
|
||||
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: true })
|
||||
)
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flush()
|
||||
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('removes pending hint immediately when the task leaves the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
const taskId = '2'
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
jobItems.value
|
||||
|
||||
queueStoreMock.pendingTasks = []
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
|
||||
]
|
||||
await flush()
|
||||
jobItems.value
|
||||
expect(buildJobDisplay).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'pending',
|
||||
expect.objectContaining({ showAddedHint: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up timeouts on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
initComposable()
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts all tasks by create time', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'p',
|
||||
promptId: 'p',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
createTime: 3000
|
||||
@@ -338,7 +266,7 @@ describe('useJobList', () => {
|
||||
]
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'r',
|
||||
promptId: 'r',
|
||||
queueIndex: 5,
|
||||
mockState: 'running',
|
||||
createTime: 2000
|
||||
@@ -346,7 +274,7 @@ describe('useJobList', () => {
|
||||
]
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'h',
|
||||
promptId: 'h',
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
createTime: 1000,
|
||||
@@ -357,7 +285,7 @@ describe('useJobList', () => {
|
||||
const { allTasksSorted } = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(allTasksSorted.value.map((task) => task.jobId)).toEqual([
|
||||
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
|
||||
'p',
|
||||
'r',
|
||||
'h'
|
||||
@@ -366,9 +294,9 @@ describe('useJobList', () => {
|
||||
|
||||
it('filters by job tab and resets failed tab when failures disappear', async () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
@@ -376,15 +304,15 @@ describe('useJobList', () => {
|
||||
|
||||
instance.selectedJobTab.value = 'Completed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['c'])
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
|
||||
|
||||
instance.selectedJobTab.value = 'Failed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['f'])
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
|
||||
expect(instance.hasFailedJobs.value).toBe(true)
|
||||
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
]
|
||||
await flush()
|
||||
|
||||
@@ -395,13 +323,13 @@ describe('useJobList', () => {
|
||||
it('filters by active workflow when requested', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'wf-1',
|
||||
promptId: 'wf-1',
|
||||
queueIndex: 2,
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-1'
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'wf-2',
|
||||
promptId: 'wf-2',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-2'
|
||||
@@ -418,72 +346,28 @@ describe('useJobList', () => {
|
||||
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
|
||||
await flush()
|
||||
|
||||
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['wf-1'])
|
||||
})
|
||||
|
||||
it('filters jobs by search query', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'alpha',
|
||||
queueIndex: 2,
|
||||
mockState: 'completed',
|
||||
createTime: 2000,
|
||||
executionEndTimestamp: 2000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'beta',
|
||||
queueIndex: 1,
|
||||
mockState: 'failed',
|
||||
createTime: 1000,
|
||||
executionEndTimestamp: 1000
|
||||
})
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
|
||||
'alpha',
|
||||
'beta'
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
|
||||
'wf-1'
|
||||
])
|
||||
|
||||
instance.searchQuery.value = 'beta'
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
|
||||
'beta'
|
||||
])
|
||||
|
||||
instance.searchQuery.value = 'failed meta'
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
|
||||
'beta'
|
||||
])
|
||||
|
||||
instance.searchQuery.value = 'does-not-exist'
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value).toEqual([])
|
||||
})
|
||||
|
||||
it('hydrates job items with active progress and compute hours', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'active',
|
||||
promptId: 'active',
|
||||
queueIndex: 3,
|
||||
mockState: 'running',
|
||||
executionTime: 7_200_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'other',
|
||||
promptId: 'other',
|
||||
queueIndex: 2,
|
||||
mockState: 'running',
|
||||
executionTime: 3_600_000
|
||||
})
|
||||
]
|
||||
|
||||
executionStoreMock.activeJobId = 'active'
|
||||
executionStoreMock.activePromptId = 'active'
|
||||
executionStoreMock.executingNode = { title: 'Render Node' }
|
||||
totalPercent.value = 80
|
||||
currentNodePercent.value = 40
|
||||
@@ -506,7 +390,7 @@ describe('useJobList', () => {
|
||||
it('assigns preview urls for running jobs when previews enabled', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'live-preview',
|
||||
promptId: 'live-preview',
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
@@ -525,7 +409,7 @@ describe('useJobList', () => {
|
||||
it('omits preview urls when previews are disabled', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'disabled-preview',
|
||||
promptId: 'disabled-preview',
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
@@ -566,28 +450,28 @@ describe('useJobList', () => {
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'today-small',
|
||||
promptId: 'today-small',
|
||||
queueIndex: 4,
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 2_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'today-large',
|
||||
promptId: 'today-large',
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 5_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'yesterday',
|
||||
promptId: 'yesterday',
|
||||
queueIndex: 2,
|
||||
mockState: 'failed',
|
||||
executionEndTimestamp: Date.now() - 86_400_000,
|
||||
executionTime: 1_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'undated',
|
||||
promptId: 'undated',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending'
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { orderBy } from 'es-toolkit/array'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -128,7 +127,7 @@ export function useJobList() {
|
||||
watch(
|
||||
() =>
|
||||
queueStore.pendingTasks
|
||||
.map((task) => taskIdToKey(task.jobId))
|
||||
.map((task) => taskIdToKey(task.promptId))
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
@@ -159,7 +158,7 @@ export function useJobList() {
|
||||
|
||||
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
|
||||
if (state !== 'pending') return false
|
||||
const id = taskIdToKey(task.jobId)
|
||||
const id = taskIdToKey(task.promptId)
|
||||
if (!id) return false
|
||||
return recentlyAddedPendingIds.value.has(id)
|
||||
}
|
||||
@@ -184,8 +183,8 @@ export function useJobList() {
|
||||
})
|
||||
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
|
||||
|
||||
const isJobInitializing = (jobId: string | number | undefined) =>
|
||||
executionStore.isJobInitializing(jobId)
|
||||
const isJobInitializing = (promptId: string | number | undefined) =>
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
return resolveNodeDisplayName(executionStore.executingNode, {
|
||||
@@ -198,8 +197,6 @@ export function useJobList() {
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
const searchQuery = ref('')
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
|
||||
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
|
||||
|
||||
@@ -215,7 +212,7 @@ export function useJobList() {
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
allTasksSorted.value.map((task) => ({
|
||||
task,
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.jobId))
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -251,20 +248,17 @@ export function useJobList() {
|
||||
return entries
|
||||
})
|
||||
|
||||
const normalizedSearchQuery = computed(() =>
|
||||
debouncedSearchQuery.value.trim().toLocaleLowerCase()
|
||||
)
|
||||
|
||||
const filteredTasks = computed<TaskItemImpl[]>(() =>
|
||||
searchableTaskEntries.value.map(({ task }) => task)
|
||||
filteredTaskEntries.value.map(({ task }) => task)
|
||||
)
|
||||
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.jobId ?? '') === String(executionStore.activeJobId ?? '')
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
const promptKey = taskIdToKey(task.jobId)
|
||||
const promptKey = taskIdToKey(task.promptId)
|
||||
const promptPreviewUrl =
|
||||
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
|
||||
? jobPreviewStore.previewsByPromptId[promptKey]
|
||||
@@ -283,7 +277,7 @@ export function useJobList() {
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(task.jobId),
|
||||
id: String(task.promptId),
|
||||
title: display.primary,
|
||||
meta: display.secondary,
|
||||
state,
|
||||
@@ -314,31 +308,11 @@ export function useJobList() {
|
||||
return m
|
||||
})
|
||||
|
||||
const searchableTaskEntries = computed<TaskWithState[]>(() => {
|
||||
if (!normalizedSearchQuery.value) return filteredTaskEntries.value
|
||||
|
||||
return filteredTaskEntries.value.filter(({ task }) => {
|
||||
const taskId = String(task.jobId ?? '').toLocaleLowerCase()
|
||||
const item = jobItemById.value.get(String(task.jobId))
|
||||
if (!item) {
|
||||
return taskId.includes(normalizedSearchQuery.value)
|
||||
}
|
||||
|
||||
const title = item.title.toLocaleLowerCase()
|
||||
const meta = item.meta.toLocaleLowerCase()
|
||||
return (
|
||||
title.includes(normalizedSearchQuery.value) ||
|
||||
meta.includes(normalizedSearchQuery.value) ||
|
||||
taskId.includes(normalizedSearchQuery.value)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const groupedJobItems = computed<JobGroup[]>(() => {
|
||||
const groups: JobGroup[] = []
|
||||
const index = new Map<string, number>()
|
||||
const localeValue = locale.value
|
||||
for (const { task, state } of searchableTaskEntries.value) {
|
||||
for (const { task, state } of filteredTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp
|
||||
@@ -360,7 +334,7 @@ export function useJobList() {
|
||||
groupIdx = groups.length - 1
|
||||
index.set(key, groupIdx)
|
||||
}
|
||||
const ji = jobItemById.value.get(String(task.jobId))
|
||||
const ji = jobItemById.value.get(String(task.promptId))
|
||||
if (ji) groups[groupIdx].items.push(ji)
|
||||
}
|
||||
|
||||
@@ -383,7 +357,6 @@ export function useJobList() {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
searchQuery,
|
||||
hasFailedJobs,
|
||||
// data sources
|
||||
allTasksSorted,
|
||||
|
||||
@@ -72,7 +72,8 @@ const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
|
||||
interrupt: (runningPromptId: string | null) =>
|
||||
interruptMock(runningPromptId),
|
||||
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
|
||||
}
|
||||
}))
|
||||
@@ -119,7 +120,7 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
}))
|
||||
|
||||
const executionStoreMock = {
|
||||
clearInitializationByJobId: vi.fn()
|
||||
clearInitializationByPromptId: vi.fn()
|
||||
}
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStoreMock
|
||||
@@ -742,17 +743,6 @@ describe('useJobMenu', () => {
|
||||
'd3',
|
||||
'delete'
|
||||
])
|
||||
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(false)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(false)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
await inspectEntry?.onClick?.()
|
||||
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
|
||||
@@ -770,7 +760,6 @@ describe('useJobMenu', () => {
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
expect(inspectEntry?.onClick).toBeUndefined()
|
||||
expect(inspectEntry?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('omits delete asset entry when no preview exists', async () => {
|
||||
@@ -778,15 +767,6 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
|
||||
await nextTick()
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(true)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(true)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
@@ -29,7 +29,6 @@ export type MenuEntry =
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
@@ -84,7 +83,7 @@ export function useJobMenu(
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(target.id)
|
||||
executionStore.clearInitializationByPromptId(target.id)
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
@@ -217,11 +216,10 @@ export function useJobMenu(
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async (task?: TaskItemImpl | null) => {
|
||||
const target =
|
||||
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
|
||||
if (!target) return
|
||||
await queueStore.delete(target)
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
|
||||
const jobMenuOpenWorkflowLabel = computed(() =>
|
||||
@@ -241,14 +239,13 @@ export function useJobMenu(
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!item?.taskRef?.previewOutput
|
||||
const hasDeletableAsset = !!item?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
key: 'inspect-asset',
|
||||
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
disabled: !hasPreviewAsset || !onInspectAsset,
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
@@ -263,14 +260,12 @@ export function useJobMenu(
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
@@ -294,7 +289,7 @@ export function useJobMenu(
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasPreviewAsset
|
||||
...(hasDeletableAsset
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
@@ -370,7 +365,6 @@ export function useJobMenu(
|
||||
jobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob,
|
||||
removeFailedJob
|
||||
cancelJob
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
export const useQueueClearHistoryDialog = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const showQueueClearHistoryDialog = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: 'bg-transparent',
|
||||
style: 'padding: 0'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
showQueueClearHistoryDialog
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
||||
|
||||
const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({
|
||||
const { mockGetSetting, mockPendingTasks } = vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockActiveJobsCount: { value: 0 }
|
||||
mockPendingTasks: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -19,14 +19,14 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: mockActiveJobsCount.value
|
||||
pendingTasks: mockPendingTasks
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetsSidebarTab', () => {
|
||||
it('hides icon badge when QPO V2 is disabled', () => {
|
||||
mockGetSetting.mockReturnValue(false)
|
||||
mockActiveJobsCount.value = 3
|
||||
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {})
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
@@ -34,22 +34,13 @@ describe('useAssetsSidebarTab', () => {
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows active job count when QPO V2 is enabled', () => {
|
||||
it('shows pending task count when QPO V2 is enabled', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockActiveJobsCount.value = 3
|
||||
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {}, {})
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
expect(typeof sidebarTab.iconBadge).toBe('function')
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
|
||||
it('hides badge when no active jobs', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockActiveJobsCount.value = 0
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,8 +22,9 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
}
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const count = queueStore.activeJobsCount
|
||||
return count > 0 ? count.toString() : null
|
||||
return queueStore.pendingTasks.length > 0
|
||||
? queueStore.pendingTasks.length.toString()
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'job-history',
|
||||
icon: 'icon-[lucide--history]',
|
||||
title: 'queue.jobHistory',
|
||||
tooltip: 'queue.jobHistory',
|
||||
label: 'queue.jobHistory',
|
||||
component: markRaw(JobHistorySidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const executionStore = reactive<{
|
||||
executingNode: unknown
|
||||
executingNodeProgress: number
|
||||
nodeProgressStates: Record<string, unknown>
|
||||
activeJob: {
|
||||
activePrompt: {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
@@ -32,7 +32,7 @@ const executionStore = reactive<{
|
||||
executingNode: null,
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {},
|
||||
activeJob: null
|
||||
activePrompt: null
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -76,7 +76,7 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executingNode = null
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activeJob = null
|
||||
executionStore.activePrompt = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
@@ -187,7 +187,7 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
|
||||
}
|
||||
executionStore.activeJob = {
|
||||
executionStore.activePrompt = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
|
||||
@@ -77,7 +77,7 @@ export const useBrowserTabTitle = () => {
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
|
||||
|
||||
@@ -23,13 +23,7 @@ vi.mock('vue-i18n', async () => {
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockGraphClear = vi.fn()
|
||||
const mockCanvas = {
|
||||
subgraph: undefined,
|
||||
selectedItems: new Set(),
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
const mockCanvas = { subgraph: undefined }
|
||||
|
||||
return {
|
||||
app: {
|
||||
@@ -111,8 +105,7 @@ vi.mock('@/stores/subgraphStore', () => ({
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
getCanvas: () => app.canvas,
|
||||
canvas: app.canvas
|
||||
getCanvas: () => app.canvas
|
||||
})),
|
||||
useTitleEditorStore: vi.fn(() => ({
|
||||
titleEditorTarget: null
|
||||
@@ -307,48 +300,6 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas clipboard commands', () => {
|
||||
function findCommand(id: string) {
|
||||
return useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
vi.mocked(app.canvas.copyToClipboard).mockClear()
|
||||
vi.mocked(app.canvas.pasteFromClipboard).mockClear()
|
||||
vi.mocked(app.canvas.selectItems).mockClear()
|
||||
})
|
||||
|
||||
it('should copy selected items when selection exists', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
]) as typeof app.canvas.selectedItems
|
||||
|
||||
await findCommand('Comfy.Canvas.CopySelected').function()
|
||||
|
||||
expect(app.canvas.copyToClipboard).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should not copy when no items are selected', async () => {
|
||||
await findCommand('Comfy.Canvas.CopySelected').function()
|
||||
|
||||
expect(app.canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should paste from clipboard', async () => {
|
||||
await findCommand('Comfy.Canvas.PasteFromClipboard').function()
|
||||
|
||||
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should select all items', async () => {
|
||||
await findCommand('Comfy.Canvas.SelectAll').function()
|
||||
|
||||
// No arguments means "select all items on canvas"
|
||||
expect(app.canvas.selectItems).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph metadata commands', () => {
|
||||
beforeEach(() => {
|
||||
mockSubgraph.extra = {}
|
||||
|
||||
@@ -312,7 +312,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activeJobId)
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t('g.interrupted'),
|
||||
@@ -884,32 +884,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
window.open(staticUrls.forum, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.CopySelected',
|
||||
icon: 'icon-[lucide--copy]',
|
||||
label: 'Copy',
|
||||
function: () => {
|
||||
if (app.canvas.selectedItems?.size) {
|
||||
app.canvas.copyToClipboard()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.PasteFromClipboard',
|
||||
icon: 'icon-[lucide--clipboard-paste]',
|
||||
label: 'Paste',
|
||||
function: () => {
|
||||
app.canvas.pasteFromClipboard()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectAll',
|
||||
icon: 'icon-[lucide--lasso-select]',
|
||||
label: 'Select All',
|
||||
function: () => {
|
||||
app.canvas.selectItems()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.DeleteSelectedItems',
|
||||
icon: 'pi pi-trash',
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { usePerTabState } from './usePerTabState'
|
||||
|
||||
type TabId = 'a' | 'b' | 'c'
|
||||
|
||||
describe('usePerTabState', () => {
|
||||
function setup(initialTab: TabId = 'a') {
|
||||
const selectedTab = ref<TabId>(initialTab)
|
||||
const stateByTab = ref<Record<TabId, string[]>>({
|
||||
a: [],
|
||||
b: [],
|
||||
c: []
|
||||
})
|
||||
const state = usePerTabState(selectedTab, stateByTab)
|
||||
return { selectedTab, stateByTab, state }
|
||||
}
|
||||
|
||||
it('should return state for the current tab', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
stateByTab.value.a = ['key1', 'key2']
|
||||
stateByTab.value.b = ['key3']
|
||||
|
||||
expect(state.value).toEqual(['key1', 'key2'])
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual(['key3'])
|
||||
})
|
||||
|
||||
it('should set state only for the current tab', () => {
|
||||
const { stateByTab, state } = setup()
|
||||
|
||||
state.value = ['new-key1', 'new-key2']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
|
||||
expect(stateByTab.value.b).toEqual([])
|
||||
expect(stateByTab.value.c).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve state when switching tabs', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
state.value = ['a-key']
|
||||
selectedTab.value = 'b'
|
||||
state.value = ['b-key']
|
||||
selectedTab.value = 'c'
|
||||
state.value = ['c-key']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['a-key'])
|
||||
expect(stateByTab.value.b).toEqual(['b-key'])
|
||||
expect(stateByTab.value.c).toEqual(['c-key'])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['a-key'])
|
||||
})
|
||||
|
||||
it('should not share state between tabs', () => {
|
||||
const { selectedTab, state } = setup()
|
||||
|
||||
state.value = ['only-a']
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'c'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['only-a'])
|
||||
})
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function usePerTabState<K extends string, V>(
|
||||
selectedTab: Ref<K>,
|
||||
stateByTab: Ref<Record<K, V>>
|
||||
) {
|
||||
return computed({
|
||||
get: () => stateByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
stateByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,14 +14,6 @@ export const CORE_MENU_COMMANDS = [
|
||||
]
|
||||
],
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[
|
||||
['Edit'],
|
||||
[
|
||||
'Comfy.Canvas.CopySelected',
|
||||
'Comfy.Canvas.PasteFromClipboard',
|
||||
'Comfy.Canvas.SelectAll'
|
||||
]
|
||||
],
|
||||
[['Edit'], ['Comfy.ClearWorkflow']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[
|
||||
|
||||
@@ -5,8 +5,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -125,94 +125,6 @@ describe('Subgraph proxyWidgets', () => {
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
expect(proxyWidget.label).toBe('seed')
|
||||
expect(proxyWidget.name).toBe('1: seed')
|
||||
})
|
||||
|
||||
test('Proxy widget label reflects linked widget label', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
expect(proxyWidget.label).toBe('seed')
|
||||
|
||||
innerNodes[0].widgets![0].label = 'My Inner Label'
|
||||
// Trigger re-resolve of linked widget
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('My Inner Label')
|
||||
})
|
||||
|
||||
test('Proxy widget user rename takes priority over linked widget label', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
proxyWidget.label = 'My Custom Seed'
|
||||
expect(proxyWidget.label).toBe('My Custom Seed')
|
||||
|
||||
innerNodes[0].widgets![0].label = 'Inner Override'
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('My Custom Seed')
|
||||
})
|
||||
|
||||
test('Proxy widget label resets to linked widget on undefined', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
proxyWidget.label = 'Custom'
|
||||
expect(proxyWidget.label).toBe('Custom')
|
||||
|
||||
proxyWidget.label = undefined
|
||||
innerNodes[0].widgets![0].label = 'Inner Label'
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('Inner Label')
|
||||
})
|
||||
|
||||
test('Proxy widget labels are correct when loaded from serialized data', () => {
|
||||
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
|
||||
// to exercise the deserialization/onConfigure path from blueprint JSON.
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(innerNode)
|
||||
innerNode.addWidget('text', 'seed', 'value', () => {})
|
||||
innerNode.addWidget('text', 'steps', 'value', () => {})
|
||||
|
||||
const parentGraph = new LGraph()
|
||||
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
|
||||
id: 1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['1', 'seed'],
|
||||
['1', 'steps']
|
||||
]
|
||||
},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].label).toBe('seed')
|
||||
expect(subgraphNode.widgets[0].name).toBe('1: seed')
|
||||
expect(subgraphNode.widgets[1].label).toBe('steps')
|
||||
expect(subgraphNode.widgets[1].name).toBe('1: steps')
|
||||
})
|
||||
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
@@ -154,6 +154,7 @@ function newProxyWidget(
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
last_y: undefined,
|
||||
label: name,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
onRemove: undefined,
|
||||
@@ -201,15 +202,12 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
* and the value used as 'this' if property is a get/set method
|
||||
* @param {unknown} value - only used on set calls. The thing being assigned
|
||||
*/
|
||||
let userLabel: string | undefined
|
||||
const handler = {
|
||||
get(_t: IBaseWidget, property: string, receiver: object) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
let redirectedReceiver = receiver
|
||||
if (property == '_overlay') return overlay
|
||||
else if (property == 'value') redirectedReceiver = backingWidget
|
||||
else if (property == 'label')
|
||||
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
redirectedReceiver = overlay
|
||||
@@ -217,10 +215,6 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
return Reflect.get(redirectedTarget, property, redirectedReceiver)
|
||||
},
|
||||
set(_t: IBaseWidget, property: string, value: unknown) {
|
||||
if (property == 'label') {
|
||||
userLabel = value as string | undefined
|
||||
return true
|
||||
}
|
||||
let redirectedTarget: object = backingWidget
|
||||
if (property == 'computedHeight') {
|
||||
if (overlay.widgetName.startsWith('$$') && linkedNode) {
|
||||
|
||||
@@ -277,7 +277,6 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
) {
|
||||
const input = this.inputs[slot]
|
||||
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
|
||||
if (app.configuringGraph) return
|
||||
const [matchKey, matchGroup] = Object.entries(
|
||||
this.comfyDynamic.matchType
|
||||
).find(([, group]) => input.name in group) ?? ['', undefined]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user