Compare commits
2 Commits
queue-over
...
graph-stat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71401b1059 | ||
|
|
076acf1b31 |
124
.github/workflows/release-version-bump.yaml
vendored
@@ -20,13 +20,6 @@ on:
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
schedule:
|
||||
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: release-version-bump
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
@@ -36,99 +29,15 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Prepare inputs
|
||||
id: prepared-inputs
|
||||
shell: bash
|
||||
env:
|
||||
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
|
||||
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
|
||||
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION_TYPE="$RAW_VERSION_TYPE"
|
||||
PRE_RELEASE="$RAW_PRE_RELEASE"
|
||||
TARGET_BRANCH="$RAW_BRANCH"
|
||||
|
||||
if [[ -z "$VERSION_TYPE" ]]; then
|
||||
VERSION_TYPE='patch'
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_BRANCH" ]]; then
|
||||
TARGET_BRANCH='main'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version_type=$VERSION_TYPE"
|
||||
echo "pre_release=$PRE_RELEASE"
|
||||
echo "branch=$TARGET_BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Close stale nightly version bump PRs
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const prefix = 'version-bump-'
|
||||
const closed = []
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
})
|
||||
|
||||
for (const pr of prs) {
|
||||
if (!pr.head?.ref?.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (pr.user?.login !== 'github-actions[bot]') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only clean up stale nightly PRs targeting main.
|
||||
// Adjust here if other target branches should be cleaned.
|
||||
if (pr.base?.ref !== 'main') {
|
||||
continue
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${pr.head.ref}`
|
||||
})
|
||||
} catch (error) {
|
||||
if (![404, 422].includes(error.status)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
closed.push(pr.number)
|
||||
}
|
||||
|
||||
core.info(`Closed ${closed.length} stale PR(s).`)
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate branch exists
|
||||
env:
|
||||
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
run: |
|
||||
BRANCH="$TARGET_BRANCH"
|
||||
BRANCH="${{ github.event.inputs.branch }}"
|
||||
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
|
||||
echo "❌ Branch '$BRANCH' does not exist"
|
||||
echo ""
|
||||
@@ -142,7 +51,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -153,31 +62,16 @@ jobs:
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
|
||||
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$PRE_RELEASE" ]]; then
|
||||
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
|
||||
else
|
||||
pnpm version "$VERSION_TYPE" --no-git-tag-version
|
||||
fi
|
||||
|
||||
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Format PR string
|
||||
id: capitalised
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
run: |
|
||||
CAPITALISED_TYPE="$VERSION_TYPE"
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
|
||||
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
@@ -188,8 +82,8 @@ jobs:
|
||||
body: |
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
|
||||
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
|
||||
**Base branch:** `${{ github.event.inputs.branch }}`
|
||||
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
base: ${{ github.event.inputs.branch }}
|
||||
labels: |
|
||||
Release
|
||||
|
||||
@@ -51,6 +51,8 @@ defineProps<{
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
|
Before Width: | Height: | Size: 422 B |
@@ -13,7 +13,6 @@ import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { QueueList } from './components/QueueList'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import {
|
||||
NodeLibrarySidebarTab,
|
||||
@@ -127,6 +126,20 @@ class ConfirmDialog {
|
||||
const loc = this[locator]
|
||||
await expect(loc).toBeVisible()
|
||||
await loc.click()
|
||||
|
||||
// Wait for the dialog mask to disappear after confirming
|
||||
const mask = this.page.locator('.p-dialog-mask')
|
||||
const count = await mask.count()
|
||||
if (count > 0) {
|
||||
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
|
||||
}
|
||||
|
||||
// Wait for workflow service to finish if it's busy
|
||||
await this.page.waitForFunction(
|
||||
() => window['app']?.extensionManager?.workflow?.isBusy === false,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +165,6 @@ export class ComfyPage {
|
||||
|
||||
// Components
|
||||
public readonly searchBox: ComfyNodeSearchBox
|
||||
public readonly queueList: QueueList
|
||||
public readonly menu: ComfyMenu
|
||||
public readonly actionbar: ComfyActionbar
|
||||
public readonly templates: ComfyTemplates
|
||||
@@ -185,7 +197,6 @@ export class ComfyPage {
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.queueList = new QueueList(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
@@ -245,6 +256,9 @@ export class ComfyPage {
|
||||
await this.page.evaluate(async () => {
|
||||
await window['app'].extensionManager.workflow.syncWorkflows()
|
||||
})
|
||||
|
||||
// Wait for Vue to re-render the workflow list
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
|
||||
@@ -160,7 +160,7 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').nth(1)
|
||||
decrementButton: widget.locator('button').last()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
export class QueueList {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get toggleButton() {
|
||||
return this.page.getByTestId('queue-toggle-button')
|
||||
}
|
||||
|
||||
get inlineProgress() {
|
||||
return this.page.getByTestId('queue-inline-progress')
|
||||
}
|
||||
|
||||
get overlay() {
|
||||
return this.page.getByTestId('queue-overlay')
|
||||
}
|
||||
|
||||
get closeButton() {
|
||||
return this.page.getByTestId('queue-overlay-close-button')
|
||||
}
|
||||
|
||||
get jobItems() {
|
||||
return this.page.getByTestId('queue-job-item')
|
||||
}
|
||||
|
||||
get clearHistoryButton() {
|
||||
return this.page.getByRole('button', { name: /Clear History/i })
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (!(await this.overlay.isVisible())) {
|
||||
await this.toggleButton.click()
|
||||
await expect(this.overlay).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (await this.overlay.isVisible()) {
|
||||
await this.closeButton.click()
|
||||
await expect(this.overlay).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
async getJobCount(state?: string) {
|
||||
if (state) {
|
||||
return await this.page
|
||||
.locator(`[data-testid="queue-job-item"][data-job-state="${state}"]`)
|
||||
.count()
|
||||
}
|
||||
return await this.jobItems.count()
|
||||
}
|
||||
|
||||
getJobAction(actionKey: string) {
|
||||
return this.page.getByTestId(`job-action-${actionKey}`)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
|
||||
export type WsMessage = { type: 'status'; data: StatusWsMessage }
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: WsMessage, url?: string): Promise<void> }
|
||||
ws: { trigger(data: any, url?: string): Promise<void> }
|
||||
}>({
|
||||
ws: [
|
||||
async ({ page }, use) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import type { WsMessage } from '../fixtures/ws'
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts'
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage.ts'
|
||||
import { webSocketFixture } from '../fixtures/ws.ts'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Actionbar', () => {
|
||||
|
||||
// Trigger a status websocket message
|
||||
const triggerStatus = async (queueSize: number) => {
|
||||
const message = {
|
||||
await ws.trigger({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: {
|
||||
@@ -70,9 +70,7 @@ test.describe('Actionbar', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies WsMessage
|
||||
|
||||
await ws.trigger(message)
|
||||
} as StatusWsMessage)
|
||||
}
|
||||
|
||||
// Extract the width from the queue response
|
||||
|
||||
@@ -12,7 +12,6 @@ test.describe('Load Workflow in Media', () => {
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,29 +0,0 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 256 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-default-workflow.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,157 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from '../../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../../fixtures/ws'
|
||||
import type { WsMessage } from '../../fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
type QueueState = {
|
||||
running: QueueJob[]
|
||||
pending: QueueJob[]
|
||||
}
|
||||
|
||||
type QueueJob = [
|
||||
string,
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
string[]
|
||||
]
|
||||
|
||||
type QueueController = {
|
||||
state: QueueState
|
||||
sync: (
|
||||
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
|
||||
nextState: Partial<QueueState>
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
test.describe('Queue UI', () => {
|
||||
let queue: QueueController
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/api/prompt', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: 'mock-prompt-id',
|
||||
number: 1,
|
||||
node_errors: {}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Mock history to avoid pulling real data
|
||||
await comfyPage.page.route('**/api/history**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ History: [] })
|
||||
})
|
||||
})
|
||||
|
||||
queue = await createQueueController(comfyPage)
|
||||
})
|
||||
|
||||
test('toggles overlay and updates count from status events', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await queue.sync(ws, { running: [], pending: [] })
|
||||
|
||||
await expect(comfyPage.queueList.toggleButton).toContainText('0')
|
||||
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
|
||||
await expect(comfyPage.queueList.overlay).toBeHidden()
|
||||
|
||||
await queue.sync(ws, {
|
||||
pending: [queueJob('1', 'mock-pending', 'client-a')]
|
||||
})
|
||||
|
||||
await expect(comfyPage.queueList.toggleButton).toContainText('1')
|
||||
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
|
||||
|
||||
await comfyPage.queueList.open()
|
||||
await expect(comfyPage.queueList.overlay).toBeVisible()
|
||||
await expect(comfyPage.queueList.jobItems).toHaveCount(1)
|
||||
|
||||
await comfyPage.queueList.close()
|
||||
await expect(comfyPage.queueList.overlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('displays running and pending jobs via status updates', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await queue.sync(ws, {
|
||||
running: [queueJob('2', 'mock-running', 'client-b')],
|
||||
pending: [queueJob('3', 'mock-pending', 'client-c')]
|
||||
})
|
||||
|
||||
await comfyPage.queueList.open()
|
||||
await expect(comfyPage.queueList.jobItems).toHaveCount(2)
|
||||
|
||||
const firstJob = comfyPage.queueList.jobItems.first()
|
||||
await firstJob.hover()
|
||||
|
||||
const cancelAction = firstJob
|
||||
.getByTestId('job-action-cancel-running')
|
||||
.or(firstJob.getByTestId('job-action-cancel-hover'))
|
||||
|
||||
await expect(cancelAction).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
const queueJob = (
|
||||
queueIndex: string,
|
||||
promptId: string,
|
||||
clientId: string
|
||||
): QueueJob => [
|
||||
queueIndex,
|
||||
promptId,
|
||||
{ client_id: clientId },
|
||||
{ class_type: 'Note' },
|
||||
['output']
|
||||
]
|
||||
|
||||
const createQueueController = async (
|
||||
comfyPage: ComfyPage
|
||||
): Promise<QueueController> => {
|
||||
const state: QueueState = { running: [], pending: [] }
|
||||
|
||||
// Single queue handler reads the latest in-memory state
|
||||
await comfyPage.page.route('**/api/queue', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
queue_running: state.running,
|
||||
queue_pending: state.pending
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const sync = async (
|
||||
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
|
||||
nextState: Partial<QueueState>
|
||||
) => {
|
||||
if (nextState.running) state.running = nextState.running
|
||||
if (nextState.pending) state.pending = nextState.pending
|
||||
|
||||
const total = state.running.length + state.pending.length
|
||||
const queueResponse = comfyPage.page.waitForResponse('**/api/queue')
|
||||
|
||||
await ws.trigger({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: { exec_info: { queue_remaining: total } }
|
||||
}
|
||||
})
|
||||
|
||||
await queueResponse
|
||||
}
|
||||
|
||||
return { state, sync }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 111 KiB |
@@ -15,9 +15,7 @@ test.describe('Vue Integer Widget', () => {
|
||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'seed')
|
||||
.first()
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 84 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.35.6",
|
||||
"version": "1.35.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -264,7 +264,7 @@ if (!releaseInfo) {
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
|
||||
@@ -1,56 +1,67 @@
|
||||
<template>
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex flex-col gap-1 pt-1">
|
||||
<div class="flex gap-x-0.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
class="actionbar-container relative pointer-events-auto flex h-12 items-center overflow-hidden rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar
|
||||
v-model:docked="isActionbarDocked"
|
||||
v-model:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
/>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<QueueInlineProgressSummary
|
||||
v-if="!isActionbarFloating"
|
||||
class="pr-1"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -58,39 +69,33 @@ import { useI18n } from 'vue-i18n'
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingsStore = useSettingStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const actionbarContainerRef = ref<HTMLElement>()
|
||||
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const actionbarPosition = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const isActionbarEnabled = computed(
|
||||
() => actionbarPosition.value !== 'Disabled'
|
||||
)
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
const queueStore = useQueueStore()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
@@ -103,6 +108,10 @@ onMounted(() => {
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center">
|
||||
<div
|
||||
v-if="isDragging && !docked"
|
||||
v-if="isDragging && !isDocked"
|
||||
:class="actionbarClass"
|
||||
@mouseenter="onMouseEnterDropZone"
|
||||
@mouseleave="onMouseLeaveDropZone"
|
||||
@@ -9,101 +9,46 @@
|
||||
{{ t('actionbar.dockToTop') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarWrapperRef"
|
||||
:class="panelClass"
|
||||
<Panel
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
class="flex flex-col items-stretch"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
header: { class: 'hidden' },
|
||||
content: { class: isDocked ? 'p-0' : 'p-1' }
|
||||
}"
|
||||
>
|
||||
<Panel
|
||||
:class="
|
||||
cn(
|
||||
panelRootClass,
|
||||
isDragging ? 'pointer-events-none' : 'pointer-events-auto'
|
||||
)
|
||||
"
|
||||
:pt="{
|
||||
header: { class: 'hidden' },
|
||||
content: { class: 'p-0' }
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="panelRef"
|
||||
:class="cn('flex flex-col', docked ? 'p-0' : 'p-1')"
|
||||
>
|
||||
<div class="flex items-center select-none">
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle cursor-grab w-3 h-max mr-2',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<Suspense @resolve="comfyRunButtonResolved">
|
||||
<ComfyRunButton />
|
||||
</Suspense>
|
||||
<IconButton
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
<IconTextButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
size="sm"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
data-testid="queue-toggle-button"
|
||||
class="ml-2 h-8 border-0 px-3 text-sm font-medium text-base-foreground cursor-pointer"
|
||||
:aria-pressed="props.queueOverlayExpanded"
|
||||
:aria-label="queueToggleLabel"
|
||||
:label="queueToggleLabel"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<!-- Custom implementation for static 1-2 digit shifts -->
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex min-w-[2ch] justify-center tabular-nums text-center"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
<span>{{ queuedSuffix }}</span>
|
||||
</span>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<div v-if="isFloating" class="flex justify-end pt-1 pr-1">
|
||||
<QueueInlineProgressSummary
|
||||
class="pr-1"
|
||||
:hidden="props.queueOverlayExpanded"
|
||||
<div ref="panelRef" class="flex items-center select-none">
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle cursor-grab w-3 h-max mr-2',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Suspense @resolve="comfyRunButtonResolved">
|
||||
<ComfyRunButton />
|
||||
</Suspense>
|
||||
<IconButton
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
<QueueInlineProgress
|
||||
:hidden="props.queueOverlayExpanded"
|
||||
data-testid="queue-inline-progress"
|
||||
/>
|
||||
</Teleport>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
unrefElement,
|
||||
useDraggable,
|
||||
useEventListener,
|
||||
useLocalStorage,
|
||||
@@ -113,59 +58,34 @@ import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const props = defineProps<{
|
||||
queueOverlayExpanded: boolean
|
||||
topMenuContainer?: HTMLElement | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:queueOverlayExpanded', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
|
||||
const tabContainer = document.querySelector('.workflow-tabs-container')
|
||||
const actionbarWrapperRef = ref<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const docked = defineModel<boolean>('docked', { default: false })
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const wrapperElement = computed(() => {
|
||||
const element = unrefElement(actionbarWrapperRef)
|
||||
return element instanceof HTMLElement ? element : null
|
||||
})
|
||||
const panelElement = computed(() => {
|
||||
const element = unrefElement(panelRef)
|
||||
return element instanceof HTMLElement ? element : null
|
||||
})
|
||||
const { x, y, style, isDragging } = useDraggable(wrapperElement, {
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body,
|
||||
@@ -178,33 +98,6 @@ const { x, y, style, isDragging } = useDraggable(wrapperElement, {
|
||||
}
|
||||
})
|
||||
|
||||
// Queue and Execution logic
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueToggleLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.toggleLabel', {
|
||||
count: queuedCount.value
|
||||
})
|
||||
)
|
||||
const queuedSuffix = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
emit('update:queueOverlayExpanded', !props.queueOverlayExpanded)
|
||||
}
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
// Update storedPosition when x or y changes
|
||||
watchDebounced(
|
||||
[x, y],
|
||||
@@ -216,12 +109,11 @@ watchDebounced(
|
||||
|
||||
// Set initial position to bottom center
|
||||
const setInitialPosition = () => {
|
||||
const containerEl = wrapperElement.value
|
||||
if (containerEl) {
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuWidth = containerEl.offsetWidth
|
||||
const menuHeight = containerEl.offsetHeight
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
if (menuWidth === 0 || menuHeight === 0) {
|
||||
return
|
||||
@@ -297,12 +189,11 @@ watch(
|
||||
)
|
||||
|
||||
const adjustMenuPosition = () => {
|
||||
const containerEl = wrapperElement.value
|
||||
if (containerEl) {
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuWidth = containerEl.offsetWidth
|
||||
const menuHeight = containerEl.offsetHeight
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
// Calculate distances to all edges
|
||||
const distanceLeft = lastDragState.value.x
|
||||
@@ -373,27 +264,31 @@ const onMouseLeaveDropZone = () => {
|
||||
watch(isDragging, (dragging) => {
|
||||
if (dragging) {
|
||||
// Starting to drag - undock if docked
|
||||
if (docked.value) {
|
||||
docked.value = false
|
||||
if (isDocked.value) {
|
||||
isDocked.value = false
|
||||
}
|
||||
} else {
|
||||
// Stopped dragging - dock if mouse is over drop zone
|
||||
if (isMouseOverDropZone.value) {
|
||||
docked.value = true
|
||||
isDocked.value = true
|
||||
}
|
||||
// Reset drop zone state
|
||||
isMouseOverDropZone.value = false
|
||||
}
|
||||
})
|
||||
const isFloating = computed(() => visible.value && !docked.value)
|
||||
const inlineProgressTarget = computed(() => {
|
||||
if (!visible.value) return null
|
||||
if (isFloating.value) return panelElement.value
|
||||
return props.topMenuContainer ?? null
|
||||
})
|
||||
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
'w-[300px] border-dashed border-blue-500 opacity-80',
|
||||
'w-[200px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
@@ -403,21 +298,11 @@ const actionbarClass = computed(() =>
|
||||
)
|
||||
const panelClass = computed(() =>
|
||||
cn(
|
||||
'actionbar z-1300 overflow-hidden rounded-[var(--p-panel-border-radius)]',
|
||||
docked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed',
|
||||
isDragging.value ? 'select-none pointer-events-none' : 'pointer-events-auto'
|
||||
'actionbar pointer-events-auto z-1300',
|
||||
isDragging.value && 'select-none pointer-events-none',
|
||||
isDocked.value
|
||||
? 'p-0 static mr-2 border-none bg-transparent'
|
||||
: 'fixed shadow-interface'
|
||||
)
|
||||
)
|
||||
const panelRootClass = computed(() =>
|
||||
cn(
|
||||
'relative overflow-hidden rounded-[var(--p-panel-border-radius)]',
|
||||
docked.value
|
||||
? 'border-none shadow-none bg-transparent'
|
||||
: 'border border-interface-stroke shadow-interface'
|
||||
)
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
isFloating
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
@click="onClick"
|
||||
>
|
||||
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
|
||||
<slot v-if="hasDefaultSlot"></slot>
|
||||
<span v-else>{{ label }}</span>
|
||||
<span>{{ label }}</span>
|
||||
<slot v-if="iconPosition === 'right'" name="icon"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
@@ -47,9 +46,6 @@ const {
|
||||
onClick
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const slots = useSlots()
|
||||
const hasDefaultSlot = computed(() => Boolean(slots.default?.().length))
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShow"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-[3px]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${totalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${currentNodePercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
|
||||
const props = defineProps<{
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
|
||||
const shouldShow = computed(
|
||||
() =>
|
||||
!props.hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
|
||||
)
|
||||
</script>
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import QueueInlineProgressSummary from './QueueInlineProgressSummary.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeProgressState, ProgressWsMessage } from '@/schemas/apiSchema'
|
||||
|
||||
type SeedOptions = {
|
||||
promptId: string
|
||||
nodes: Record<NodeId, boolean>
|
||||
runningNodeId?: NodeId
|
||||
runningNodeTitle?: string
|
||||
runningNodeType?: string
|
||||
currentValue?: number
|
||||
currentMax?: number
|
||||
}
|
||||
|
||||
function createWorkflow({
|
||||
promptId,
|
||||
nodes,
|
||||
runningNodeId,
|
||||
runningNodeTitle,
|
||||
runningNodeType
|
||||
}: SeedOptions): ComfyWorkflow {
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: `${ComfyWorkflow.basePath}${promptId}.json`,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
|
||||
const workflowState: ComfyWorkflowJSON = {
|
||||
last_node_id: Object.keys(nodes).length,
|
||||
last_link_id: 0,
|
||||
nodes: Object.keys(nodes).map((id, index) => ({
|
||||
id,
|
||||
type: id === runningNodeId ? (runningNodeType ?? 'Node') : 'Node',
|
||||
title: id === runningNodeId ? (runningNodeTitle ?? '') : `Node ${id}`,
|
||||
pos: [index * 120, 0],
|
||||
size: [240, 120],
|
||||
flags: {},
|
||||
order: index,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
widgets_values: []
|
||||
})),
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
|
||||
workflow.changeTracker = new ChangeTracker(workflow, workflowState)
|
||||
return workflow
|
||||
}
|
||||
|
||||
function resetExecutionStore() {
|
||||
const exec = useExecutionStore()
|
||||
exec.activePromptId = null
|
||||
exec.queuedPrompts = {}
|
||||
exec.nodeProgressStates = {}
|
||||
exec.nodeProgressStatesByPrompt = {}
|
||||
exec._executingNodeProgress = null
|
||||
exec.lastExecutionError = null
|
||||
exec.lastNodeErrors = null
|
||||
exec.initializingPromptIds = new Set()
|
||||
exec.promptIdToWorkflowId = new Map()
|
||||
}
|
||||
|
||||
function seedExecutionState({
|
||||
promptId,
|
||||
nodes,
|
||||
runningNodeId,
|
||||
runningNodeTitle,
|
||||
runningNodeType,
|
||||
currentValue = 0,
|
||||
currentMax = 100
|
||||
}: SeedOptions) {
|
||||
resetExecutionStore()
|
||||
|
||||
const exec = useExecutionStore()
|
||||
const workflow = runningNodeId
|
||||
? createWorkflow({
|
||||
promptId,
|
||||
nodes,
|
||||
runningNodeId,
|
||||
runningNodeTitle,
|
||||
runningNodeType
|
||||
})
|
||||
: undefined
|
||||
|
||||
exec.activePromptId = promptId
|
||||
exec.queuedPrompts = {
|
||||
[promptId]: {
|
||||
nodes,
|
||||
...(workflow ? { workflow } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const nodeProgress: Record<string, NodeProgressState> = runningNodeId
|
||||
? {
|
||||
[String(runningNodeId)]: {
|
||||
value: currentValue,
|
||||
max: currentMax,
|
||||
state: 'running',
|
||||
node_id: runningNodeId,
|
||||
display_node_id: runningNodeId,
|
||||
prompt_id: promptId
|
||||
}
|
||||
}
|
||||
: {}
|
||||
|
||||
exec.nodeProgressStates = nodeProgress
|
||||
exec.nodeProgressStatesByPrompt = runningNodeId
|
||||
? { [promptId]: nodeProgress }
|
||||
: {}
|
||||
exec._executingNodeProgress = runningNodeId
|
||||
? ({
|
||||
value: currentValue,
|
||||
max: currentMax,
|
||||
prompt_id: promptId,
|
||||
node: runningNodeId
|
||||
} satisfies ProgressWsMessage)
|
||||
: null
|
||||
}
|
||||
|
||||
const meta: Meta<typeof QueueInlineProgressSummary> = {
|
||||
title: 'Queue/QueueInlineProgressSummary',
|
||||
component: QueueInlineProgressSummary,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'light'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const RunningKSampler: Story = {
|
||||
render: () => ({
|
||||
components: { QueueInlineProgressSummary },
|
||||
setup() {
|
||||
seedExecutionState({
|
||||
promptId: 'prompt-running',
|
||||
nodes: { '1': true, '2': false, '3': false, '4': true },
|
||||
runningNodeId: '2',
|
||||
runningNodeTitle: 'KSampler',
|
||||
runningNodeType: 'KSampler',
|
||||
currentValue: 12,
|
||||
currentMax: 100
|
||||
})
|
||||
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
|
||||
<QueueInlineProgressSummary />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const RunningWithFallbackName: Story = {
|
||||
render: () => ({
|
||||
components: { QueueInlineProgressSummary },
|
||||
setup() {
|
||||
seedExecutionState({
|
||||
promptId: 'prompt-fallback',
|
||||
nodes: { '10': true, '11': true, '12': false, '13': true },
|
||||
runningNodeId: '12',
|
||||
runningNodeTitle: '',
|
||||
runningNodeType: 'custom_node',
|
||||
currentValue: 78,
|
||||
currentMax: 100
|
||||
})
|
||||
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
|
||||
<QueueInlineProgressSummary />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ProgressWithoutCurrentNode: Story = {
|
||||
render: () => ({
|
||||
components: { QueueInlineProgressSummary },
|
||||
setup() {
|
||||
seedExecutionState({
|
||||
promptId: 'prompt-progress-only',
|
||||
nodes: { '21': true, '22': true, '23': true, '24': false }
|
||||
})
|
||||
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
|
||||
<QueueInlineProgressSummary />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="flex justify-end">
|
||||
<div
|
||||
class="flex items-center gap-4 whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-base-foreground">
|
||||
<span class="font-normal">
|
||||
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
|
||||
</span>
|
||||
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
|
||||
{{ totalPercentFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<span
|
||||
class="w-[16ch] shrink-0 truncate text-right"
|
||||
:title="currentNodeName"
|
||||
>
|
||||
{{ currentNodeName }}:
|
||||
</span>
|
||||
<span class="w-[5ch] shrink-0 text-right tabular-nums">
|
||||
{{ currentNodePercentFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const props = defineProps<{
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const { currentNodeName } = useCurrentNodeName()
|
||||
const {
|
||||
totalPercent,
|
||||
totalPercentFormatted,
|
||||
currentNodePercent,
|
||||
currentNodePercentFormatted
|
||||
} = useQueueProgress()
|
||||
|
||||
const shouldShow = computed(
|
||||
() =>
|
||||
!props.hidden &&
|
||||
(!executionStore.isIdle ||
|
||||
totalPercent.value > 0 ||
|
||||
currentNodePercent.value > 0)
|
||||
)
|
||||
</script>
|
||||
125
src/components/queue/QueueOverlayActive.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from './QueueOverlayActive.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
total: 'Total: {percent}',
|
||||
currentNode: 'Current node:',
|
||||
running: 'running',
|
||||
interruptAll: 'Interrupt all running jobs',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
viewAllJobs: 'View all jobs',
|
||||
cancelJobTooltip: 'Cancel job',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipDirectiveStub = {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const SELECTORS = {
|
||||
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
|
||||
clearQueuedButton: 'button[aria-label="Clear queued"]',
|
||||
summaryRow: '.flex.items-center.gap-2',
|
||||
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
viewAllJobs: 'View all jobs'
|
||||
}
|
||||
|
||||
const mountComponent = (props: Record<string, unknown> = {}) =>
|
||||
mount(QueueOverlayActive, {
|
||||
props: {
|
||||
totalProgressStyle: { width: '65%' },
|
||||
currentNodeProgressStyle: { width: '40%' },
|
||||
totalPercentFormatted: '65%',
|
||||
currentNodePercentFormatted: '40%',
|
||||
currentNodeName: 'Sampler',
|
||||
runningCount: 1,
|
||||
queuedCount: 2,
|
||||
bottomRowClass: 'flex custom-bottom-row',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: tooltipDirectiveStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayActive', () => {
|
||||
it('renders progress metrics and emits actions when buttons clicked', async () => {
|
||||
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
|
||||
|
||||
const progressBars = wrapper.findAll('.absolute.inset-0')
|
||||
expect(progressBars[0].attributes('style')).toContain('width: 65%')
|
||||
expect(progressBars[1].attributes('style')).toContain('width: 40%')
|
||||
|
||||
const content = wrapper.text().replace(/\s+/g, ' ')
|
||||
expect(content).toContain('Total: 65%')
|
||||
|
||||
const [runningSection, queuedSection] = wrapper.findAll(
|
||||
SELECTORS.summaryRow
|
||||
)
|
||||
expect(runningSection.text()).toContain('2')
|
||||
expect(runningSection.text()).toContain('running')
|
||||
expect(queuedSection.text()).toContain('3')
|
||||
expect(queuedSection.text()).toContain('queued')
|
||||
|
||||
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
|
||||
expect(currentNodeSection.text()).toContain('Current node:')
|
||||
expect(currentNodeSection.text()).toContain('Sampler')
|
||||
expect(currentNodeSection.text()).toContain('40%')
|
||||
|
||||
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
|
||||
await interruptButton.trigger('click')
|
||||
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
|
||||
|
||||
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const viewAllButton = buttons.find((btn) =>
|
||||
btn.text().includes(COPY.viewAllJobs)
|
||||
)
|
||||
expect(viewAllButton).toBeDefined()
|
||||
await viewAllButton!.trigger('click')
|
||||
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
|
||||
|
||||
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides action buttons when counts are zero', () => {
|
||||
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
|
||||
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('builds tooltip configs with translated strings', () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
mountComponent()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('Cancel job')
|
||||
expect(spy).toHaveBeenCalledWith('Clear queue')
|
||||
})
|
||||
})
|
||||
125
src/components/queue/QueueOverlayActive.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="relative h-2 w-full overflow-hidden rounded-full border border-interface-stroke bg-interface-panel-surface"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 h-full rounded-full transition-[width]"
|
||||
:style="totalProgressStyle"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 h-full rounded-full transition-[width]"
|
||||
:style="currentNodeProgressStyle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-end gap-4 text-[12px] leading-none">
|
||||
<div class="flex items-center gap-1 text-text-primary opacity-90">
|
||||
<i18n-t keypath="sideToolbar.queueProgressOverlay.total">
|
||||
<template #percent>
|
||||
<span class="font-bold">{{ totalPercentFormatted }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-text-secondary">
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.currentNode') }}</span>
|
||||
<span class="inline-block max-w-[10rem] truncate">{{
|
||||
currentNodeName
|
||||
}}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ currentNodePercentFormatted }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="bottomRowClass">
|
||||
<div class="flex items-center gap-4 text-[12px] text-text-primary">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ runningCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
<IconButton
|
||||
v-if="runningCount > 0"
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearQueueTooltip"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextButton
|
||||
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
|
||||
@click="$emit('viewAllJobs')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
totalProgressStyle: Record<string, string>
|
||||
currentNodeProgressStyle: Record<string, string>
|
||||
totalPercentFormatted: string
|
||||
currentNodePercentFormatted: string
|
||||
currentNodeName: string
|
||||
runningCount: number
|
||||
queuedCount: number
|
||||
bottomRowClass: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'interruptAll'): void
|
||||
(e: 'clearQueued'): void
|
||||
(e: 'viewAllJobs'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const cancelJobTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
|
||||
)
|
||||
const clearQueueTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
|
||||
)
|
||||
</script>
|
||||
@@ -1,39 +1,63 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<QueueOverlayHeader
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center justify-between px-3 text-[12px] leading-none"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{ activeJobsCount }}
|
||||
{{ t('sideToolbar.queueProgressOverlay.activeJobsSuffix') }}
|
||||
</span>
|
||||
<div
|
||||
v-if="queuedCount > 0"
|
||||
class="inline-flex items-center gap-2 text-text-primary"
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<IconTextButton
|
||||
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<span class="opacity-90">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueue') }}
|
||||
</span>
|
||||
<template #icon>
|
||||
<div
|
||||
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="ml-4 inline-flex items-center">
|
||||
<div
|
||||
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
|
||||
>
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
type="transparent"
|
||||
v-if="queuedCount > 0"
|
||||
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-8 rounded-lg bg-destructive-background text-base-foreground hover:bg-destructive-background-hover transition-colors"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueue')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
<i
|
||||
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobFiltersBar
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
@update:selected-workflow-filter="
|
||||
$emit('update:selectedWorkflowFilter', $event)
|
||||
"
|
||||
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<JobGroupsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@@ -57,12 +81,19 @@ import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
import JobGroupsList from './job/JobGroupsList.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -70,14 +101,20 @@ defineProps<{
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
activeJobsCount: number
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
selectedSortMode: JobSortMode
|
||||
displayedJobGroups: JobGroup[]
|
||||
hasFailedJobs: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
(e: 'close'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
|
||||
@@ -36,7 +36,7 @@ const i18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { more: 'More', close: 'Close' },
|
||||
g: { more: 'More' },
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
@@ -95,13 +95,4 @@ describe('QueueOverlayHeader', () => {
|
||||
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits close when close button is clicked', async () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const closeButton = wrapper.get('button[aria-label="Close"]')
|
||||
await closeButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,19 +62,6 @@
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
<IconButton
|
||||
v-tooltip.top="closeTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('g.close')"
|
||||
data-testid="queue-overlay-close-button"
|
||||
@click="onCloseClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--x] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,19 +84,16 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const morePopoverRef = ref<PopoverMethods | null>(null)
|
||||
const closeTooltipConfig = computed(() => buildTooltipConfig(t('g.close')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const onMoreClick = (event: MouseEvent) => {
|
||||
morePopoverRef.value?.toggle(event)
|
||||
}
|
||||
const onCloseClick = () => emit('close')
|
||||
const onClearHistoryFromMenu = () => {
|
||||
morePopoverRef.value?.hide()
|
||||
emit('clearHistory')
|
||||
|
||||
@@ -6,26 +6,45 @@
|
||||
<div
|
||||
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
|
||||
:class="containerClass"
|
||||
data-testid="queue-overlay"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<!-- Expanded state -->
|
||||
<QueueOverlayExpanded
|
||||
v-if="isExpanded"
|
||||
v-model:selected-job-tab="selectedJobTab"
|
||||
v-model:selected-workflow-filter="selectedWorkflowFilter"
|
||||
v-model:selected-sort-mode="selectedSortMode"
|
||||
class="flex-1 min-h-0"
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:active-jobs-count="activeJobsCount"
|
||||
:queued-count="queuedCount"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="openAssetsSidebar"
|
||||
@clear-history="onClearHistoryFromMenu"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@close="closeOverlay"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="inspectJobAsset"
|
||||
/>
|
||||
|
||||
<QueueOverlayActive
|
||||
v-else-if="hasActiveJob"
|
||||
:total-progress-style="totalProgressStyle"
|
||||
:current-node-progress-style="currentNodeProgressStyle"
|
||||
:total-percent-formatted="totalPercentFormatted"
|
||||
:current-node-percent-formatted="currentNodePercentFormatted"
|
||||
:current-node-name="currentNodeName"
|
||||
:running-count="runningCount"
|
||||
:queued-count="queuedCount"
|
||||
:bottom-row-class="bottomRowClass"
|
||||
@interrupt-all="interruptAll"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@view-all-jobs="viewAllJobs"
|
||||
/>
|
||||
|
||||
<QueueOverlayEmpty
|
||||
v-else-if="completionSummary"
|
||||
:summary="completionSummary"
|
||||
@@ -44,6 +63,7 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
@@ -51,9 +71,11 @@ import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -62,11 +84,17 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'expanded'
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = defineProps<{
|
||||
expanded?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -82,6 +110,14 @@ const assetsStore = useAssetsStore()
|
||||
const assetSelectionStore = useAssetSelectionStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const {
|
||||
totalPercentFormatted,
|
||||
currentNodePercentFormatted,
|
||||
totalProgressStyle,
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
@@ -105,12 +141,16 @@ const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
|
||||
|
||||
const overlayState = computed<OverlayState>(() => {
|
||||
if (isExpanded.value) return 'expanded'
|
||||
if (hasActiveJob.value) return 'active'
|
||||
if (hasCompletionSummary.value) return 'empty'
|
||||
return 'hidden'
|
||||
})
|
||||
|
||||
const showBackground = computed(
|
||||
() => overlayState.value === 'expanded' || overlayState.value === 'empty'
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||
@@ -121,6 +161,14 @@ const containerClass = computed(() =>
|
||||
: 'border-transparent bg-transparent shadow-none'
|
||||
)
|
||||
|
||||
const bottomRowClass = computed(
|
||||
() =>
|
||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||
overlayState.value === 'active' && isOverlayHovered.value
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
)
|
||||
const headerTitle = computed(() =>
|
||||
hasActiveJob.value
|
||||
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
@@ -134,7 +182,15 @@ const showConcurrentIndicator = computed(
|
||||
() => concurrentWorkflowCount.value > 1
|
||||
)
|
||||
|
||||
const { orderedTasks, groupedJobItems } = useJobList()
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
} = useJobList()
|
||||
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
|
||||
@@ -153,17 +209,17 @@ const {
|
||||
galleryActiveIndex,
|
||||
galleryItems,
|
||||
onViewItem: openResultGallery
|
||||
} = useResultGallery(() => orderedTasks.value)
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const setExpanded = (expanded: boolean) => {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
const closeOverlay = () => {
|
||||
setExpanded(false)
|
||||
const openExpandedFromEmpty = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const openExpandedFromEmpty = () => {
|
||||
const viewAllJobs = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
@@ -206,6 +262,25 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
})
|
||||
|
||||
const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
const tasks = queueStore.runningTasks
|
||||
const promptIds = tasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
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(promptIds.map((id) => api.deleteItem('queue', id)))
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(promptIds.map((id) => api.interrupt(id)))
|
||||
})
|
||||
|
||||
const showClearHistoryDialog = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'queue-clear-history',
|
||||
|
||||
@@ -104,6 +104,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -121,14 +122,13 @@ const props = defineProps<{
|
||||
workflowId?: string
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const copyAriaLabel = computed(() => t('g.copy'))
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const dialog = useDialogService()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const workflowValue = computed(() => {
|
||||
const wid = props.workflowId
|
||||
@@ -260,22 +260,6 @@ const estimatedFinishInValue = computed(() => {
|
||||
|
||||
type DetailRow = { label: string; value: string; canCopy?: boolean }
|
||||
|
||||
const formatComputeHours = (execMs: number | undefined) => {
|
||||
if (execMs === undefined) return ''
|
||||
const hours = Math.max(0, execMs) / 3600000
|
||||
const formatHours = (value: number) =>
|
||||
new Intl.NumberFormat(locale.value, {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3
|
||||
}).format(value)
|
||||
if (hours > 0 && hours < 0.001) {
|
||||
return t('queue.jobDetails.computeHoursValueLessThan', {
|
||||
hours: formatHours(0.001)
|
||||
})
|
||||
}
|
||||
return t('queue.jobDetails.computeHoursValue', { hours: formatHours(hours) })
|
||||
}
|
||||
|
||||
const baseRows = computed<DetailRow[]>(() => [
|
||||
{ label: t('queue.jobDetails.workflow'), value: workflowValue.value },
|
||||
{ label: t('queue.jobDetails.jobId'), value: jobIdValue.value, canCopy: true }
|
||||
@@ -326,7 +310,8 @@ const extraRows = computed<DetailRow[]>(() => {
|
||||
const generatedOnValue = endTs ? formatClockTime(endTs, locale.value) : ''
|
||||
const totalGenTimeValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue = formatComputeHours(execMs)
|
||||
const computeHoursValue =
|
||||
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
|
||||
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
|
||||
@@ -348,7 +333,8 @@ const extraRows = computed<DetailRow[]>(() => {
|
||||
const execMs: number | undefined = task?.executionTime
|
||||
const failedAfterValue =
|
||||
execMs !== undefined ? formatElapsedTime(execMs) : ''
|
||||
const computeHoursValue = formatComputeHours(execMs)
|
||||
const computeHoursValue =
|
||||
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
|
||||
const rows: DetailRow[] = [
|
||||
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
|
||||
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }
|
||||
|
||||
231
src/components/queue/job/JobFiltersBar.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-2 px-3">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<TextButton
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
|
||||
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
|
||||
:class="[
|
||||
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
|
||||
]"
|
||||
:label="tabLabel(tab)"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<IconButton
|
||||
v-if="showWorkflowFilter"
|
||||
v-tooltip.top="filterTooltipConfig"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
|
||||
@click="onFilterClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-filter] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-if="selectedWorkflowFilter !== 'all'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</IconButton>
|
||||
<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"
|
||||
>
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
"
|
||||
@click="selectWorkflowFilter('all')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'all'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="mx-2 mt-1 h-px" />
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
"
|
||||
@click="selectWorkflowFilter('current')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'current'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
<IconButton
|
||||
v-tooltip.top="sortTooltipConfig"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
|
||||
@click="onSortClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--arrow-up-down] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
<span
|
||||
v-if="selectedSortMode !== 'mostRecent'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</IconButton>
|
||||
<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">
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="sortLabel(mode)"
|
||||
:aria-label="sortLabel(mode)"
|
||||
@click="selectSortMode(mode)"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedSortMode === mode"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div
|
||||
v-if="index < jobSortModes.length - 1"
|
||||
class="mx-2 mt-1 h-px"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.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
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(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'))
|
||||
)
|
||||
|
||||
// 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 as any)?.hide?.()
|
||||
emit('update:selectedWorkflowFilter', value)
|
||||
}
|
||||
|
||||
const onSortClick = (event: Event) => {
|
||||
if (sortPopoverRef.value) {
|
||||
sortPopoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
const selectSortMode = (value: JobSortMode) => {
|
||||
;(sortPopoverRef.value as any)?.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>
|
||||
@@ -64,8 +64,7 @@ export const RunningWithCurrent: Story = {
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 66,
|
||||
progressCurrentPercent: 10,
|
||||
runningNodeName: 'KSampler'
|
||||
progressCurrentPercent: 10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="relative"
|
||||
data-testid="queue-job-item"
|
||||
:data-job-id="props.jobId"
|
||||
:data-job-state="props.state"
|
||||
:data-running-node="props.runningNodeName"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@mouseenter="onRowEnter"
|
||||
@mouseleave="onRowLeave"
|
||||
@contextmenu.stop.prevent="onContextMenu"
|
||||
>
|
||||
<Teleport to="body">
|
||||
@@ -46,99 +42,165 @@
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<div
|
||||
class="relative flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
(props.progressTotalPercent !== undefined ||
|
||||
props.progressCurrentPercent !== undefined)
|
||||
"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
(props.progressTotalPercent !== undefined ||
|
||||
props.progressCurrentPercent !== undefined)
|
||||
"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<div
|
||||
v-if="props.progressTotalPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${props.progressTotalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
v-if="props.progressCurrentPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${props.progressCurrentPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
v-if="props.progressTotalPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${props.progressTotalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
v-if="props.progressCurrentPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${props.progressCurrentPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-1">
|
||||
<div class="relative inline-flex items-center justify-center">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
|
||||
@mouseenter.stop="onIconEnter"
|
||||
@mouseleave.stop="onIconLeave"
|
||||
<div class="relative z-[1] flex items-center gap-1">
|
||||
<div class="relative inline-flex items-center justify-center">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
|
||||
@mouseenter.stop="onIconEnter"
|
||||
@mouseleave.stop="onIconLeave"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
|
||||
>
|
||||
<img
|
||||
v-if="iconImageUrl"
|
||||
:src="iconImageUrl"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
|
||||
>
|
||||
<img
|
||||
v-if="iconImageUrl"
|
||||
:src="iconImageUrl"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="props.title">
|
||||
<slot name="primary">{{ props.title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] shrink-0 pr-2 text-text-secondary">
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="visibleActions.length"
|
||||
class="relative z-[1] flex items-center gap-1 text-text-secondary"
|
||||
>
|
||||
<template v-for="action in visibleActions" :key="action.key">
|
||||
<IconButton
|
||||
v-if="action.type === 'icon'"
|
||||
v-tooltip.top="action.tooltip"
|
||||
:type="action.buttonType"
|
||||
size="sm"
|
||||
:class="actionButtonClass"
|
||||
:aria-label="action.ariaLabel"
|
||||
:data-testid="`job-action-${action.key}`"
|
||||
@click.stop="action.onClick?.($event)"
|
||||
<div class="relative z-[1] min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="props.title">
|
||||
<slot name="primary">{{ props.title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: Refactor action buttons to use a declarative config system.
|
||||
|
||||
Instead of hardcoding button visibility logic in the template, define an array of
|
||||
action button configs with properties like:
|
||||
- icon, label, action, tooltip
|
||||
- visibleStates: JobState[] (which job states show this button)
|
||||
- alwaysVisible: boolean (show without hover)
|
||||
- destructive: boolean (use destructive styling)
|
||||
|
||||
Then render buttons in two groups:
|
||||
1. Always-visible buttons (outside Transition)
|
||||
2. Hover-only buttons (inside Transition)
|
||||
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
|
||||
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-0.5"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-0.5"
|
||||
>
|
||||
<div
|
||||
v-if="isHovered"
|
||||
key="actions"
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<i :class="cn(action.iconClass, 'size-4')" />
|
||||
</IconButton>
|
||||
<TextButton
|
||||
v-else
|
||||
class="h-8 gap-1 rounded-lg bg-modal-card-button-surface px-3 py-0 text-text-primary transition duration-150 ease-in-out hover:opacity-95"
|
||||
type="transparent"
|
||||
:label="action.label"
|
||||
:aria-label="action.ariaLabel"
|
||||
:data-testid="`job-action-${action.key}`"
|
||||
@click.stop="action.onClick?.($event)"
|
||||
/>
|
||||
</template>
|
||||
<IconButton
|
||||
v-if="props.state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="onDeleteClick"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
<TextButton
|
||||
v-else-if="props.state === 'completed'"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
type="transparent"
|
||||
:label="t('menuLabels.View')"
|
||||
:aria-label="t('menuLabels.View')"
|
||||
@click.stop="emit('view')"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="props.showMenu !== undefined ? props.showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="props.state !== 'running'"
|
||||
key="secondary"
|
||||
class="pr-2"
|
||||
>
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<IconButton
|
||||
v-if="props.state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -163,7 +225,6 @@ const props = withDefaults(
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
runningNodeName?: string
|
||||
activeDetailsId?: string | null
|
||||
}>(),
|
||||
{
|
||||
@@ -194,9 +255,6 @@ const { t } = useI18n()
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
const viewTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menuLabels.View'))
|
||||
)
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => props.activeDetailsId === props.jobId)
|
||||
@@ -248,11 +306,6 @@ const onIconLeave = () => scheduleHidePreview()
|
||||
const onPreviewEnter = () => scheduleShowPreview()
|
||||
const onPreviewLeave = () => scheduleHidePreview()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
})
|
||||
|
||||
const popoverPosition = ref<{ top: number; right: number } | null>(null)
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
@@ -270,32 +323,6 @@ const isAnyPopoverVisible = computed(
|
||||
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
|
||||
)
|
||||
|
||||
type ActionVariant = 'neutral' | 'destructive'
|
||||
type ActionMode = 'hover' | 'always'
|
||||
|
||||
type BaseActionConfig = {
|
||||
key: string
|
||||
variant: ActionVariant
|
||||
mode: ActionMode
|
||||
ariaLabel: string
|
||||
tooltip?: ReturnType<typeof buildTooltipConfig>
|
||||
isVisible: () => boolean
|
||||
onClick?: (event?: MouseEvent) => void
|
||||
}
|
||||
|
||||
type IconActionConfig = BaseActionConfig & {
|
||||
type: 'icon'
|
||||
iconClass: string
|
||||
buttonType: 'secondary' | 'destructive'
|
||||
}
|
||||
|
||||
type TextActionConfig = BaseActionConfig & {
|
||||
type: 'text'
|
||||
label: string
|
||||
}
|
||||
|
||||
type ActionConfig = IconActionConfig | TextActionConfig
|
||||
|
||||
watch(
|
||||
isAnyPopoverVisible,
|
||||
(visible) => {
|
||||
@@ -310,114 +337,6 @@ watch(
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
})
|
||||
|
||||
const resolvedShowMenu = computed(() => props.showMenu ?? true)
|
||||
|
||||
const baseActions = computed<ActionConfig[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'menu',
|
||||
type: 'icon',
|
||||
variant: 'neutral',
|
||||
buttonType: 'secondary',
|
||||
mode: 'hover',
|
||||
iconClass: 'icon-[lucide--more-horizontal]',
|
||||
ariaLabel: t('g.more'),
|
||||
tooltip: moreTooltipConfig.value,
|
||||
isVisible: () => resolvedShowMenu.value,
|
||||
onClick: (event?: MouseEvent) => {
|
||||
if (event) emit('menu', event)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
type: 'icon',
|
||||
variant: 'destructive',
|
||||
buttonType: 'destructive',
|
||||
mode: 'hover',
|
||||
iconClass: 'icon-[lucide--trash-2]',
|
||||
ariaLabel: t('g.delete'),
|
||||
tooltip: deleteTooltipConfig.value,
|
||||
isVisible: () => props.state === 'failed' && computedShowClear.value,
|
||||
onClick: () => {
|
||||
onRowLeave()
|
||||
emit('delete')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'cancel-hover',
|
||||
type: 'icon',
|
||||
variant: 'destructive',
|
||||
buttonType: 'destructive',
|
||||
mode: 'hover',
|
||||
iconClass: 'icon-[lucide--x]',
|
||||
ariaLabel: t('g.cancel'),
|
||||
tooltip: cancelTooltipConfig.value,
|
||||
isVisible: () =>
|
||||
props.state !== 'completed' &&
|
||||
props.state !== 'running' &&
|
||||
props.state !== 'failed' &&
|
||||
computedShowClear.value,
|
||||
onClick: () => {
|
||||
onRowLeave()
|
||||
emit('cancel')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'view',
|
||||
type: 'icon',
|
||||
variant: 'neutral',
|
||||
buttonType: 'secondary',
|
||||
mode: 'hover',
|
||||
iconClass: 'icon-[lucide--zoom-in]',
|
||||
ariaLabel: t('menuLabels.View'),
|
||||
tooltip: viewTooltipConfig.value,
|
||||
isVisible: () => props.state === 'completed',
|
||||
onClick: () => emit('view')
|
||||
},
|
||||
{
|
||||
key: 'cancel-running',
|
||||
type: 'icon',
|
||||
variant: 'destructive',
|
||||
buttonType: 'destructive',
|
||||
mode: 'always',
|
||||
iconClass: 'icon-[lucide--x]',
|
||||
ariaLabel: t('g.cancel'),
|
||||
tooltip: cancelTooltipConfig.value,
|
||||
isVisible: () => props.state === 'running' && computedShowClear.value,
|
||||
onClick: () => {
|
||||
onRowLeave()
|
||||
emit('cancel')
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const visibleActions = computed(() =>
|
||||
baseActions.value.filter(
|
||||
(action) =>
|
||||
action.isVisible() &&
|
||||
(action.mode === 'always' || (action.mode === 'hover' && isHovered.value))
|
||||
)
|
||||
)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
onRowEnter()
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
onRowLeave()
|
||||
}
|
||||
|
||||
const actionButtonClass =
|
||||
'h-8 min-w-8 gap-1 rounded-lg text-text-primary transition duration-150 ease-in-out hover:opacity-95'
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (props.iconName) return props.iconName
|
||||
return iconForJobState(props.state)
|
||||
@@ -430,7 +349,25 @@ const shouldSpin = computed(
|
||||
!props.iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (props.showClear !== undefined) return props.showClear
|
||||
return props.state !== 'completed'
|
||||
})
|
||||
|
||||
const emitDetailsLeave = () => emit('details-leave', props.jobId)
|
||||
|
||||
const onCancelClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const onDeleteClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
if (resolvedShowMenu.value) emit('menu', event)
|
||||
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,10 +59,12 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<Divider type="dashed" class="m-2" />
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<div v-else-if="!loading && !displayAssets.length">
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!displayAssets.length">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
@@ -75,6 +77,7 @@
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<VirtualGrid
|
||||
:items="mediaAssetsWithKey"
|
||||
|
||||
@@ -20,8 +20,7 @@ import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -48,7 +47,6 @@ export interface SafeWidgetData {
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
isDOMWidget?: boolean
|
||||
controlWidget?: SafeControlWidget
|
||||
borderStyle?: string
|
||||
}
|
||||
|
||||
@@ -86,17 +84,6 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
if (!cagWidget) return
|
||||
return {
|
||||
value: normalizeControlOption(cagWidget.value),
|
||||
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||
}
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
@@ -135,8 +122,7 @@ export function safeWidgetMapper(
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
controlWidget: getControlWidget(widget)
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -231,36 +231,6 @@ const ltxvPricingCalculator = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const klingVideoWithAudioPricingCalculator: PricingFunction = (
|
||||
node: LGraphNode
|
||||
): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !generateAudioWidget) {
|
||||
return '$0.35-1.40/Run (varies with duration & audio)'
|
||||
}
|
||||
|
||||
const duration = String(durationWidget.value)
|
||||
const generateAudio =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
|
||||
if (duration === '5') {
|
||||
return generateAudio ? '$0.70/Run' : '$0.35/Run'
|
||||
}
|
||||
|
||||
if (duration === '10') {
|
||||
return generateAudio ? '$1.40/Run' : '$0.70/Run'
|
||||
}
|
||||
|
||||
// Fallback for unexpected duration values
|
||||
return '$0.35-1.40/Run (varies with duration & audio)'
|
||||
}
|
||||
|
||||
// ---- constants ----
|
||||
const SORA_SIZES = {
|
||||
BASIC: new Set(['720x1280', '1280x720']),
|
||||
@@ -774,12 +744,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingOmniProImageNode: {
|
||||
displayPrice: '$0.028/Run'
|
||||
},
|
||||
KlingTextToVideoWithAudio: {
|
||||
displayPrice: klingVideoWithAudioPricingCalculator
|
||||
},
|
||||
KlingImageToVideoWithAudio: {
|
||||
displayPrice: klingVideoWithAudioPricingCalculator
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1967,8 +1931,6 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingTextToVideoWithAudio: ['duration', 'generate_audio'],
|
||||
KlingImageToVideoWithAudio: ['duration', 'generate_audio'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
export function useCurrentNodeName() {
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
const node = executionStore.executingNode
|
||||
if (!node) return t('g.emDash')
|
||||
const title = (node.title ?? '').toString().trim()
|
||||
if (title) return title
|
||||
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
})
|
||||
|
||||
return { currentNodeName }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
@@ -15,9 +16,17 @@ import {
|
||||
isToday,
|
||||
isYesterday
|
||||
} from '@/utils/dateTimeUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
/** Tabs for job list filtering */
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
export type JobTab = (typeof jobTabs)[number]
|
||||
|
||||
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
|
||||
export type JobSortMode = (typeof jobSortModes)[number]
|
||||
|
||||
/**
|
||||
* UI item in the job list. Mirrors data previously prepared inline.
|
||||
*/
|
||||
@@ -80,12 +89,13 @@ type TaskWithState = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the reactive job list and grouped view for the queue overlay.
|
||||
* Builds the reactive job list, filters, and grouped view for the queue overlay.
|
||||
*/
|
||||
export function useJobList() {
|
||||
const { t, locale } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const seenPendingIds = ref<Set<string>>(new Set())
|
||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||
@@ -158,7 +168,6 @@ export function useJobList() {
|
||||
})
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
const { currentNodeName } = useCurrentNodeName()
|
||||
|
||||
const relativeTimeFormatter = computed(() => {
|
||||
const localeValue = locale.value
|
||||
@@ -174,7 +183,21 @@ export function useJobList() {
|
||||
const isJobInitializing = (promptId: string | number | undefined) =>
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const orderedTasks = computed<TaskItemImpl[]>(() => {
|
||||
const currentNodeName = computed(() => {
|
||||
const node = executionStore.executingNode
|
||||
if (!node) return t('g.emDash')
|
||||
const title = (node.title ?? '').toString().trim()
|
||||
if (title) return title
|
||||
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
})
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
|
||||
const allTasksSorted = computed<TaskItemImpl[]>(() => {
|
||||
const all = [
|
||||
...queueStore.pendingTasks,
|
||||
...queueStore.runningTasks,
|
||||
@@ -184,14 +207,50 @@ export function useJobList() {
|
||||
})
|
||||
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
orderedTasks.value.map((task) => ({
|
||||
allTasksSorted.value.map((task) => ({
|
||||
task,
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
|
||||
}))
|
||||
)
|
||||
|
||||
const hasFailedJobs = computed(() =>
|
||||
tasksWithJobState.value.some(({ state }) => state === 'failed')
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasFailedJobs.value,
|
||||
(hasFailed) => {
|
||||
if (!hasFailed && selectedJobTab.value === 'Failed') {
|
||||
selectedJobTab.value = 'All'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredTaskEntries = computed<TaskWithState[]>(() => {
|
||||
let entries = tasksWithJobState.value
|
||||
if (selectedJobTab.value === 'Completed') {
|
||||
entries = entries.filter(({ state }) => state === 'completed')
|
||||
} else if (selectedJobTab.value === 'Failed') {
|
||||
entries = entries.filter(({ state }) => state === 'failed')
|
||||
}
|
||||
|
||||
if (selectedWorkflowFilter.value === 'current') {
|
||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||
if (!activeId) return []
|
||||
entries = entries.filter(({ task }) => {
|
||||
const wid = task.workflow?.id
|
||||
return !!wid && wid === activeId
|
||||
})
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const filteredTasks = computed<TaskItemImpl[]>(() =>
|
||||
filteredTaskEntries.value.map(({ task }) => task)
|
||||
)
|
||||
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return tasksWithJobState.value.map(({ task, state }) => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
@@ -245,7 +304,7 @@ export function useJobList() {
|
||||
const groups: JobGroup[] = []
|
||||
const index = new Map<string, number>()
|
||||
const localeValue = locale.value
|
||||
for (const { task, state } of tasksWithJobState.value) {
|
||||
for (const { task, state } of filteredTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp
|
||||
@@ -271,11 +330,29 @@ export function useJobList() {
|
||||
if (ji) groups[groupIdx].items.push(ji)
|
||||
}
|
||||
|
||||
if (selectedSortMode.value === 'totalGenerationTime') {
|
||||
const valueOrDefault = (value: JobListItem['executionTimeMs']) =>
|
||||
typeof value === 'number' && !Number.isNaN(value) ? value : -1
|
||||
const sortByExecutionTimeDesc = (a: JobListItem, b: JobListItem) =>
|
||||
valueOrDefault(b.executionTimeMs) - valueOrDefault(a.executionTimeMs)
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.items.sort(sortByExecutionTimeDesc)
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
return {
|
||||
orderedTasks,
|
||||
// filters/state
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
// data sources
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
jobItems,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
|
||||
130
src/core/graph/state/graphStateStore.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useGraphStateStore } from './graphStateStore'
|
||||
|
||||
describe('graphStateStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('execute SetNodeError command', () => {
|
||||
it('sets hasError on new node', () => {
|
||||
const store = useGraphStateStore()
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '123',
|
||||
hasError: true
|
||||
})
|
||||
|
||||
expect(store.getNodeState('123')?.hasError).toBe(true)
|
||||
})
|
||||
|
||||
it('updates hasError on existing node', () => {
|
||||
const store = useGraphStateStore()
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '123',
|
||||
hasError: true
|
||||
})
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '123',
|
||||
hasError: false
|
||||
})
|
||||
|
||||
expect(store.getNodeState('123')?.hasError).toBe(false)
|
||||
})
|
||||
|
||||
it('handles subgraph node locator IDs', () => {
|
||||
const store = useGraphStateStore()
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: 'uuid-123:456',
|
||||
hasError: true
|
||||
})
|
||||
|
||||
expect(store.getNodeState('uuid-123:456')?.hasError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute ClearAllErrors command', () => {
|
||||
it('clears all error flags', () => {
|
||||
const store = useGraphStateStore()
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '1',
|
||||
hasError: true
|
||||
})
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '2',
|
||||
hasError: true
|
||||
})
|
||||
|
||||
store.execute({ type: 'ClearAllErrors', version: 1 })
|
||||
|
||||
expect(store.getNodeState('1')?.hasError).toBe(false)
|
||||
expect(store.getNodeState('2')?.hasError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodesWithErrors', () => {
|
||||
it('returns only nodes with errors', () => {
|
||||
const store = useGraphStateStore()
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '1',
|
||||
hasError: true
|
||||
})
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '2',
|
||||
hasError: false
|
||||
})
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '3',
|
||||
hasError: true
|
||||
})
|
||||
|
||||
const nodesWithErrors = store.getNodesWithErrors()
|
||||
|
||||
expect(nodesWithErrors).toHaveLength(2)
|
||||
expect(nodesWithErrors).toContain('1')
|
||||
expect(nodesWithErrors).toContain('3')
|
||||
expect(nodesWithErrors).not.toContain('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stateRef reactivity', () => {
|
||||
it('increments revision on command execution', () => {
|
||||
const store = useGraphStateStore()
|
||||
const initialRevision = store.stateRef
|
||||
|
||||
store.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: '1',
|
||||
hasError: true
|
||||
})
|
||||
|
||||
expect(store.stateRef).not.toBe(initialRevision)
|
||||
})
|
||||
})
|
||||
})
|
||||
79
src/core/graph/state/graphStateStore.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { customRef } from 'vue'
|
||||
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface NodeState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
interface SetNodeErrorCommand {
|
||||
type: 'SetNodeError'
|
||||
version: 1
|
||||
nodeId: NodeLocatorId
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
interface ClearAllErrorsCommand {
|
||||
type: 'ClearAllErrors'
|
||||
version: 1
|
||||
}
|
||||
|
||||
type GraphStateCommand = SetNodeErrorCommand | ClearAllErrorsCommand
|
||||
|
||||
export const useGraphStateStore = defineStore('graphState', () => {
|
||||
const nodes = new Map<NodeLocatorId, NodeState>()
|
||||
|
||||
let revision = 0
|
||||
const stateRef = customRef<number>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
return revision
|
||||
},
|
||||
set() {
|
||||
revision++
|
||||
trigger()
|
||||
}
|
||||
}))
|
||||
|
||||
const execute = (command: GraphStateCommand): void => {
|
||||
switch (command.type) {
|
||||
case 'SetNodeError': {
|
||||
const existing = nodes.get(command.nodeId)
|
||||
if (existing) {
|
||||
existing.hasError = command.hasError
|
||||
} else {
|
||||
nodes.set(command.nodeId, { hasError: command.hasError })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ClearAllErrors': {
|
||||
for (const state of nodes.values()) {
|
||||
state.hasError = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
stateRef.value = revision + 1
|
||||
}
|
||||
|
||||
const getNodeState = (nodeId: NodeLocatorId): NodeState | undefined => {
|
||||
return nodes.get(nodeId)
|
||||
}
|
||||
|
||||
const getNodesWithErrors = (): NodeLocatorId[] => {
|
||||
const result: NodeLocatorId[] = []
|
||||
for (const [nodeId, state] of nodes) {
|
||||
if (state.hasError) result.push(nodeId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
stateRef,
|
||||
nodes,
|
||||
execute,
|
||||
getNodeState,
|
||||
getNodesWithErrors
|
||||
}
|
||||
})
|
||||
45
src/core/graph/state/useGraphErrorState.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
forEachNode,
|
||||
forEachSubgraphNode,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { useGraphStateStore } from './graphStateStore'
|
||||
|
||||
const propagateErrorToParents = (node: LGraphNode): void => {
|
||||
const subgraph = node.graph
|
||||
if (!subgraph || subgraph.isRootGraph) return
|
||||
|
||||
forEachSubgraphNode(app.rootGraph, subgraph.id, (subgraphNode) => {
|
||||
subgraphNode.has_errors = true
|
||||
propagateErrorToParents(subgraphNode)
|
||||
})
|
||||
}
|
||||
|
||||
export const useGraphErrorState = () => {
|
||||
const store = useGraphStateStore()
|
||||
|
||||
watch(
|
||||
() => store.stateRef,
|
||||
() => {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
forEachNode(app.rootGraph, (node) => {
|
||||
node.has_errors = false
|
||||
})
|
||||
|
||||
for (const locatorId of store.getNodesWithErrors()) {
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node) continue
|
||||
|
||||
node.has_errors = true
|
||||
propagateErrorToParents(node)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -707,7 +707,6 @@
|
||||
"title": "Queue Progress",
|
||||
"total": "Total: {percent}",
|
||||
"colonPercent": ": {percent}",
|
||||
"inlineTotalLabel": "Total",
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"running": "running",
|
||||
@@ -717,8 +716,6 @@
|
||||
"showAssets": "Show assets",
|
||||
"showAssetsPanel": "Show assets panel",
|
||||
"queuedSuffix": "queued",
|
||||
"toggleLabel": "{count} queued",
|
||||
"clearQueue": "Clear queue",
|
||||
"clearQueued": "Clear queued",
|
||||
"clearHistory": "Clear job queue history",
|
||||
"filterJobs": "Filter jobs",
|
||||
@@ -1040,8 +1037,6 @@
|
||||
"generatedOn": "Generated on",
|
||||
"totalGenerationTime": "Total generation time",
|
||||
"computeHoursUsed": "Compute hours used",
|
||||
"computeHoursValue": "{hours} hours",
|
||||
"computeHoursValueLessThan": "<{hours} hours",
|
||||
"failedAfter": "Failed after",
|
||||
"errorMessage": "Error message",
|
||||
"report": "Report",
|
||||
@@ -2057,24 +2052,6 @@
|
||||
"placeholderVideo": "Select video...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media..."
|
||||
},
|
||||
"numberControl": {
|
||||
"header": {
|
||||
"prefix": "Automatically update the value",
|
||||
"after": "AFTER",
|
||||
"before": "BEFORE",
|
||||
"postfix": "running the workflow:"
|
||||
},
|
||||
"linkToGlobal": "Link to",
|
||||
"linkToGlobalSeed": "Global Value",
|
||||
"linkToGlobalDesc": "Unique value linked to the Global Value's control setting",
|
||||
"randomize": "Randomize Value",
|
||||
"randomizeDesc": "Shuffles the value randomly after each generation",
|
||||
"increment": "Increment Value",
|
||||
"incrementDesc": "Adds 1 to the value number",
|
||||
"decrement": "Decrement Value",
|
||||
"decrementDesc": "Subtracts 1 from the value number",
|
||||
"editSettings": "Edit control settings"
|
||||
}
|
||||
},
|
||||
"widgetFileUpload": {
|
||||
@@ -2449,4 +2426,4 @@
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,8 +172,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
options: widgetOptions,
|
||||
callback: widget.callback,
|
||||
spec: widget.spec,
|
||||
borderStyle: widget.borderStyle,
|
||||
controlWidget: widget.controlWidget
|
||||
borderStyle: widget.borderStyle
|
||||
}
|
||||
|
||||
function updateHandler(value: WidgetValue) {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
import { NumberControlMode } from '../composables/useStepperControl'
|
||||
|
||||
type ControlOption = {
|
||||
description: string
|
||||
mode: NumberControlMode
|
||||
icon?: string
|
||||
text?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const popover = ref()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
defineExpose({ toggle })
|
||||
|
||||
const ENABLE_LINK_TO_GLOBAL = false
|
||||
|
||||
const controlOptions: ControlOption[] = [
|
||||
...(ENABLE_LINK_TO_GLOBAL
|
||||
? ([
|
||||
{
|
||||
mode: NumberControlMode.LINK_TO_GLOBAL,
|
||||
icon: 'pi pi-link',
|
||||
title: 'linkToGlobal',
|
||||
description: 'linkToGlobalDesc'
|
||||
} satisfies ControlOption
|
||||
] as ControlOption[])
|
||||
: []),
|
||||
{
|
||||
mode: NumberControlMode.RANDOMIZE,
|
||||
icon: 'icon-[lucide--shuffle]',
|
||||
title: 'randomize',
|
||||
description: 'randomizeDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.INCREMENT,
|
||||
text: '+1',
|
||||
title: 'increment',
|
||||
description: 'incrementDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.DECREMENT,
|
||||
text: '-1',
|
||||
title: 'decrement',
|
||||
description: 'decrementDesc'
|
||||
}
|
||||
]
|
||||
|
||||
const widgetControlMode = computed(() =>
|
||||
settingStore.get('Comfy.WidgetControlMode')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
controlMode: NumberControlMode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:controlMode': [mode: NumberControlMode]
|
||||
}>()
|
||||
|
||||
const handleToggle = (mode: NumberControlMode) => {
|
||||
if (props.controlMode === mode) return
|
||||
emit('update:controlMode', mode)
|
||||
}
|
||||
|
||||
const isActive = (mode: NumberControlMode) => {
|
||||
return props.controlMode === mode
|
||||
}
|
||||
|
||||
const handleEditSettings = () => {
|
||||
popover.value.hide()
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
ref="popover"
|
||||
class="bg-interface-panel-surface border border-interface-stroke rounded-lg"
|
||||
>
|
||||
<div class="w-113 max-w-md p-4 space-y-4">
|
||||
<div class="text-sm text-muted-foreground leading-tight">
|
||||
{{ $t('widgets.numberControl.header.prefix') }}
|
||||
<span class="text-base-foreground font-medium">
|
||||
{{
|
||||
widgetControlMode === 'before'
|
||||
? $t('widgets.numberControl.header.before')
|
||||
: $t('widgets.numberControl.header.after')
|
||||
}}
|
||||
</span>
|
||||
{{ $t('widgets.numberControl.header.postfix') }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="option in controlOptions"
|
||||
:key="option.mode"
|
||||
class="flex items-center justify-between py-2 gap-7"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
|
||||
>
|
||||
<i
|
||||
v-if="option.icon"
|
||||
:class="option.icon"
|
||||
class="text-base text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="option.text"
|
||||
class="text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ option.text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div
|
||||
class="text-sm font-normal text-base-foreground leading-tight"
|
||||
>
|
||||
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
|
||||
{{ $t('widgets.numberControl.linkToGlobal') }}
|
||||
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t(`widgets.numberControl.${option.title}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-normal text-muted-foreground leading-tight"
|
||||
>
|
||||
{{ $t(`widgets.numberControl.${option.description}`) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
:model-value="isActive(option.mode)"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="handleToggle(option.mode)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border-subtle"></div>
|
||||
<Button
|
||||
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
|
||||
@click="handleEditSettings"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<i class="pi pi-cog text-xs text-muted-foreground" />
|
||||
<span class="font-normal text-base-foreground">{{
|
||||
$t('widgets.numberControl.editSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,31 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const hasControlAfterGenerate = computed(() => {
|
||||
return !!props.widget.controlWidget
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="
|
||||
hasControlAfterGenerate
|
||||
? WidgetInputNumberWithControl
|
||||
: widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
v-model="modelValue"
|
||||
:widget="widget"
|
||||
|
||||
@@ -89,11 +89,8 @@ const buttonTooltip = computed(() => {
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="{
|
||||
root: {
|
||||
class: cn(
|
||||
'[&>input]:bg-transparent [&>input]:border-0',
|
||||
'[&>input]:truncate [&>input]:min-w-[4ch]',
|
||||
$slots.default && '[&>input]:pr-7'
|
||||
)
|
||||
class:
|
||||
'[&>input]:bg-transparent [&>input]:border-0 [&>input]:truncate [&>input]:min-w-[4ch]'
|
||||
},
|
||||
decrementButton: {
|
||||
class: 'w-8 border-0'
|
||||
@@ -110,9 +107,6 @@ const buttonTooltip = computed(() => {
|
||||
<span class="pi pi-minus text-sm" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
|
||||
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
@@ -57,7 +56,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => {
|
||||
}
|
||||
|
||||
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
||||
if (newValue !== undefined) {
|
||||
if (newValue) {
|
||||
updateLocalValue([newValue])
|
||||
return
|
||||
}
|
||||
@@ -68,11 +67,33 @@ const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const p = widget.options?.precision
|
||||
const precision = typeof p === 'number' && p >= 0 ? p : undefined
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = widget.options?.precision
|
||||
// Treat negative or non-numeric precision as undefined
|
||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
||||
})
|
||||
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = useNumberStepCalculation(widget.options, precision, true)
|
||||
const stepValue = computed(() => {
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (widget.options?.step2 !== undefined) {
|
||||
return widget.options.step2
|
||||
}
|
||||
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (precision.value === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return 1 / Math.pow(10, precision.value)
|
||||
})
|
||||
|
||||
const sliderNumberPt = useNumberWidgetButtonPt({
|
||||
roundedLeft: true,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { NumberControlMode } from '../composables/useStepperControl'
|
||||
import { useStepperControl } from '../composables/useStepperControl'
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
|
||||
const NumberControlPopover = defineAsyncComponent(
|
||||
() => import('./NumberControlPopover.vue')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const popover = ref()
|
||||
|
||||
const handleControlChange = (newValue: number) => {
|
||||
modelValue.value = newValue
|
||||
}
|
||||
|
||||
const { controlMode, controlButtonIcon } = useStepperControl(
|
||||
modelValue,
|
||||
{
|
||||
...props.widget.options,
|
||||
onChange: handleControlChange
|
||||
},
|
||||
props.widget.controlWidget!.value
|
||||
)
|
||||
|
||||
const setControlMode = (mode: NumberControlMode) => {
|
||||
controlMode.value = mode
|
||||
props.widget.controlWidget!.update(mode)
|
||||
}
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative grid grid-cols-subgrid">
|
||||
<WidgetInputNumberInput
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
class="grid grid-cols-subgrid col-span-2"
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="small"
|
||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||
@click="togglePopover"
|
||||
>
|
||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
|
||||
</Button>
|
||||
</WidgetInputNumberInput>
|
||||
<NumberControlPopover
|
||||
ref="popover"
|
||||
:control-mode
|
||||
@update:control-mode="setControlMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -146,9 +146,11 @@ const outputItems = computed<DropdownItem[]>(() => {
|
||||
})
|
||||
|
||||
const allItems = computed<DropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData) {
|
||||
return assetData.dropdownItems.value
|
||||
}
|
||||
return [...inputItems.value, ...outputItems.value]
|
||||
})
|
||||
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
if (props.isAssetMode) {
|
||||
return allItems.value
|
||||
@@ -161,7 +163,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
return outputItems.value
|
||||
case 'all':
|
||||
default:
|
||||
return [...inputItems.value, ...outputItems.value]
|
||||
return allItems.value
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const hideLayoutField = inject<boolean>('hideLayoutField', false)
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'cursor-default min-w-0 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
'cursor-default min-w-0 rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
|
||||
@@ -13,16 +13,16 @@ import {
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -69,16 +69,6 @@ const addMultiSelectWidget = (
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
// TODO: Add remote support to multi-select widget
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
const round = this.options.round
|
||||
@@ -57,7 +55,7 @@ export const useFloatWidget = () => {
|
||||
|
||||
/** Assertion {@link inputSpec.default} */
|
||||
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
||||
const widget = node.addWidget(
|
||||
return node.addWidget(
|
||||
widgetType,
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
@@ -75,20 +73,6 @@ export const useFloatWidget = () => {
|
||||
precision
|
||||
}
|
||||
)
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
const controlWidget = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [controlWidget]
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
// For integers, always round to the nearest step
|
||||
@@ -69,10 +69,14 @@ export const useIntWidget = () => {
|
||||
|
||||
const controlAfterGenerate =
|
||||
inputSpec.control_after_generate ??
|
||||
/**
|
||||
* Compatibility with legacy node convention. Int input with name
|
||||
* 'seed' or 'noise_seed' get automatically added a control widget.
|
||||
*/
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const controlWidget = addValueControlWidget(
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
@@ -80,7 +84,7 @@ export const useIntWidget = () => {
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [controlWidget]
|
||||
widget.linkedWidgets = [seedControl]
|
||||
}
|
||||
|
||||
return widget
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface NumberWidgetOptions {
|
||||
step2?: number
|
||||
precision?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared composable for calculating step values in number input widgets
|
||||
* Handles both explicit step2 values and precision-derived steps
|
||||
*/
|
||||
export function useNumberStepCalculation(
|
||||
options: NumberWidgetOptions | undefined,
|
||||
precisionArg: MaybeRefOrGetter<number | undefined>,
|
||||
returnUndefinedForDefault = false
|
||||
) {
|
||||
return computed(() => {
|
||||
const precision = toValue(precisionArg)
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (options?.step2 !== undefined) {
|
||||
return Number(options.step2)
|
||||
}
|
||||
|
||||
if (precision === undefined) {
|
||||
return returnUndefinedForDefault ? undefined : 0
|
||||
}
|
||||
|
||||
if (precision === 0) return 1
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
const step = 1 / Math.pow(10, precision)
|
||||
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
|
||||
})
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
|
||||
import { numberControlRegistry } from '../services/NumberControlRegistry'
|
||||
|
||||
export enum NumberControlMode {
|
||||
FIXED = 'fixed',
|
||||
INCREMENT = 'increment',
|
||||
DECREMENT = 'decrement',
|
||||
RANDOMIZE = 'randomize',
|
||||
LINK_TO_GLOBAL = 'linkToGlobal'
|
||||
}
|
||||
|
||||
interface StepperControlOptions {
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
step2?: number
|
||||
onChange?: (value: number) => void
|
||||
}
|
||||
|
||||
function convertToEnum(str?: ControlOptions): NumberControlMode {
|
||||
switch (str) {
|
||||
case 'fixed':
|
||||
return NumberControlMode.FIXED
|
||||
case 'increment':
|
||||
return NumberControlMode.INCREMENT
|
||||
case 'decrement':
|
||||
return NumberControlMode.DECREMENT
|
||||
case 'randomize':
|
||||
return NumberControlMode.RANDOMIZE
|
||||
}
|
||||
return NumberControlMode.RANDOMIZE
|
||||
}
|
||||
|
||||
function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
|
||||
return computed(() => {
|
||||
switch (controlMode.value) {
|
||||
case NumberControlMode.INCREMENT:
|
||||
return 'pi pi-plus'
|
||||
case NumberControlMode.DECREMENT:
|
||||
return 'pi pi-minus'
|
||||
case NumberControlMode.FIXED:
|
||||
return 'icon-[lucide--pencil-off]'
|
||||
case NumberControlMode.LINK_TO_GLOBAL:
|
||||
return 'pi pi-link'
|
||||
default:
|
||||
return 'icon-[lucide--shuffle]'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useStepperControl(
|
||||
modelValue: Ref<number>,
|
||||
options: StepperControlOptions,
|
||||
defaultValue?: ControlOptions
|
||||
) {
|
||||
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
|
||||
const controlId = Symbol('numberControl')
|
||||
|
||||
const applyControl = () => {
|
||||
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
|
||||
const safeMax = Math.min(2 ** 50, max)
|
||||
const safeMin = Math.max(-(2 ** 50), min)
|
||||
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
|
||||
const actualStep = step2 !== undefined ? step2 : step
|
||||
|
||||
let newValue: number
|
||||
switch (controlMode.value) {
|
||||
case NumberControlMode.FIXED:
|
||||
// Do nothing - keep current value
|
||||
return
|
||||
case NumberControlMode.INCREMENT:
|
||||
newValue = Math.min(safeMax, modelValue.value + actualStep)
|
||||
break
|
||||
case NumberControlMode.DECREMENT:
|
||||
newValue = Math.max(safeMin, modelValue.value - actualStep)
|
||||
break
|
||||
case NumberControlMode.RANDOMIZE:
|
||||
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue)
|
||||
} else {
|
||||
modelValue.value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// Register with singleton registry
|
||||
onMounted(() => {
|
||||
numberControlRegistry.register(controlId, applyControl)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
numberControlRegistry.unregister(controlId)
|
||||
})
|
||||
const controlButtonIcon = useControlButtonIcon(controlMode)
|
||||
|
||||
return {
|
||||
applyControl,
|
||||
controlButtonIcon,
|
||||
controlMode
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
/**
|
||||
* Registry for managing Vue number controls with deterministic execution timing.
|
||||
* Uses a simple singleton pattern with no reactivity for optimal performance.
|
||||
*/
|
||||
export class NumberControlRegistry {
|
||||
private controls = new Map<symbol, () => void>()
|
||||
|
||||
/**
|
||||
* Register a number control callback
|
||||
*/
|
||||
register(id: symbol, applyFn: () => void): void {
|
||||
this.controls.set(id, applyFn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a number control callback
|
||||
*/
|
||||
unregister(id: symbol): void {
|
||||
this.controls.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all registered controls for the given phase
|
||||
*/
|
||||
executeControls(phase: 'before' | 'after'): void {
|
||||
const settingStore = useSettingStore()
|
||||
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
|
||||
for (const applyFn of this.controls.values()) {
|
||||
applyFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of registered controls (for testing)
|
||||
*/
|
||||
getControlCount(): number {
|
||||
return this.controls.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered controls (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.controls.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
export const numberControlRegistry = new NumberControlRegistry()
|
||||
|
||||
/**
|
||||
* Public API function to execute number controls
|
||||
*/
|
||||
export function executeNumberControls(phase: 'before' | 'after'): void {
|
||||
numberControlRegistry.executeControls(phase)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
type NodeId,
|
||||
isSubgraphDefinition
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
@@ -48,12 +47,14 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useGraphErrorState } from '@/core/graph/state/useGraphErrorState'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useGraphStateStore } from '@/core/graph/state/graphStateStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
findLegacyRerouteNodes,
|
||||
noNativeReroutes
|
||||
} from '@/utils/migration/migrateReroute'
|
||||
import { collectMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
|
||||
@@ -765,6 +767,8 @@ export class ComfyApp {
|
||||
void useSubgraphStore().fetchSubgraphs()
|
||||
await useExtensionService().loadExtensions()
|
||||
|
||||
useGraphErrorState()
|
||||
|
||||
this.addProcessKeyHandler()
|
||||
this.addConfigureHandler()
|
||||
this.addApiUpdateHandlers()
|
||||
@@ -1232,6 +1236,23 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const graphStateStore = useGraphStateStore()
|
||||
const missingNodes = collectMissingNodes(
|
||||
this.rootGraph,
|
||||
useNodeDefStore().nodeDefsByName
|
||||
)
|
||||
for (const node of missingNodes) {
|
||||
const locatorId = node.graph?.isRootGraph
|
||||
? String(node.id)
|
||||
: `${node.graph?.id}:${node.id}`
|
||||
graphStateStore.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: locatorId,
|
||||
hasError: true
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.loadWorkflowTitle'),
|
||||
@@ -1359,7 +1380,6 @@ export class ComfyApp {
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
|
||||
})
|
||||
executeNumberControls('before')
|
||||
|
||||
const p = await this.graphToPrompt(this.rootGraph)
|
||||
const queuedNodes = collectAllNodes(this.rootGraph)
|
||||
@@ -1404,7 +1424,6 @@ export class ComfyApp {
|
||||
// Allow widgets to run callbacks after a prompt has been queued
|
||||
// e.g. random seed after every gen
|
||||
executeWidgetsCallback(queuedNodes, 'afterQueued')
|
||||
executeNumberControls('after')
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
@@ -1471,21 +1490,7 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
try {
|
||||
const promptObj =
|
||||
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
|
||||
if (this.isApiJson(promptObj)) {
|
||||
this.loadApiJson(promptObj, fileName)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse prompt:', err)
|
||||
}
|
||||
// Fall through to parameters as a last resort
|
||||
}
|
||||
|
||||
// Use parameters strictly as the final fallback
|
||||
// Use parameters as fallback when no workflow exists
|
||||
if (parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
@@ -1498,25 +1503,18 @@ export class ComfyApp {
|
||||
return
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
|
||||
this.loadApiJson(promptObj, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
|
||||
// @deprecated
|
||||
isApiJson(data: unknown): data is ComfyApiWorkflow {
|
||||
if (!_.isObject(data) || Array.isArray(data)) {
|
||||
return false
|
||||
}
|
||||
if (Object.keys(data).length === 0) return false
|
||||
|
||||
return Object.values(data).every((node) => {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { class_type: classType, inputs } = node as Record<string, unknown>
|
||||
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
|
||||
return typeof classType === 'string' && inputsIsRecord
|
||||
})
|
||||
isApiJson(data: unknown) {
|
||||
return _.isObject(data) && Object.values(data).every((v) => v.class_type)
|
||||
}
|
||||
|
||||
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {
|
||||
|
||||
@@ -17,6 +17,7 @@ export function clone<T>(obj: T): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
|
||||
* There are external callers to this function, so we need to keep it for now
|
||||
*/
|
||||
@@ -24,6 +25,7 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
|
||||
return _applyTextReplacements(app.rootGraph, value)
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||
export async function addStylesheet(
|
||||
urlOrFile: string,
|
||||
relativeTo?: string
|
||||
|
||||
@@ -32,6 +32,7 @@ import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { useGraphStateStore } from '@/core/graph/state/graphStateStore'
|
||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface QueuedPrompt {
|
||||
@@ -574,56 +575,54 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node and slot error flags when validation errors change.
|
||||
* Propagates errors up subgraph chains.
|
||||
* Push execution errors to graphStateStore and handle slot errors.
|
||||
*/
|
||||
watch(lastNodeErrors, () => {
|
||||
if (!app.rootGraph) return
|
||||
const graphStateStore = useGraphStateStore()
|
||||
|
||||
// Clear all error flags
|
||||
forEachNode(app.rootGraph, (node) => {
|
||||
node.has_errors = false
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
// Clear slot errors
|
||||
if (app.rootGraph) {
|
||||
forEachNode(app.rootGraph, (node) => {
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Clear previous execution errors
|
||||
graphStateStore.execute({ type: 'ClearAllErrors', version: 1 })
|
||||
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
// Set error flags on nodes and slots
|
||||
// Push execution errors to graphStateStore
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
node.has_errors = true
|
||||
|
||||
// Mark input slots with errors
|
||||
if (node.inputs) {
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) {
|
||||
slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
const locatorId = executionIdToNodeLocatorId(executionId)
|
||||
if (locatorId) {
|
||||
graphStateStore.execute({
|
||||
type: 'SetNodeError',
|
||||
version: 1,
|
||||
nodeId: locatorId,
|
||||
hasError: true
|
||||
})
|
||||
}
|
||||
|
||||
// Propagate errors to parent subgraph nodes
|
||||
const parts = executionId.split(':')
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentExecutionId = parts.slice(0, i).join(':')
|
||||
const parentNode = getNodeByExecutionId(
|
||||
app.rootGraph,
|
||||
parentExecutionId
|
||||
)
|
||||
if (parentNode) {
|
||||
parentNode.has_errors = true
|
||||
// Handle slot errors directly (not part of graphStateStore yet)
|
||||
if (app.rootGraph) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (node?.inputs) {
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) {
|
||||
slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
export type ButtonSize = 'full-width' | 'fit-content' | 'sm' | 'md'
|
||||
type ButtonType =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'transparent'
|
||||
| 'accent'
|
||||
| 'destructive'
|
||||
type ButtonType = 'primary' | 'secondary' | 'transparent' | 'accent'
|
||||
type ButtonBorder = boolean
|
||||
|
||||
export interface BaseButtonProps {
|
||||
@@ -38,10 +33,7 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
'bg-transparent border-none text-muted-foreground hover:bg-secondary-background-hover'
|
||||
),
|
||||
accent:
|
||||
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold',
|
||||
destructive: cn(
|
||||
'bg-destructive-background hover:bg-destructive-background-hover border-none text-base-foreground'
|
||||
)
|
||||
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold'
|
||||
} as const
|
||||
|
||||
return baseByType[type]
|
||||
@@ -55,18 +47,14 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
|
||||
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
|
||||
),
|
||||
accent:
|
||||
'bg-primary-background hover:bg-primary-background-hover text-white font-bold',
|
||||
destructive: cn(
|
||||
'bg-destructive-background hover:bg-destructive-background-hover text-base-foreground'
|
||||
)
|
||||
'bg-primary-background hover:bg-primary-background-hover text-white font-bold'
|
||||
} as const
|
||||
|
||||
const borderByType = {
|
||||
primary: 'border border-solid border-base-background',
|
||||
secondary: 'border border-solid border-base-foreground',
|
||||
transparent: 'border border-solid border-base-foreground',
|
||||
accent: 'border border-solid border-primary-background',
|
||||
destructive: 'border border-solid border-destructive-background'
|
||||
accent: 'border border-solid border-primary-background'
|
||||
} as const
|
||||
|
||||
return `${baseByType[type]} ${borderByType[type]}`
|
||||
|
||||
@@ -15,28 +15,6 @@ export type WidgetValue =
|
||||
| void
|
||||
| File[]
|
||||
|
||||
const CONTROL_OPTIONS = [
|
||||
'fixed',
|
||||
'increment',
|
||||
'decrement',
|
||||
'randomize'
|
||||
] as const
|
||||
export type ControlOptions = (typeof CONTROL_OPTIONS)[number]
|
||||
|
||||
function isControlOption(val: WidgetValue): val is ControlOptions {
|
||||
return CONTROL_OPTIONS.includes(val as ControlOptions)
|
||||
}
|
||||
|
||||
export function normalizeControlOption(val: WidgetValue): ControlOptions {
|
||||
if (isControlOption(val)) return val
|
||||
return 'randomize'
|
||||
}
|
||||
|
||||
export type SafeControlWidget = {
|
||||
value: ControlOptions
|
||||
update: (value: WidgetValue) => void
|
||||
}
|
||||
|
||||
export interface SimplifiedWidget<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
O = Record<string, any>
|
||||
@@ -69,6 +47,4 @@ export interface SimplifiedWidget<
|
||||
|
||||
/** Optional input specification backing this widget */
|
||||
spec?: InputSpecV2
|
||||
|
||||
controlWidget?: SafeControlWidget
|
||||
}
|
||||
|
||||
@@ -158,6 +158,23 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
let workflowStoreMock: {
|
||||
activeWorkflow: null | { activeState?: { id?: string } }
|
||||
}
|
||||
const ensureWorkflowStore = () => {
|
||||
if (!workflowStoreMock) {
|
||||
workflowStoreMock = reactive({
|
||||
activeWorkflow: null as null | { activeState?: { id?: string } }
|
||||
})
|
||||
}
|
||||
return workflowStoreMock
|
||||
}
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => {
|
||||
return ensureWorkflowStore()
|
||||
}
|
||||
}))
|
||||
|
||||
const createTask = (
|
||||
overrides: Partial<TestTask> & { mockState?: JobState } = {}
|
||||
): TestTask => ({
|
||||
@@ -193,6 +210,9 @@ const resetStores = () => {
|
||||
executionStore.activePromptId = null
|
||||
executionStore.executingNode = null
|
||||
|
||||
const workflowStore = ensureWorkflowStore()
|
||||
workflowStore.activeWorkflow = null
|
||||
|
||||
ensureProgressRefs()
|
||||
totalPercent.value = 0
|
||||
currentNodePercent.value = 0
|
||||
@@ -312,7 +332,7 @@ describe('useJobList', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts tasks by queue index descending', async () => {
|
||||
it('sorts all tasks by queue index descending', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
@@ -323,16 +343,75 @@ describe('useJobList', () => {
|
||||
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
|
||||
]
|
||||
|
||||
const { orderedTasks } = initComposable()
|
||||
const { allTasksSorted } = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(orderedTasks.value.map((task) => task.promptId)).toEqual([
|
||||
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
|
||||
'r',
|
||||
'h',
|
||||
'p'
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by job tab and resets failed tab when failures disappear', async () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
instance.selectedJobTab.value = 'Completed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
|
||||
|
||||
instance.selectedJobTab.value = 'Failed'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
|
||||
expect(instance.hasFailedJobs.value).toBe(true)
|
||||
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
]
|
||||
await flush()
|
||||
|
||||
expect(instance.hasFailedJobs.value).toBe(false)
|
||||
expect(instance.selectedJobTab.value).toBe('All')
|
||||
})
|
||||
|
||||
it('filters by active workflow when requested', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
promptId: 'wf-1',
|
||||
queueIndex: 2,
|
||||
mockState: 'pending',
|
||||
workflow: { id: 'workflow-1' }
|
||||
}),
|
||||
createTask({
|
||||
promptId: 'wf-2',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
workflow: { id: 'workflow-2' }
|
||||
})
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
instance.selectedWorkflowFilter.value = 'current'
|
||||
await flush()
|
||||
expect(instance.filteredTasks.value).toEqual([])
|
||||
|
||||
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
|
||||
await flush()
|
||||
|
||||
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
|
||||
'wf-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates job items with active progress and compute hours', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
@@ -389,7 +468,7 @@ describe('useJobList', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups job items by date label using queue order', async () => {
|
||||
it('groups job items by date label and sorts by total generation time when requested', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
queueStoreMock.historyTasks = [
|
||||
@@ -422,6 +501,7 @@ describe('useJobList', () => {
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
instance.selectedSortMode.value = 'totalGenerationTime'
|
||||
await flush()
|
||||
|
||||
const groups = instance.groupedJobItems.value
|
||||
@@ -433,8 +513,8 @@ describe('useJobList', () => {
|
||||
|
||||
const todayGroup = groups[0]
|
||||
expect(todayGroup.items.map((item) => item.id)).toEqual([
|
||||
'today-small',
|
||||
'today-large'
|
||||
'today-large',
|
||||
'today-small'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
NumberControlMode,
|
||||
useStepperControl
|
||||
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
|
||||
|
||||
// Mock the registry to spy on calls
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
|
||||
() => ({
|
||||
numberControlRegistry: {
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
executeControls: vi.fn(),
|
||||
getControlCount: vi.fn(() => 0),
|
||||
clear: vi.fn()
|
||||
},
|
||||
executeNumberControls: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
describe('useStepperControl', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with RANDOMIZED mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 }
|
||||
|
||||
const { controlMode } = useStepperControl(modelValue, options)
|
||||
|
||||
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
|
||||
})
|
||||
|
||||
it('should return control mode and apply function', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
|
||||
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
|
||||
expect(typeof applyControl).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('control modes', () => {
|
||||
it('should not change value in FIXED mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.FIXED
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(100)
|
||||
})
|
||||
|
||||
it('should increment value in INCREMENT mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 5 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(105)
|
||||
})
|
||||
|
||||
it('should decrement value in DECREMENT mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 5 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.DECREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(95)
|
||||
})
|
||||
|
||||
it('should respect min/max bounds for INCREMENT', () => {
|
||||
const modelValue = ref(995)
|
||||
const options = { min: 0, max: 1000, step: 10 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(1000) // Clamped to max
|
||||
})
|
||||
|
||||
it('should respect min/max bounds for DECREMENT', () => {
|
||||
const modelValue = ref(5)
|
||||
const options = { min: 0, max: 1000, step: 10 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.DECREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(0) // Clamped to min
|
||||
})
|
||||
|
||||
it('should randomize value in RANDOMIZE mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 10, step: 1 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.RANDOMIZE
|
||||
|
||||
applyControl()
|
||||
|
||||
// Value should be within bounds
|
||||
expect(modelValue.value).toBeGreaterThanOrEqual(0)
|
||||
expect(modelValue.value).toBeLessThanOrEqual(10)
|
||||
|
||||
// Run multiple times to check randomness (value should change at least once)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const beforeValue = modelValue.value
|
||||
applyControl()
|
||||
if (modelValue.value !== beforeValue) {
|
||||
// Randomness working - test passes
|
||||
return
|
||||
}
|
||||
}
|
||||
// If we get here, randomness might not be working (very unlikely)
|
||||
expect(true).toBe(true) // Still pass the test
|
||||
})
|
||||
})
|
||||
|
||||
describe('default options', () => {
|
||||
it('should use default options when not provided', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = {} // Empty options
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(101) // Default step is 1
|
||||
})
|
||||
|
||||
it('should use default min/max for randomize', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = {} // Empty options - should use defaults
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.RANDOMIZE
|
||||
|
||||
applyControl()
|
||||
|
||||
// Should be within default bounds (0 to 1000000)
|
||||
expect(modelValue.value).toBeGreaterThanOrEqual(0)
|
||||
expect(modelValue.value).toBeLessThanOrEqual(1000000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onChange callback', () => {
|
||||
it('should call onChange callback when provided', () => {
|
||||
const modelValue = ref(100)
|
||||
const onChange = vi.fn()
|
||||
const options = { min: 0, max: 1000, step: 1, onChange }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(101)
|
||||
})
|
||||
|
||||
it('should fallback to direct assignment when onChange not provided', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 } // No onChange
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
|
||||
expect(modelValue.value).toBe(101)
|
||||
})
|
||||
|
||||
it('should not call onChange in FIXED mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const onChange = vi.fn()
|
||||
const options = { min: 0, max: 1000, step: 1, onChange }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.FIXED
|
||||
|
||||
applyControl()
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,4 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getComponent,
|
||||
@@ -28,18 +26,7 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the settings store for components that might use it
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn(() => 'before')
|
||||
})
|
||||
}))
|
||||
|
||||
describe('widgetRegistry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
describe('getComponent', () => {
|
||||
// Test number type mappings
|
||||
describe('number types', () => {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
|
||||
|
||||
// Mock the settings store
|
||||
const mockGetSetting = vi.fn()
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockGetSetting
|
||||
})
|
||||
}))
|
||||
|
||||
describe('NumberControlRegistry', () => {
|
||||
let registry: NumberControlRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new NumberControlRegistry()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('register and unregister', () => {
|
||||
it('should register a control callback', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should unregister a control callback', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
|
||||
registry.unregister(controlId)
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle multiple registrations', () => {
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
registry.register(control1, callback1)
|
||||
registry.register(control2, callback2)
|
||||
|
||||
expect(registry.getControlCount()).toBe(2)
|
||||
|
||||
registry.unregister(control1)
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle unregistering non-existent controls gracefully', () => {
|
||||
const nonExistentId = Symbol('non-existent')
|
||||
|
||||
expect(() => registry.unregister(nonExistentId)).not.toThrow()
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeControls', () => {
|
||||
it('should execute controls when mode matches phase', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
// Mock setting store to return 'before'
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
registry.executeControls('before')
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
|
||||
})
|
||||
|
||||
it('should not execute controls when mode does not match phase', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
// Mock setting store to return 'after'
|
||||
mockGetSetting.mockReturnValue('after')
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
registry.executeControls('before')
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute all registered controls when mode matches', () => {
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
|
||||
registry.register(control1, callback1)
|
||||
registry.register(control2, callback2)
|
||||
registry.executeControls('before')
|
||||
|
||||
expect(callback1).toHaveBeenCalledTimes(1)
|
||||
expect(callback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle empty registry gracefully', () => {
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
|
||||
expect(() => registry.executeControls('before')).not.toThrow()
|
||||
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
|
||||
})
|
||||
|
||||
it('should work with both before and after phases', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
|
||||
// Test 'before' phase
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
registry.executeControls('before')
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Test 'after' phase
|
||||
mockGetSetting.mockReturnValue('after')
|
||||
registry.executeControls('after')
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('utility methods', () => {
|
||||
it('should return correct control count', () => {
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
|
||||
registry.register(control1, vi.fn())
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
|
||||
registry.register(control2, vi.fn())
|
||||
expect(registry.getControlCount()).toBe(2)
|
||||
|
||||
registry.unregister(control1)
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should clear all controls', () => {
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
|
||||
registry.register(control1, vi.fn())
|
||||
registry.register(control2, vi.fn())
|
||||
expect(registry.getControlCount()).toBe(2)
|
||||
|
||||
registry.clear()
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||