Compare commits
58 Commits
coderabbit
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592cccd163 | ||
|
|
1d14eadc70 | ||
|
|
bcb2bceb65 | ||
|
|
4558ca7f17 | ||
|
|
c2e3b8a841 | ||
|
|
5db2f20312 | ||
|
|
76b63dbcb1 | ||
|
|
5cd62c0b51 | ||
|
|
1d948db4a9 | ||
|
|
7bc05bdece | ||
|
|
bca95177e2 | ||
|
|
84780becf7 | ||
|
|
44d0159c82 | ||
|
|
3b766ac98c | ||
|
|
4a128585a8 | ||
|
|
613b660786 | ||
|
|
a7761cac77 | ||
|
|
ea98a480d7 | ||
|
|
d9e930bd1c | ||
|
|
53e7abcc4a | ||
|
|
caa5574ba7 | ||
|
|
3b2c8d541b | ||
|
|
503eb72c8b | ||
|
|
96823b6f58 | ||
|
|
a6adab43cc | ||
|
|
d913a3e4b8 | ||
|
|
9cea37fed2 | ||
|
|
125bd01a61 | ||
|
|
5c2a8b741e | ||
|
|
5088defdf0 | ||
|
|
99099b5a79 | ||
|
|
d1ad5a6093 | ||
|
|
9fd8455b92 | ||
|
|
19b1151b84 | ||
|
|
d1fb972c82 | ||
|
|
00490e8d94 | ||
|
|
e5a4443653 | ||
|
|
094c4c4871 | ||
|
|
6ab6e78497 | ||
|
|
602784a672 | ||
|
|
22eefc4222 | ||
|
|
e181ec95b0 | ||
|
|
c5f42b0862 | ||
|
|
32fff22eb1 | ||
|
|
e29f9b6800 | ||
|
|
c1262e3bb2 | ||
|
|
69aa9ae2d7 | ||
|
|
fa652592b4 | ||
|
|
9f2de249f4 | ||
|
|
114c2ef182 | ||
|
|
dd0aff5865 | ||
|
|
c723ee4891 | ||
|
|
3bea20e755 | ||
|
|
3e97dde185 | ||
|
|
f0fbb55a0a | ||
|
|
d37023bf5e | ||
|
|
a28cb69a73 | ||
|
|
cd7d627ef4 |
10
.github/workflows/release-draft-create.yaml
vendored
@@ -53,7 +53,13 @@ jobs:
|
|||||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||||
run: |
|
run: |
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm build
|
|
||||||
|
# Desktop-specific release artifact with desktop distribution flags.
|
||||||
|
DISTRIBUTION=desktop pnpm build
|
||||||
|
pnpm zipdist ./dist ./dist-desktop.zip
|
||||||
|
|
||||||
|
# Default release artifact for core/PyPI.
|
||||||
|
NX_SKIP_NX_CACHE=true pnpm build
|
||||||
pnpm zipdist
|
pnpm zipdist
|
||||||
- name: Upload dist artifact
|
- name: Upload dist artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
@@ -62,6 +68,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
dist/
|
dist/
|
||||||
dist.zip
|
dist.zip
|
||||||
|
dist-desktop.zip
|
||||||
|
|
||||||
draft_release:
|
draft_release:
|
||||||
needs: build
|
needs: build
|
||||||
@@ -79,6 +86,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
dist.zip
|
dist.zip
|
||||||
|
dist-desktop.zip
|
||||||
tag_name: v${{ needs.build.outputs.version }}
|
tag_name: v${{ needs.build.outputs.version }}
|
||||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||||
make_latest: >-
|
make_latest: >-
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"no-control-regex": "off",
|
"no-control-regex": "off",
|
||||||
"no-eval": "off",
|
"no-eval": "error",
|
||||||
"no-redeclare": "error",
|
"no-redeclare": "error",
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
@@ -61,8 +61,7 @@
|
|||||||
"^build"
|
"^build"
|
||||||
],
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "apps/desktop-ui",
|
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||||
"command": "vite build --config vite.config.mts"
|
|
||||||
},
|
},
|
||||||
"outputs": [
|
"outputs": [
|
||||||
"{projectRoot}/dist"
|
"{projectRoot}/dist"
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
|
||||||
ref="rootEl"
|
<div class="p-terminal size-full rounded-none p-2">
|
||||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
<div ref="terminalEl" class="terminal-host h-full" />
|
||||||
>
|
|
||||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
|
||||||
<div ref="terminalEl" class="h-full terminal-host" />
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.left="{
|
v-tooltip.left="{
|
||||||
@@ -16,7 +13,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
:class="
|
:class="
|
||||||
cn('absolute top-2 right-8 transition-opacity', {
|
cn('absolute top-2 right-8 transition-opacity', {
|
||||||
'opacity-0 pointer-events-none select-none': !isHovered
|
'pointer-events-none opacity-0 select-none': !isHovered
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
:aria-label="tooltipText"
|
:aria-label="tooltipText"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
|
<div class="mx-auto flex w-full max-w-3xl flex-col gap-8 select-none">
|
||||||
<!-- Installation Path Section -->
|
<!-- Installation Path Section -->
|
||||||
<div class="grow flex flex-col gap-6 text-neutral-300">
|
<div class="flex grow flex-col gap-6 text-neutral-300">
|
||||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
<h2 class="text-center font-inter text-3xl font-bold text-neutral-100">
|
||||||
{{ $t('install.locationPicker.title') }}
|
{{ $t('install.locationPicker.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-center text-neutral-400 px-12">
|
<p class="px-12 text-center text-neutral-400">
|
||||||
{{ $t('install.locationPicker.subtitle') }}
|
{{ $t('install.locationPicker.subtitle') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<InputText
|
<InputText
|
||||||
v-model="installPath"
|
v-model="installPath"
|
||||||
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
||||||
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
class="flex-1 border-neutral-700 bg-neutral-800/50 text-neutral-200 placeholder:text-neutral-500"
|
||||||
:class="{ 'p-invalid': pathError }"
|
:class="{ 'p-invalid': pathError }"
|
||||||
@update:model-value="validatePath"
|
@update:model-value="validatePath"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<Button
|
<Button
|
||||||
icon="pi pi-folder-open"
|
icon="pi pi-folder-open"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
class="bg-neutral-700 hover:bg-neutral-600 border-0"
|
class="border-0 bg-neutral-700 hover:bg-neutral-600"
|
||||||
@click="browsePath"
|
@click="browsePath"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<Message
|
<Message
|
||||||
v-if="pathError"
|
v-if="pathError"
|
||||||
severity="error"
|
severity="error"
|
||||||
class="whitespace-pre-line w-full"
|
class="w-full whitespace-pre-line"
|
||||||
>
|
>
|
||||||
{{ pathError }}
|
{{ pathError }}
|
||||||
</Message>
|
</Message>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<img
|
<img
|
||||||
v-if="task.headerImg"
|
v-if="task.headerImg"
|
||||||
:src="task.headerImg"
|
:src="task.headerImg"
|
||||||
class="h-full w-full object-contain px-4 pt-4 opacity-25"
|
class="size-full object-contain px-4 pt-4 opacity-25"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<i
|
<i
|
||||||
v-if="!isLoading && runner.state === 'OK'"
|
v-if="!isLoading && runner.state === 'OK'"
|
||||||
class="task-card-ok pi pi-check"
|
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 z-10 col-span-full row-span-full text-[4rem] text-green-500 opacity-100 transition-opacity [text-shadow:0.25rem_0_0.5rem_black] group-hover/task-card:opacity-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<template v-if="filter.tasks.length === 0">
|
<template v-if="filter.tasks.length === 0">
|
||||||
<!-- Empty filter -->
|
<!-- Empty filter -->
|
||||||
<Divider />
|
<Divider />
|
||||||
<p class="text-neutral-400 w-full text-center">
|
<p class="w-full text-center text-neutral-400">
|
||||||
{{ $t('maintenance.allOk') }}
|
{{ $t('maintenance.allOk') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<!-- Display: Cards -->
|
<!-- Display: Cards -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||||
<TaskCard
|
<TaskCard
|
||||||
v-for="task in filter.tasks"
|
v-for="task in filter.tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
|
|||||||
import ConfirmPopup from 'primevue/confirmpopup'
|
import ConfirmPopup from 'primevue/confirmpopup'
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
|
|
||||||
import { t } from '@/i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||||
import type {
|
import type {
|
||||||
MaintenanceFilter,
|
MaintenanceFilter,
|
||||||
@@ -55,6 +56,7 @@ import type {
|
|||||||
import TaskCard from './TaskCard.vue'
|
import TaskCard from './TaskCard.vue'
|
||||||
import TaskListItem from './TaskListItem.vue'
|
import TaskListItem from './TaskListItem.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
const taskStore = useMaintenanceTaskStore()
|
const taskStore = useMaintenanceTaskStore()
|
||||||
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('maintenance.error.toastTitle'),
|
summary: t('maintenance.error.toastTitle'),
|
||||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
detail: message ?? t('maintenance.error.defaultDescription')
|
||||||
life: 10_000
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
|
<div class="flex size-full flex-col justify-between rounded-lg p-6">
|
||||||
<h1 class="font-inter font-semibold text-xl m-0 italic">
|
<h1 class="m-0 font-inter text-xl font-semibold italic">
|
||||||
{{ t(`desktopDialogs.${id}.title`, title) }}
|
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="whitespace-pre-wrap">
|
<p class="whitespace-pre-wrap">
|
||||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseViewTemplate dark>
|
<BaseViewTemplate dark>
|
||||||
<div
|
<div
|
||||||
class="h-screen w-screen grid items-center justify-around overflow-y-auto"
|
class="grid h-screen w-screen items-center justify-around overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div class="relative m-8 text-center">
|
<div class="relative m-8 text-center">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<span>{{ t('desktopUpdate.description') }}</span>
|
<span>{{ t('desktopUpdate.description') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
<ProgressSpinner class="m-8 size-48" />
|
||||||
|
|
||||||
<!-- Console button -->
|
<!-- Console button -->
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseViewTemplate dark>
|
<BaseViewTemplate dark>
|
||||||
<!-- Fixed height container with flexbox layout for proper content management -->
|
<!-- Fixed height container with flexbox layout for proper content management -->
|
||||||
<div class="w-full h-full flex flex-col">
|
<div class="flex size-full flex-col">
|
||||||
<Stepper
|
<Stepper
|
||||||
v-model:value="currentStep"
|
v-model:value="currentStep"
|
||||||
class="flex flex-col h-full"
|
class="flex h-full flex-col"
|
||||||
@update:value="handleStepChange"
|
@update:value="handleStepChange"
|
||||||
>
|
>
|
||||||
<!-- Main content area that grows to fill available space -->
|
<!-- Main content area that grows to fill available space -->
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<!-- Install footer with navigation -->
|
<!-- Install footer with navigation -->
|
||||||
<InstallFooter
|
<InstallFooter
|
||||||
class="w-full max-w-2xl my-6 mx-auto"
|
class="mx-auto my-6 w-full max-w-2xl"
|
||||||
:current-step
|
:current-step
|
||||||
:can-proceed
|
:can-proceed
|
||||||
:disable-location-step="noGpu"
|
:disable-location-step="noGpu"
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseViewTemplate dark>
|
<BaseViewTemplate dark>
|
||||||
<div
|
<div
|
||||||
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
|
class="dark-theme grid h-screen min-h-full w-screen min-w-full justify-around overflow-y-auto bg-neutral-900 font-sans text-neutral-300"
|
||||||
>
|
>
|
||||||
<div class="max-w-(--breakpoint-sm) w-screen m-8 relative">
|
<div class="relative m-8 w-screen max-w-(--breakpoint-sm)">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<h1 class="backspan pi-wrench text-4xl font-bold">
|
<h1 class="backspan pi-wrench text-4xl font-bold">
|
||||||
{{ t('maintenance.title') }}
|
{{ t('maintenance.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="w-full flex flex-wrap gap-4 items-center">
|
<div class="flex w-full flex-wrap items-center gap-4">
|
||||||
<span class="grow">
|
<span class="grow">
|
||||||
{{ t('maintenance.status') }}:
|
{{ t('maintenance.status') }}:
|
||||||
<StatusTag :refreshing="isRefreshing" :error="anyErrors" />
|
<StatusTag :refreshing="isRefreshing" :error="anyErrors" />
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex items-center gap-4">
|
||||||
<SelectButton
|
<SelectButton
|
||||||
v-model="displayAsList"
|
v-model="displayAsList"
|
||||||
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
|
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
:value="t('icon.exclamation-triangle')"
|
:value="t('icon.exclamation-triangle')"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong class="block mb-1">
|
<strong class="mb-1 block">
|
||||||
{{ t('maintenance.unsafeMigration.title') }}
|
{{ t('maintenance.unsafeMigration.title') }}
|
||||||
</strong>
|
</strong>
|
||||||
<span class="block mb-1">
|
<span class="mb-1 block">
|
||||||
{{ unsafeReasonText }}
|
{{ unsafeReasonText }}
|
||||||
</span>
|
</span>
|
||||||
<span class="block text-sm text-neutral-400">
|
<span class="block text-sm text-neutral-400">
|
||||||
@@ -71,13 +71,13 @@
|
|||||||
|
|
||||||
<!-- Tasks -->
|
<!-- Tasks -->
|
||||||
<TaskListPanel
|
<TaskListPanel
|
||||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
class="border-x-0 border-y border-solid border-neutral-700"
|
||||||
:filter
|
:filter
|
||||||
:display-as-list
|
:display-as-list
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-between gap-4 flex-row">
|
<div class="flex flex-row justify-between gap-4">
|
||||||
<Button
|
<Button
|
||||||
:label="t('maintenance.consoleLogs')"
|
:label="t('maintenance.consoleLogs')"
|
||||||
icon="pi pi-desktop"
|
icon="pi pi-desktop"
|
||||||
@@ -188,8 +188,7 @@ const completeValidation = async () => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('g.error'),
|
summary: t('g.error'),
|
||||||
detail: t('maintenance.error.cannotContinue'),
|
detail: t('maintenance.error.cannotContinue')
|
||||||
life: 5_000
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseViewTemplate dark hide-language-selector>
|
<BaseViewTemplate dark hide-language-selector>
|
||||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||||
<div
|
<div
|
||||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||||
>
|
>
|
||||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||||
{{ $t('install.helpImprove') }}
|
{{ $t('install.helpImprove') }}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<a
|
<a
|
||||||
href="https://comfy.org/privacy"
|
href="https://comfy.org/privacy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-blue-400 hover:text-blue-300 underline"
|
class="text-blue-400 underline hover:text-blue-300"
|
||||||
>
|
>
|
||||||
{{ $t('install.privacyPolicy') }} </a
|
{{ $t('install.privacyPolicy') }} </a
|
||||||
>.
|
>.
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex pt-6 justify-end">
|
<div class="flex justify-end pt-6">
|
||||||
<Button
|
<Button
|
||||||
:label="$t('g.ok')"
|
:label="$t('g.ok')"
|
||||||
icon="pi pi-check"
|
icon="pi pi-check"
|
||||||
@@ -72,8 +72,7 @@ const updateConsent = async () => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('install.settings.errorUpdatingConsent'),
|
summary: t('install.settings.errorUpdatingConsent'),
|
||||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||||
life: 3000
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating.value = false
|
isUpdating.value = false
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="no-drag sad-text flex items-center">
|
<div class="no-drag sad-text flex items-center">
|
||||||
<div class="flex flex-col gap-8 p-8 min-w-110">
|
<div class="flex min-w-110 flex-col gap-8 p-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<h1 class="text-4xl font-bold text-red-500">
|
<h1 class="text-4xl font-bold text-red-500">
|
||||||
{{ $t('notSupported.title') }}
|
{{ $t('notSupported.title') }}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<p class="text-xl">
|
<p class="text-xl">
|
||||||
{{ $t('notSupported.message') }}
|
{{ $t('notSupported.message') }}
|
||||||
</p>
|
</p>
|
||||||
<ul class="list-disc list-inside space-y-1 text-neutral-800">
|
<ul class="list-inside list-disc space-y-1 text-neutral-800">
|
||||||
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
|
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
|
||||||
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
|
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
<BaseViewTemplate dark>
|
<BaseViewTemplate dark>
|
||||||
<div class="relative min-h-screen">
|
<div class="relative min-h-screen">
|
||||||
<!-- Terminal Background Layer (always visible during loading) -->
|
<!-- Terminal Background Layer (always visible during loading) -->
|
||||||
<div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
|
<div v-if="!isError" class="fixed inset-0 z-0 overflow-hidden">
|
||||||
<div class="h-full w-full">
|
<div class="size-full">
|
||||||
<BaseTerminal @created="terminalCreated" />
|
<BaseTerminal @created="terminalCreated" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Semi-transparent overlay -->
|
<!-- Semi-transparent overlay -->
|
||||||
<div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
|
<div v-if="!isError" class="fixed inset-0 z-5 bg-neutral-900/80"></div>
|
||||||
|
|
||||||
<!-- Smooth radial gradient overlay -->
|
<!-- Smooth radial gradient overlay -->
|
||||||
<div
|
<div
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
<!-- Error Section (positioned at bottom) -->
|
<!-- Error Section (positioned at bottom) -->
|
||||||
<div
|
<div
|
||||||
v-if="isError"
|
v-if="isError"
|
||||||
class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
|
class="absolute inset-x-0 bottom-20 flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4 justify-center">
|
<div class="flex justify-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-flag"
|
icon="pi pi-flag"
|
||||||
:label="$t('serverStart.reportIssue')"
|
:label="$t('serverStart.reportIssue')"
|
||||||
@@ -71,10 +71,10 @@
|
|||||||
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
|
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
|
||||||
<div
|
<div
|
||||||
v-if="terminalVisible && isError"
|
v-if="terminalVisible && isError"
|
||||||
class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
|
class="absolute inset-x-4 bottom-4 z-10 mx-auto max-w-4xl"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
|
class="h-[300px] rounded-lg border border-neutral-700 bg-neutral-900/95 p-4"
|
||||||
>
|
>
|
||||||
<BaseTerminal @created="terminalCreated" />
|
<BaseTerminal @created="terminalCreated" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
183
browser_tests/assets/subgraphs/subgraph-duplicate-links.json
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
{
|
||||||
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"revision": 0,
|
||||||
|
"last_node_id": 2,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||||
|
"pos": [600, 400],
|
||||||
|
"size": [200, 100],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"definitions": {
|
||||||
|
"subgraphs": [
|
||||||
|
{
|
||||||
|
"id": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||||
|
"version": 1,
|
||||||
|
"state": {
|
||||||
|
"lastGroupId": 0,
|
||||||
|
"lastNodeId": 2,
|
||||||
|
"lastLinkId": 5,
|
||||||
|
"lastRerouteId": 0
|
||||||
|
},
|
||||||
|
"revision": 0,
|
||||||
|
"config": {},
|
||||||
|
"name": "Subgraph With Duplicate Links",
|
||||||
|
"inputNode": {
|
||||||
|
"id": -10,
|
||||||
|
"bounding": [200, 400, 120, 60]
|
||||||
|
},
|
||||||
|
"outputNode": {
|
||||||
|
"id": -20,
|
||||||
|
"bounding": [900, 400, 120, 60]
|
||||||
|
},
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"id": "out-latent-1",
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"linkIds": [2],
|
||||||
|
"pos": [920, 420]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"widgets": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": [400, 100],
|
||||||
|
"size": [270, 262],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [2]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": [100, 200],
|
||||||
|
"size": [200, 106],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [1, 3, 4, 5]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "EmptyLatentImage"
|
||||||
|
},
|
||||||
|
"widgets_values": [512, 512, 1]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"origin_id": 2,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 1,
|
||||||
|
"target_slot": 3,
|
||||||
|
"type": "LATENT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"origin_id": 1,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": -20,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "LATENT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"origin_id": 2,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 1,
|
||||||
|
"target_slot": 3,
|
||||||
|
"type": "LATENT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"origin_id": 2,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 1,
|
||||||
|
"target_slot": 3,
|
||||||
|
"type": "LATENT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"origin_id": 2,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 1,
|
||||||
|
"target_slot": 3,
|
||||||
|
"type": "LATENT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [0, 0]
|
||||||
|
},
|
||||||
|
"frontendVersion": "1.38.14"
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
760
browser_tests/assets/subgraphs/subgraph-nested-promotion.json
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
{
|
||||||
|
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
|
||||||
|
"revision": 0,
|
||||||
|
"last_node_id": 11,
|
||||||
|
"last_link_id": 18,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "PreviewAny",
|
||||||
|
"pos": [1031, 434],
|
||||||
|
"size": [250, 178],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "source",
|
||||||
|
"type": "*",
|
||||||
|
"link": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "PreviewAny"
|
||||||
|
},
|
||||||
|
"widgets_values": [null, null, null]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||||
|
"pos": [788, 433.5],
|
||||||
|
"size": [225, 380],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_a"
|
||||||
|
},
|
||||||
|
"link": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [5]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"proxyWidgets": [
|
||||||
|
["3", "string_a"],
|
||||||
|
["4", "value"],
|
||||||
|
["6", "value"],
|
||||||
|
["6", "value_1"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "PrimitiveStringMultiline",
|
||||||
|
"pos": [548, 451],
|
||||||
|
"size": [225, 142],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [4]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Outer",
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "PrimitiveStringMultiline"
|
||||||
|
},
|
||||||
|
"widgets_values": ["Outer\n"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[4, 1, 0, 5, 0, "STRING"],
|
||||||
|
[5, 5, 0, 2, 0, "STRING"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"definitions": {
|
||||||
|
"subgraphs": [
|
||||||
|
{
|
||||||
|
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||||
|
"version": 1,
|
||||||
|
"state": {
|
||||||
|
"lastGroupId": 0,
|
||||||
|
"lastNodeId": 11,
|
||||||
|
"lastLinkId": 18,
|
||||||
|
"lastRerouteId": 0
|
||||||
|
},
|
||||||
|
"revision": 0,
|
||||||
|
"config": {},
|
||||||
|
"name": "Sub 0",
|
||||||
|
"inputNode": {
|
||||||
|
"id": -10,
|
||||||
|
"bounding": [351, 432.5, 120, 120]
|
||||||
|
},
|
||||||
|
"outputNode": {
|
||||||
|
"id": -20,
|
||||||
|
"bounding": [1352, 294.5, 120, 60]
|
||||||
|
},
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [1],
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"pos": [451, 452.5]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [13],
|
||||||
|
"pos": [451, 472.5]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
|
||||||
|
"name": "value_1",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [16],
|
||||||
|
"pos": [451, 492.5]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
|
||||||
|
"name": "value_1_1",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [18],
|
||||||
|
"pos": [451, 512.5]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [9],
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"pos": [1372, 314.5]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"widgets": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "PrimitiveStringMultiline",
|
||||||
|
"pos": [504, 437],
|
||||||
|
"size": [210, 88],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "value",
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "value"
|
||||||
|
},
|
||||||
|
"link": 13
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [2]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Inner 1",
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "PrimitiveStringMultiline"
|
||||||
|
},
|
||||||
|
"widgets_values": ["Inner 1\n"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "StringConcatenate",
|
||||||
|
"pos": [743, 325],
|
||||||
|
"size": [347, 231],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_a"
|
||||||
|
},
|
||||||
|
"link": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localized_name": "string_b",
|
||||||
|
"name": "string_b",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_b"
|
||||||
|
},
|
||||||
|
"link": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [7]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "StringConcatenate"
|
||||||
|
},
|
||||||
|
"widgets_values": ["", "", ""]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||||
|
"pos": [1115, 301],
|
||||||
|
"size": [210, 196],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_a"
|
||||||
|
},
|
||||||
|
"link": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "value"
|
||||||
|
},
|
||||||
|
"link": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value_1",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "value_1"
|
||||||
|
},
|
||||||
|
"link": 18
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [9]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"proxyWidgets": [
|
||||||
|
["5", "string_a"],
|
||||||
|
["11", "value"],
|
||||||
|
["9", "value"],
|
||||||
|
["10", "string_a"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"origin_id": 4,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 3,
|
||||||
|
"target_slot": 1,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 3,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"origin_id": 3,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 6,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"origin_id": 6,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": -20,
|
||||||
|
"target_slot": 1,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"origin_id": 6,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": -20,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 1,
|
||||||
|
"target_id": 4,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 2,
|
||||||
|
"target_id": 6,
|
||||||
|
"target_slot": 1,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 3,
|
||||||
|
"target_id": 6,
|
||||||
|
"target_slot": 2,
|
||||||
|
"type": "STRING"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||||
|
"version": 1,
|
||||||
|
"state": {
|
||||||
|
"lastGroupId": 0,
|
||||||
|
"lastNodeId": 11,
|
||||||
|
"lastLinkId": 18,
|
||||||
|
"lastRerouteId": 0
|
||||||
|
},
|
||||||
|
"revision": 0,
|
||||||
|
"config": {},
|
||||||
|
"name": "Sub 1",
|
||||||
|
"inputNode": {
|
||||||
|
"id": -10,
|
||||||
|
"bounding": [180, 739, 120, 100]
|
||||||
|
},
|
||||||
|
"outputNode": {
|
||||||
|
"id": -20,
|
||||||
|
"bounding": [1246, 612, 120, 60]
|
||||||
|
},
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [4],
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"pos": [280, 759]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [14],
|
||||||
|
"pos": [280, 779]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
|
||||||
|
"name": "value_1",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [17],
|
||||||
|
"pos": [280, 799]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [12],
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"pos": [1266, 632]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"widgets": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"type": "PrimitiveStringMultiline",
|
||||||
|
"pos": [334, 742],
|
||||||
|
"size": [210, 88],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "value",
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "value"
|
||||||
|
},
|
||||||
|
"link": 14
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [7]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Inner 2",
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "PrimitiveStringMultiline"
|
||||||
|
},
|
||||||
|
"widgets_values": ["Inner 2\n"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "StringConcatenate",
|
||||||
|
"pos": [581, 637],
|
||||||
|
"size": [400, 200],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_a"
|
||||||
|
},
|
||||||
|
"link": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localized_name": "string_b",
|
||||||
|
"name": "string_b",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_b"
|
||||||
|
},
|
||||||
|
"link": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [11]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "StringConcatenate"
|
||||||
|
},
|
||||||
|
"widgets_values": ["", "", ""]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||||
|
"pos": [1004, 613],
|
||||||
|
"size": [210, 142],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_a"
|
||||||
|
},
|
||||||
|
"link": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "value"
|
||||||
|
},
|
||||||
|
"link": 17
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [12]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"proxyWidgets": [
|
||||||
|
["7", "string_a"],
|
||||||
|
["8", "value"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 10,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"origin_id": 11,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 10,
|
||||||
|
"target_slot": 1,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"origin_id": 10,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 9,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"origin_id": 9,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": -20,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"origin_id": 9,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": -20,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 1,
|
||||||
|
"target_id": 11,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 2,
|
||||||
|
"target_id": 9,
|
||||||
|
"target_slot": 1,
|
||||||
|
"type": "STRING"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||||
|
"version": 1,
|
||||||
|
"state": {
|
||||||
|
"lastGroupId": 0,
|
||||||
|
"lastNodeId": 11,
|
||||||
|
"lastLinkId": 18,
|
||||||
|
"lastRerouteId": 0
|
||||||
|
},
|
||||||
|
"revision": 0,
|
||||||
|
"config": {},
|
||||||
|
"name": "Sub 2",
|
||||||
|
"inputNode": {
|
||||||
|
"id": -10,
|
||||||
|
"bounding": [262, 1222, 120, 80]
|
||||||
|
},
|
||||||
|
"outputNode": {
|
||||||
|
"id": -20,
|
||||||
|
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
|
||||||
|
},
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [9],
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"pos": [362, 1242]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [15],
|
||||||
|
"pos": [362, 1262]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"linkIds": [10],
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"pos": [1143.089999999999, 1145.1999999999998]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"widgets": [],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "PrimitiveStringMultiline",
|
||||||
|
"pos": [412.96000000000004, 1228.2399999999996],
|
||||||
|
"size": [210, 88],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "value",
|
||||||
|
"name": "value",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "value"
|
||||||
|
},
|
||||||
|
"link": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [8]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Inner 3",
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "PrimitiveStringMultiline"
|
||||||
|
},
|
||||||
|
"widgets_values": ["Inner 3\n"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "StringConcatenate",
|
||||||
|
"pos": [686.08, 1132.38],
|
||||||
|
"size": [400, 200],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "string_a",
|
||||||
|
"name": "string_a",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_a"
|
||||||
|
},
|
||||||
|
"link": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localized_name": "string_b",
|
||||||
|
"name": "string_b",
|
||||||
|
"type": "STRING",
|
||||||
|
"widget": {
|
||||||
|
"name": "string_b"
|
||||||
|
},
|
||||||
|
"link": 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"localized_name": "STRING",
|
||||||
|
"name": "STRING",
|
||||||
|
"type": "STRING",
|
||||||
|
"links": [10]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "StringConcatenate"
|
||||||
|
},
|
||||||
|
"widgets_values": ["", "", ""]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"origin_id": 8,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 7,
|
||||||
|
"target_slot": 1,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": 7,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"origin_id": 7,
|
||||||
|
"origin_slot": 0,
|
||||||
|
"target_id": -20,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"origin_id": -10,
|
||||||
|
"origin_slot": 1,
|
||||||
|
"target_id": 8,
|
||||||
|
"target_slot": 0,
|
||||||
|
"type": "STRING"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [-412, 11]
|
||||||
|
},
|
||||||
|
"frontendVersion": "1.41.7"
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
@@ -206,9 +206,7 @@ export class ComfyPage {
|
|||||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||||
this.runButton = page
|
this.runButton = page.getByTestId(TestIds.topbar.queueButton)
|
||||||
.getByTestId(TestIds.topbar.queueButton)
|
|
||||||
.getByRole('button', { name: 'Run' })
|
|
||||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||||
|
|
||||||
this.searchBox = new ComfyNodeSearchBox(page)
|
this.searchBox = new ComfyNodeSearchBox(page)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const TestIds = {
|
|||||||
},
|
},
|
||||||
topbar: {
|
topbar: {
|
||||||
queueButton: 'queue-button',
|
queueButton: 'queue-button',
|
||||||
|
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||||
saveButton: 'save-workflow-button'
|
saveButton: 'save-workflow-button'
|
||||||
},
|
},
|
||||||
nodeLibrary: {
|
nodeLibrary: {
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ class ComfyQueueButton {
|
|||||||
public readonly dropdownButton: Locator
|
public readonly dropdownButton: Locator
|
||||||
constructor(public readonly actionbar: ComfyActionbar) {
|
constructor(public readonly actionbar: ComfyActionbar) {
|
||||||
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
|
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
|
||||||
this.primaryButton = this.root.locator('.p-splitbutton-button')
|
this.primaryButton = this.root
|
||||||
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
|
this.dropdownButton = actionbar.root.getByTestId(
|
||||||
|
TestIds.topbar.queueModeMenuTrigger
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async toggleOptions() {
|
public async toggleOptions() {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
@@ -37,12 +37,9 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
|
|
||||||
// Monitor for server feature flags
|
// Monitor for server feature flags
|
||||||
const checkInterval = setInterval(() => {
|
const checkInterval = setInterval(() => {
|
||||||
if (
|
const flags = window.app?.api?.serverFeatureFlags?.value
|
||||||
window.app?.api?.serverFeatureFlags &&
|
if (flags && Object.keys(flags).length > 0) {
|
||||||
Object.keys(window.app.api.serverFeatureFlags).length > 0
|
window.__capturedMessages!.serverFeatureFlags = flags
|
||||||
) {
|
|
||||||
window.__capturedMessages!.serverFeatureFlags =
|
|
||||||
window.app.api.serverFeatureFlags
|
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
@@ -96,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Get the actual server feature flags from the backend
|
// Get the actual server feature flags from the backend
|
||||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||||
return window.app!.api.serverFeatureFlags
|
return window.app!.api.serverFeatureFlags.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify we received real feature flags from the backend
|
// Verify we received real feature flags from the backend
|
||||||
@@ -129,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
// Test that the method only returns true for boolean true values
|
// Test that the method only returns true for boolean true values
|
||||||
const testResults = await comfyPage.page.evaluate(() => {
|
const testResults = await comfyPage.page.evaluate(() => {
|
||||||
// Temporarily modify serverFeatureFlags to test behavior
|
// Temporarily modify serverFeatureFlags to test behavior
|
||||||
const original = window.app!.api.serverFeatureFlags
|
const original = window.app!.api.serverFeatureFlags.value
|
||||||
window.app!.api.serverFeatureFlags = {
|
window.app!.api.serverFeatureFlags.value = {
|
||||||
bool_true: true,
|
bool_true: true,
|
||||||
bool_false: false,
|
bool_false: false,
|
||||||
string_value: 'yes',
|
string_value: 'yes',
|
||||||
@@ -147,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore original
|
// Restore original
|
||||||
window.app!.api.serverFeatureFlags = original
|
window.app!.api.serverFeatureFlags.value = original
|
||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
// Monitor when feature flags arrive by checking periodically
|
// Monitor when feature flags arrive by checking periodically
|
||||||
const checkFeatureFlags = setInterval(() => {
|
const checkFeatureFlags = setInterval(() => {
|
||||||
if (
|
if (
|
||||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
window.app?.api?.serverFeatureFlags?.value
|
||||||
undefined
|
?.supports_preview_metadata !== undefined
|
||||||
) {
|
) {
|
||||||
window.__appReadiness!.featureFlagsReceived = true
|
window.__appReadiness!.featureFlagsReceived = true
|
||||||
clearInterval(checkFeatureFlags)
|
clearInterval(checkFeatureFlags)
|
||||||
@@ -320,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
// Wait for feature flags to be received
|
// Wait for feature flags to be received
|
||||||
await newPage.waitForFunction(
|
await newPage.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
window.app?.api?.serverFeatureFlags?.value
|
||||||
undefined,
|
?.supports_preview_metadata !== undefined,
|
||||||
{
|
{
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
}
|
}
|
||||||
@@ -331,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
|||||||
const readiness = await newPage.evaluate(() => {
|
const readiness = await newPage.evaluate(() => {
|
||||||
return {
|
return {
|
||||||
...window.__appReadiness,
|
...window.__appReadiness,
|
||||||
currentFlags: window.app!.api.serverFeatureFlags
|
currentFlags: window.app!.api.serverFeatureFlags.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
|
|||||||
|
|
||||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
@@ -375,6 +375,45 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('Subgraph Unpacking', () => {
|
||||||
|
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-duplicate-links'
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await comfyPage.page.evaluate(() => {
|
||||||
|
const graph = window.app!.graph!
|
||||||
|
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||||
|
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||||
|
return { error: 'No subgraph node found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.unpackSubgraph(subgraphNode)
|
||||||
|
|
||||||
|
const linkCount = graph.links.size
|
||||||
|
const nodes = graph.nodes
|
||||||
|
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||||
|
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||||
|
|
||||||
|
const linkedInputCount = ksampler.inputs.filter(
|
||||||
|
(i) => i.link != null
|
||||||
|
).length
|
||||||
|
|
||||||
|
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty('error')
|
||||||
|
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||||
|
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||||
|
// because the subgraph output has no downstream connection.
|
||||||
|
expect(result.linkCount).toBe(1)
|
||||||
|
// KSampler should have exactly 1 linked input (latent_image)
|
||||||
|
expect(result.linkedInputCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test.describe('Subgraph Creation and Deletion', () => {
|
test.describe('Subgraph Creation and Deletion', () => {
|
||||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||||
await comfyPage.workflow.loadWorkflow('default')
|
await comfyPage.workflow.loadWorkflow('default')
|
||||||
|
|||||||
690
browser_tests/tests/subgraphPromotion.spec.ts
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
|
||||||
|
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||||
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
import { TestIds } from '../fixtures/selectors'
|
||||||
|
import { fitToViewInstant } from '../helpers/fitToView'
|
||||||
|
import {
|
||||||
|
getPromotedWidgetNames,
|
||||||
|
getPromotedWidgetCount,
|
||||||
|
getPromotedWidgets
|
||||||
|
} from '../helpers/promotedWidgets'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether we're currently in a subgraph.
|
||||||
|
*/
|
||||||
|
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
|
||||||
|
return comfyPage.page.evaluate(() => {
|
||||||
|
const graph = window.app!.canvas.graph
|
||||||
|
return !!graph && 'inputNode' in graph
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
|
||||||
|
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||||
|
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
|
||||||
|
const parentLink = breadcrumb.getByRole('link').first()
|
||||||
|
await expect(parentLink).toBeVisible()
|
||||||
|
await parentLink.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe(
|
||||||
|
'Subgraph Widget Promotion',
|
||||||
|
{ tag: ['@subgraph', '@widget'] },
|
||||||
|
() => {
|
||||||
|
test.describe('Auto-promotion on Convert to Subgraph', () => {
|
||||||
|
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('default')
|
||||||
|
|
||||||
|
// Select just the KSampler node (id 3) which has a "seed" widget
|
||||||
|
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||||
|
await ksampler.click('title')
|
||||||
|
const subgraphNode = await ksampler.convertToSubgraph()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// SubgraphNode should exist
|
||||||
|
expect(await subgraphNode.exists()).toBe(true)
|
||||||
|
|
||||||
|
// The KSampler has a "seed" widget which is in the recommended list.
|
||||||
|
// The promotion store should have at least the seed widget promoted.
|
||||||
|
const nodeId = String(subgraphNode.id)
|
||||||
|
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||||
|
expect(promotedNames).toContain('seed')
|
||||||
|
|
||||||
|
// SubgraphNode should have widgets (promoted views)
|
||||||
|
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
|
||||||
|
expect(widgetCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CLIPTextEncode text widget is auto-promoted', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('default')
|
||||||
|
|
||||||
|
// Select the positive CLIPTextEncode node (id 6)
|
||||||
|
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||||
|
await clipNode.click('title')
|
||||||
|
const subgraphNode = await clipNode.convertToSubgraph()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const nodeId = String(subgraphNode.id)
|
||||||
|
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||||
|
expect(promotedNames.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||||
|
// should be promoted
|
||||||
|
expect(promotedNames).toContain('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('default')
|
||||||
|
await fitToViewInstant(comfyPage)
|
||||||
|
|
||||||
|
// Select the SaveImage node (id 9 in default workflow)
|
||||||
|
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||||
|
await saveNode.click('title')
|
||||||
|
const subgraphNode = await saveNode.convertToSubgraph()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const promotedNames = await getPromotedWidgetNames(
|
||||||
|
comfyPage,
|
||||||
|
String(subgraphNode.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||||
|
expect(promotedNames).toContain('filename_prefix')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||||
|
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// The subgraph node (id 11) should have a text widget promoted
|
||||||
|
const textarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(textarea).toBeVisible()
|
||||||
|
await expect(textarea).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Multiple promoted widgets all render on SubgraphNode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const textareas = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(textareas.first()).toBeVisible()
|
||||||
|
const count = await textareas.count()
|
||||||
|
expect(count).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.vueNodes.waitForNodes()
|
||||||
|
|
||||||
|
// SubgraphNode (id 11) should render with its body
|
||||||
|
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||||
|
await expect(subgraphVueNode).toBeVisible()
|
||||||
|
|
||||||
|
// It should have the Enter Subgraph button
|
||||||
|
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||||
|
await expect(enterButton).toBeVisible()
|
||||||
|
|
||||||
|
// The promoted text widget should render inside the node
|
||||||
|
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||||
|
await expect(nodeBody).toBeVisible()
|
||||||
|
|
||||||
|
// Widgets section should exist and have at least one widget
|
||||||
|
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||||
|
await expect(widgets.first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.vueNodes.waitForNodes()
|
||||||
|
|
||||||
|
await comfyPage.vueNodes.enterSubgraph('11')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
expect(await isInSubgraph(comfyPage)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||||
|
)
|
||||||
|
await comfyPage.vueNodes.waitForNodes()
|
||||||
|
|
||||||
|
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||||
|
await expect(subgraphVueNode).toBeVisible()
|
||||||
|
|
||||||
|
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||||
|
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||||
|
const count = await widgets.count()
|
||||||
|
expect(count).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Promoted Widget Reactivity', () => {
|
||||||
|
test('Value changes on promoted widget sync to interior widget', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const testContent = 'promoted-value-sync-test'
|
||||||
|
|
||||||
|
// Type into the promoted textarea on the SubgraphNode
|
||||||
|
const textarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await textarea.fill(testContent)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Navigate into subgraph
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||||
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
|
// Interior CLIPTextEncode textarea should have the same value
|
||||||
|
const interiorTextarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(interiorTextarea).toHaveValue(testContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Value changes on interior widget sync to promoted widget', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const testContent = 'interior-value-sync-test'
|
||||||
|
|
||||||
|
// Navigate into subgraph
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||||
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
|
// Type into the interior CLIPTextEncode textarea
|
||||||
|
const interiorTextarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await interiorTextarea.fill(testContent)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Navigate back to parent graph
|
||||||
|
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||||
|
|
||||||
|
// Promoted textarea on SubgraphNode should have the same value
|
||||||
|
const promotedTextarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(promotedTextarea).toHaveValue(testContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Value persists through repeated navigation', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const testContent = 'persistence-through-navigation'
|
||||||
|
|
||||||
|
// Set value on promoted widget
|
||||||
|
const textarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await textarea.fill(testContent)
|
||||||
|
|
||||||
|
// Navigate in and out multiple times
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||||
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
const interiorTextarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(interiorTextarea).toHaveValue(testContent)
|
||||||
|
|
||||||
|
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||||
|
|
||||||
|
const promotedTextarea = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(promotedTextarea).toHaveValue(testContent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can promote a widget from inside a subgraph', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||||
|
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||||
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
|
// Get the KSampler node (id 1) inside the subgraph
|
||||||
|
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||||
|
|
||||||
|
// Right-click on the KSampler's "steps" widget (index 2) to promote it
|
||||||
|
const stepsWidget = await ksampler.getWidget(2)
|
||||||
|
const widgetPos = await stepsWidget.getPosition()
|
||||||
|
await comfyPage.canvas.click({
|
||||||
|
position: widgetPos,
|
||||||
|
button: 'right',
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Look for the Promote Widget menu entry
|
||||||
|
const promoteEntry = comfyPage.page
|
||||||
|
.locator('.litemenu-entry')
|
||||||
|
.filter({ hasText: /Promote Widget/ })
|
||||||
|
|
||||||
|
await expect(promoteEntry).toBeVisible()
|
||||||
|
await promoteEntry.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Navigate back to parent
|
||||||
|
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||||
|
|
||||||
|
// SubgraphNode should now have the promoted widget
|
||||||
|
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||||
|
expect(widgetCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can un-promote a widget from inside a subgraph', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||||
|
|
||||||
|
// First promote a canvas-rendered widget (KSampler "steps")
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||||
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
|
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||||
|
const stepsWidget = await ksampler.getWidget(2)
|
||||||
|
const widgetPos = await stepsWidget.getPosition()
|
||||||
|
|
||||||
|
await comfyPage.canvas.click({
|
||||||
|
position: widgetPos,
|
||||||
|
button: 'right',
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const promoteEntry = comfyPage.page
|
||||||
|
.locator('.litemenu-entry')
|
||||||
|
.filter({ hasText: /Promote Widget/ })
|
||||||
|
await expect(promoteEntry).toBeVisible()
|
||||||
|
await promoteEntry.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Navigate back and verify promotion took effect
|
||||||
|
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||||
|
await fitToViewInstant(comfyPage)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||||
|
expect(initialWidgetCount).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Navigate back in and un-promote
|
||||||
|
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
|
||||||
|
await subgraphNode2.navigateIntoSubgraph()
|
||||||
|
const stepsWidget2 = await (
|
||||||
|
await comfyPage.nodeOps.getNodeRefById('1')
|
||||||
|
).getWidget(2)
|
||||||
|
const widgetPos2 = await stepsWidget2.getPosition()
|
||||||
|
|
||||||
|
await comfyPage.canvas.click({
|
||||||
|
position: widgetPos2,
|
||||||
|
button: 'right',
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const unpromoteEntry = comfyPage.page
|
||||||
|
.locator('.litemenu-entry')
|
||||||
|
.filter({ hasText: /Un-Promote Widget/ })
|
||||||
|
|
||||||
|
await expect(unpromoteEntry).toBeVisible()
|
||||||
|
await unpromoteEntry.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Navigate back to parent
|
||||||
|
await exitSubgraphViaBreadcrumb(comfyPage)
|
||||||
|
|
||||||
|
// SubgraphNode should have fewer widgets
|
||||||
|
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||||
|
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Pseudo-Widget Promotion', () => {
|
||||||
|
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-preview-node'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// The SaveImage node is in the recommendedNodes list, so its
|
||||||
|
// filename_prefix widget should be auto-promoted
|
||||||
|
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||||
|
expect(promotedNames.length).toBeGreaterThan(0)
|
||||||
|
expect(promotedNames).toContain('filename_prefix')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Converting SaveImage to subgraph promotes its widgets', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow('default')
|
||||||
|
await fitToViewInstant(comfyPage)
|
||||||
|
|
||||||
|
// Select SaveImage (id 9)
|
||||||
|
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||||
|
await saveNode.click('title')
|
||||||
|
const subgraphNode = await saveNode.convertToSubgraph()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||||
|
const nodeId = String(subgraphNode.id)
|
||||||
|
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||||
|
expect(promotedNames.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
|
||||||
|
expect(widgetCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||||
|
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-compressed-target-slot'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||||
|
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||||
|
expect(
|
||||||
|
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
promotedWidgets.some(
|
||||||
|
([interiorNodeId, widgetName]) =>
|
||||||
|
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||||
|
expect(beforePromoted).toContain('text')
|
||||||
|
|
||||||
|
const serialized = await comfyPage.page.evaluate(() => {
|
||||||
|
return window.app!.graph!.serialize()
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
|
||||||
|
return window.app!.loadGraphData(workflow)
|
||||||
|
}, serialized as ComfyWorkflowJSON)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||||
|
expect(afterPromoted).toContain('text')
|
||||||
|
|
||||||
|
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||||
|
expect(widgetCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||||
|
const originalPos = await originalNode.getPosition()
|
||||||
|
|
||||||
|
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||||
|
await comfyPage.page.keyboard.down('Alt')
|
||||||
|
await comfyPage.page.mouse.down()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||||
|
await comfyPage.page.mouse.up()
|
||||||
|
await comfyPage.page.keyboard.up('Alt')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||||
|
const graph = window.app!.canvas.graph!
|
||||||
|
return graph.nodes
|
||||||
|
.filter(
|
||||||
|
(n) =>
|
||||||
|
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||||
|
)
|
||||||
|
.map((n) => String(n.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||||
|
for (const nodeId of subgraphNodeIds) {
|
||||||
|
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||||
|
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||||
|
expect(
|
||||||
|
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Vue Mode - Promoted Preview Content', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-preview-node'
|
||||||
|
)
|
||||||
|
await comfyPage.vueNodes.waitForNodes()
|
||||||
|
|
||||||
|
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||||
|
await expect(subgraphVueNode).toBeVisible()
|
||||||
|
|
||||||
|
// The node body should exist
|
||||||
|
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
|
||||||
|
await expect(nodeBody).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||||
|
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-nested-promotion'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
|
||||||
|
// slot connected externally from the Outer node, so it should be
|
||||||
|
// disabled. The remaining promoted textarea widgets (value, value_1)
|
||||||
|
// are unlinked and should be enabled.
|
||||||
|
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||||
|
expect(promotedNames).toContain('string_a')
|
||||||
|
expect(promotedNames).toContain('value')
|
||||||
|
|
||||||
|
const disabledState = await comfyPage.page.evaluate(() => {
|
||||||
|
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||||
|
return (node?.widgets ?? []).map((w) => ({
|
||||||
|
name: w.name,
|
||||||
|
disabled: !!w.computedDisabled
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
|
||||||
|
expect(linkedWidget?.disabled).toBe(true)
|
||||||
|
|
||||||
|
const unlinkedWidgets = disabledState.filter(
|
||||||
|
(w) => w.name !== 'string_a'
|
||||||
|
)
|
||||||
|
for (const w of unlinkedWidgets) {
|
||||||
|
expect(w.disabled).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-nested-promotion'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// The promoted textareas that are NOT externally linked should be
|
||||||
|
// fully opaque and interactive.
|
||||||
|
const textareas = comfyPage.page.getByTestId(
|
||||||
|
TestIds.widgets.domWidgetTextarea
|
||||||
|
)
|
||||||
|
await expect(textareas.first()).toBeVisible()
|
||||||
|
|
||||||
|
const count = await textareas.count()
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const textarea = textareas.nth(i)
|
||||||
|
const wrapper = textarea.locator('..')
|
||||||
|
const opacity = await wrapper.evaluate(
|
||||||
|
(el) => getComputedStyle(el).opacity
|
||||||
|
)
|
||||||
|
|
||||||
|
if (opacity === '1' && (await textarea.isEditable())) {
|
||||||
|
const testContent = `nested-promotion-edit-${i}`
|
||||||
|
await textarea.fill(testContent)
|
||||||
|
await expect(textarea).toHaveValue(testContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Promotion Cleanup', () => {
|
||||||
|
test('Removing subgraph node clears promotion store entries', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Verify promotions exist
|
||||||
|
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
|
||||||
|
expect(namesBefore.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Delete the subgraph node
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||||
|
await subgraphNode.click('title')
|
||||||
|
await comfyPage.page.keyboard.press('Delete')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Node no longer exists, so promoted widgets should be gone
|
||||||
|
const nodeExists = await comfyPage.page.evaluate(() => {
|
||||||
|
return !!window.app!.canvas.graph!.getNodeById('11')
|
||||||
|
})
|
||||||
|
expect(nodeExists).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Removing I/O slot removes associated promoted widget', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
|
||||||
|
await comfyPage.workflow.loadWorkflow(
|
||||||
|
'subgraphs/subgraph-with-promoted-text-widget'
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||||
|
expect(initialWidgetCount).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Navigate into subgraph
|
||||||
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||||
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
|
// Remove the text input slot
|
||||||
|
await comfyPage.subgraph.rightClickInputSlot('text')
|
||||||
|
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Navigate back via breadcrumb
|
||||||
|
await comfyPage.page
|
||||||
|
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||||
|
.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||||
|
name: 'subgraph-with-promoted-text-widget'
|
||||||
|
})
|
||||||
|
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||||
|
await homeBreadcrumb.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Widget count should be reduced
|
||||||
|
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||||
|
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 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: 63 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 107 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"version": "1.40.10",
|
"version": "1.40.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Official front-end implementation of ComfyUI",
|
"description": "Official front-end implementation of ComfyUI",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
getMediaTypeFromFilename,
|
getMediaTypeFromFilename,
|
||||||
highlightQuery,
|
highlightQuery,
|
||||||
|
isPreviewableMediaType,
|
||||||
truncateFilename
|
truncateFilename
|
||||||
} from './formatUtil'
|
} from './formatUtil'
|
||||||
|
|
||||||
@@ -56,7 +57,8 @@ describe('formatUtil', () => {
|
|||||||
{ filename: 'image.jpeg', expected: 'image' },
|
{ filename: 'image.jpeg', expected: 'image' },
|
||||||
{ filename: 'animation.gif', expected: 'image' },
|
{ filename: 'animation.gif', expected: 'image' },
|
||||||
{ filename: 'web.webp', expected: 'image' },
|
{ filename: 'web.webp', expected: 'image' },
|
||||||
{ filename: 'bitmap.bmp', expected: 'image' }
|
{ filename: 'bitmap.bmp', expected: 'image' },
|
||||||
|
{ filename: 'modern.avif', expected: 'image' }
|
||||||
]
|
]
|
||||||
|
|
||||||
it.for(imageTestCases)(
|
it.for(imageTestCases)(
|
||||||
@@ -96,26 +98,37 @@ describe('formatUtil', () => {
|
|||||||
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
||||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||||
|
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('text files', () => {
|
||||||
|
it('should identify text file extensions correctly', () => {
|
||||||
|
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
|
||||||
|
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
|
||||||
|
expect(getMediaTypeFromFilename('data.json')).toBe('text')
|
||||||
|
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
|
||||||
|
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe('edge cases', () => {
|
||||||
it('should handle empty strings', () => {
|
it('should handle empty strings', () => {
|
||||||
expect(getMediaTypeFromFilename('')).toBe('image')
|
expect(getMediaTypeFromFilename('')).toBe('other')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle files without extensions', () => {
|
it('should handle files without extensions', () => {
|
||||||
expect(getMediaTypeFromFilename('README')).toBe('image')
|
expect(getMediaTypeFromFilename('README')).toBe('other')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle unknown extensions', () => {
|
it('should handle unknown extensions', () => {
|
||||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
|
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
|
||||||
expect(getMediaTypeFromFilename('data.json')).toBe('image')
|
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle files with multiple dots', () => {
|
it('should handle files with multiple dots', () => {
|
||||||
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
||||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
|
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle paths with directories', () => {
|
it('should handle paths with directories', () => {
|
||||||
@@ -124,8 +137,8 @@ describe('formatUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle null and undefined gracefully', () => {
|
it('should handle null and undefined gracefully', () => {
|
||||||
expect(getMediaTypeFromFilename(null)).toBe('image')
|
expect(getMediaTypeFromFilename(null)).toBe('other')
|
||||||
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
expect(getMediaTypeFromFilename(undefined)).toBe('other')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle special characters in filenames', () => {
|
it('should handle special characters in filenames', () => {
|
||||||
@@ -184,4 +197,18 @@ describe('formatUtil', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isPreviewableMediaType', () => {
|
||||||
|
it('returns true for image/video/audio/3D', () => {
|
||||||
|
expect(isPreviewableMediaType('image')).toBe(true)
|
||||||
|
expect(isPreviewableMediaType('video')).toBe(true)
|
||||||
|
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||||
|
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for text/other', () => {
|
||||||
|
expect(isPreviewableMediaType('text')).toBe(false)
|
||||||
|
expect(isPreviewableMediaType('other')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string {
|
|||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
|
const IMAGE_EXTENSIONS = [
|
||||||
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'bmp',
|
||||||
|
'avif',
|
||||||
|
'tif',
|
||||||
|
'tiff'
|
||||||
|
] as const
|
||||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
|
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||||
|
const TEXT_EXTENSIONS = [
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'markdown',
|
||||||
|
'json',
|
||||||
|
'csv',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'xml',
|
||||||
|
'log'
|
||||||
|
] as const
|
||||||
|
|
||||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
|
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
|
||||||
type MediaType = (typeof MEDIA_TYPES)[number]
|
export type MediaType = (typeof MEDIA_TYPES)[number]
|
||||||
|
|
||||||
// Type guard helper for checking array membership
|
// Type guard helper for checking array membership
|
||||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||||
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
||||||
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
||||||
|
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncates a filename while preserving the extension
|
* Truncates a filename while preserving the extension
|
||||||
@@ -543,20 +565,30 @@ export function truncateFilename(
|
|||||||
/**
|
/**
|
||||||
* Determines the media type from a filename's extension (singular form)
|
* Determines the media type from a filename's extension (singular form)
|
||||||
* @param filename The filename to analyze
|
* @param filename The filename to analyze
|
||||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
|
||||||
*/
|
*/
|
||||||
export function getMediaTypeFromFilename(
|
export function getMediaTypeFromFilename(
|
||||||
filename: string | null | undefined
|
filename: string | null | undefined
|
||||||
): MediaType {
|
): MediaType {
|
||||||
if (!filename) return 'image'
|
if (!filename) return 'other'
|
||||||
const ext = filename.split('.').pop()?.toLowerCase()
|
const ext = filename.split('.').pop()?.toLowerCase()
|
||||||
if (!ext) return 'image'
|
if (!ext) return 'other'
|
||||||
|
|
||||||
// Type-safe array includes check using type assertion
|
// Type-safe array includes check using type assertion
|
||||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
||||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
||||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
||||||
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
||||||
|
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
|
||||||
|
|
||||||
return 'image'
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||||
|
return (
|
||||||
|
mediaType === 'image' ||
|
||||||
|
mediaType === 'video' ||
|
||||||
|
mediaType === 'audio' ||
|
||||||
|
mediaType === '3D'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import zipdir from 'zip-dir'
|
import zipdir from 'zip-dir'
|
||||||
|
|
||||||
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
|
const sourceDir = process.argv[2] || './dist'
|
||||||
|
const outputPath = process.argv[3] || './dist.zip'
|
||||||
|
|
||||||
|
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error zipping "dist" directory:', err)
|
console.error(`Error zipping "${sourceDir}" directory:`, err)
|
||||||
} else {
|
} else {
|
||||||
console.log('Successfully zipped "dist" directory.')
|
process.stdout.write(
|
||||||
|
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
extractFilenameFromContentDisposition
|
extractFilenameFromContentDisposition,
|
||||||
|
openFileInNewTab
|
||||||
} from '@/base/common/downloadUtil'
|
} from '@/base/common/downloadUtil'
|
||||||
|
|
||||||
let mockIsCloud = false
|
const { mockIsCloud } = vi.hoisted(() => ({
|
||||||
|
mockIsCloud: { value: false }
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/platform/distribution/types', () => ({
|
vi.mock('@/platform/distribution/types', () => ({
|
||||||
get isCloud() {
|
get isCloud() {
|
||||||
return mockIsCloud
|
return mockIsCloud.value
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/i18n', () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||||
|
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
|
||||||
|
}))
|
||||||
|
|
||||||
// Global stubs
|
// Global stubs
|
||||||
const createObjectURLSpy = vi
|
const createObjectURLSpy = vi
|
||||||
.spyOn(URL, 'createObjectURL')
|
.spyOn(URL, 'createObjectURL')
|
||||||
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
|
|||||||
let fetchMock: ReturnType<typeof vi.fn>
|
let fetchMock: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockIsCloud = false
|
mockIsCloud.value = false
|
||||||
fetchMock = vi.fn()
|
fetchMock = vi.fn()
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||||
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('streams downloads via blob when running in cloud', async () => {
|
it('streams downloads via blob when running in cloud', async () => {
|
||||||
mockIsCloud = true
|
mockIsCloud.value = true
|
||||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||||
const blob = new Blob(['test'])
|
const blob = new Blob(['test'])
|
||||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
await fetchPromise
|
await fetchPromise
|
||||||
|
await Promise.resolve() // let fetchAsBlob return
|
||||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
await blobPromise
|
await blobPromise
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('logs an error when cloud fetch fails', async () => {
|
it('logs an error when cloud fetch fails', async () => {
|
||||||
mockIsCloud = true
|
mockIsCloud.value = true
|
||||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
fetchMock.mockResolvedValue({
|
fetchMock.mockResolvedValue({
|
||||||
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
await fetchPromise
|
await fetchPromise
|
||||||
await Promise.resolve()
|
await Promise.resolve() // let fetchAsBlob throw
|
||||||
|
await Promise.resolve() // let .catch handler run
|
||||||
expect(consoleSpy).toHaveBeenCalled()
|
expect(consoleSpy).toHaveBeenCalled()
|
||||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||||
consoleSpy.mockRestore()
|
consoleSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||||
mockIsCloud = true
|
mockIsCloud.value = true
|
||||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||||
const blob = new Blob(['test'])
|
const blob = new Blob(['test'])
|
||||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
await fetchPromise
|
await fetchPromise
|
||||||
|
await Promise.resolve() // let fetchAsBlob return
|
||||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
await blobPromise
|
await blobPromise
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||||
mockIsCloud = true
|
mockIsCloud.value = true
|
||||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||||
const blob = new Blob(['test'])
|
const blob = new Blob(['test'])
|
||||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
|
|||||||
|
|
||||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
await fetchPromise
|
await fetchPromise
|
||||||
|
await Promise.resolve() // let fetchAsBlob return
|
||||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
await blobPromise
|
await blobPromise
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||||
mockIsCloud = true
|
mockIsCloud.value = true
|
||||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||||
const blob = new Blob(['test'])
|
const blob = new Blob(['test'])
|
||||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
|
|||||||
|
|
||||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
await fetchPromise
|
await fetchPromise
|
||||||
|
await Promise.resolve() // let fetchAsBlob return
|
||||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
await blobPromise
|
await blobPromise
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('openFileInNewTab', () => {
|
||||||
|
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens URL directly when not in cloud mode', async () => {
|
||||||
|
mockIsCloud.value = false
|
||||||
|
const testUrl = 'https://example.com/image.png'
|
||||||
|
|
||||||
|
await openFileInNewTab(testUrl)
|
||||||
|
|
||||||
|
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
|
||||||
|
mockIsCloud.value = true
|
||||||
|
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||||
|
const blob = new Blob(['test'], { type: 'image/png' })
|
||||||
|
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||||
|
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
blob: vi.fn().mockResolvedValue(blob)
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await openFileInNewTab(testUrl)
|
||||||
|
|
||||||
|
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||||
|
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||||
|
expect(mockTab.location.href).toBe('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokes blob URL after timeout in cloud mode', async () => {
|
||||||
|
mockIsCloud.value = true
|
||||||
|
const blob = new Blob(['test'], { type: 'image/png' })
|
||||||
|
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||||
|
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
blob: vi.fn().mockResolvedValue(blob)
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await openFileInNewTab('https://example.com/image.png')
|
||||||
|
|
||||||
|
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes blank tab and logs error when cloud fetch fails', async () => {
|
||||||
|
mockIsCloud.value = true
|
||||||
|
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||||
|
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await openFileInNewTab(testUrl)
|
||||||
|
|
||||||
|
expect(mockTab.close).toHaveBeenCalled()
|
||||||
|
expect(consoleSpy).toHaveBeenCalled()
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokes blob URL immediately if tab was closed by user', async () => {
|
||||||
|
mockIsCloud.value = true
|
||||||
|
const blob = new Blob(['test'], { type: 'image/png' })
|
||||||
|
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||||
|
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
blob: vi.fn().mockResolvedValue(blob)
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await openFileInNewTab('https://example.com/image.png')
|
||||||
|
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
expect(mockTab.location.href).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('extractFilenameFromContentDisposition', () => {
|
describe('extractFilenameFromContentDisposition', () => {
|
||||||
it('returns null for null header', () => {
|
it('returns null for null header', () => {
|
||||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Utility functions for downloading files
|
* Utility functions for downloading files
|
||||||
*/
|
*/
|
||||||
|
import { t } from '@/i18n'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||||
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadViaBlobFetch = async (
|
/**
|
||||||
|
* Fetch a URL and return its body as a Blob.
|
||||||
|
* Shared by download and open-in-new-tab cloud paths.
|
||||||
|
*/
|
||||||
|
async function fetchAsBlob(url: string): Promise<Response> {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadViaBlobFetch(
|
||||||
href: string,
|
href: string,
|
||||||
fallbackFilename: string
|
fallbackFilename: string
|
||||||
): Promise<void> => {
|
): Promise<void> {
|
||||||
const response = await fetch(href)
|
const response = await fetchAsBlob(href)
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get filename from Content-Disposition header (set by backend)
|
// Try to get filename from Content-Disposition header (set by backend)
|
||||||
const contentDisposition = response.headers.get('Content-Disposition')
|
const contentDisposition = response.headers.get('Content-Disposition')
|
||||||
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
|
|||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a file URL in a new browser tab.
|
||||||
|
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
||||||
|
* that would trigger an auto-download instead of displaying the file.
|
||||||
|
*
|
||||||
|
* Opens the tab synchronously to preserve the user-gesture context
|
||||||
|
* (browsers block window.open after an await), then navigates it to
|
||||||
|
* the blob URL once the fetch completes.
|
||||||
|
*/
|
||||||
|
export async function openFileInNewTab(url: string): Promise<void> {
|
||||||
|
if (!isCloud) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open immediately to preserve user-gesture activation.
|
||||||
|
const tab = window.open('', '_blank')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAsBlob(url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
if (tab && !tab.closed) {
|
||||||
|
tab.location.href = blobUrl
|
||||||
|
// Revoke after the tab has had time to load the blob.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(blobUrl)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
tab?.close()
|
||||||
|
console.error('Failed to open image:', error)
|
||||||
|
useToastStore().addAlert(
|
||||||
|
t('toastMessages.errorOpenImage', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
|
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||||
const settingStore = useSettingStore(pinia)
|
const settingStore = useSettingStore(pinia)
|
||||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||||
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
|
|||||||
|
|
||||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||||
|
|
||||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
|
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||||
const settingStore = useSettingStore(pinia)
|
const settingStore = useSettingStore(pinia)
|
||||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||||
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
|
|||||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||||
|
|
||||||
await toggleButton.trigger('click')
|
await toggleButton.trigger('click')
|
||||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||||
|
|
||||||
await toggleButton.trigger('click')
|
await toggleButton.trigger('click')
|
||||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||||
@@ -296,11 +296,13 @@ describe('TopMenuSection', () => {
|
|||||||
describe('inline progress summary', () => {
|
describe('inline progress summary', () => {
|
||||||
const configureSettings = (
|
const configureSettings = (
|
||||||
pinia: ReturnType<typeof createTestingPinia>,
|
pinia: ReturnType<typeof createTestingPinia>,
|
||||||
qpoV2Enabled: boolean
|
qpoV2Enabled: boolean,
|
||||||
|
showRunProgressBar = true
|
||||||
) => {
|
) => {
|
||||||
const settingStore = useSettingStore(pinia)
|
const settingStore = useSettingStore(pinia)
|
||||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||||
|
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
|
||||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
@@ -332,6 +334,19 @@ describe('TopMenuSection', () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not render inline progress summary when run progress bar is disabled', async () => {
|
||||||
|
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||||
|
configureSettings(pinia, true, false)
|
||||||
|
|
||||||
|
const wrapper = createWrapper({ pinia })
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('teleports inline progress summary when actionbar is floating', async () => {
|
it('teleports inline progress summary when actionbar is floating', async () => {
|
||||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||||
const actionbarTarget = document.createElement('div')
|
const actionbarTarget = document.createElement('div')
|
||||||
|
|||||||
@@ -56,43 +56,6 @@
|
|||||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||||
@update:progress-target="updateProgressTarget"
|
@update:progress-target="updateProgressTarget"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
|
||||||
type="destructive"
|
|
||||||
size="md"
|
|
||||||
:aria-pressed="
|
|
||||||
isQueuePanelV2Enabled
|
|
||||||
? activeSidebarTabId === 'assets'
|
|
||||||
: isQueueProgressOverlayEnabled
|
|
||||||
? isQueueOverlayExpanded
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
class="relative px-3"
|
|
||||||
data-testid="queue-overlay-toggle"
|
|
||||||
@click="toggleQueueOverlay"
|
|
||||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-normal tabular-nums">
|
|
||||||
{{ activeJobsLabel }}
|
|
||||||
</span>
|
|
||||||
<StatusBadge
|
|
||||||
v-if="activeJobsCount > 0"
|
|
||||||
data-testid="active-jobs-indicator"
|
|
||||||
variant="dot"
|
|
||||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
|
||||||
/>
|
|
||||||
<span class="sr-only">
|
|
||||||
{{
|
|
||||||
isQueuePanelV2Enabled
|
|
||||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
|
||||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<ContextMenu
|
|
||||||
ref="queueContextMenu"
|
|
||||||
:model="queueContextMenuItems"
|
|
||||||
/>
|
|
||||||
<CurrentUserButton
|
<CurrentUserButton
|
||||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@@ -127,13 +90,15 @@
|
|||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
|
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
|
||||||
>
|
>
|
||||||
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
|
<QueueInlineProgressSummary
|
||||||
|
:hidden="shouldHideInlineProgressSummary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<QueueInlineProgressSummary
|
<QueueInlineProgressSummary
|
||||||
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
||||||
class="pr-1"
|
class="pr-1"
|
||||||
:hidden="isQueueOverlayExpanded"
|
:hidden="shouldHideInlineProgressSummary"
|
||||||
/>
|
/>
|
||||||
<QueueNotificationBannerHost
|
<QueueNotificationBannerHost
|
||||||
v-if="shouldShowQueueNotificationBanners"
|
v-if="shouldShowQueueNotificationBanners"
|
||||||
@@ -146,14 +111,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import ContextMenu from 'primevue/contextmenu'
|
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
|
||||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||||
@@ -163,15 +125,14 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
|||||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useQueueUIStore } from '@/stores/queueStore'
|
||||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
import { isDesktop } from '@/platform/distribution/types'
|
import { isDesktop } from '@/platform/distribution/types'
|
||||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||||
@@ -184,16 +145,11 @@ const workspaceStore = useWorkspaceStore()
|
|||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
const managerState = useManagerState()
|
const managerState = useManagerState()
|
||||||
const { isLoggedIn } = useCurrentUser()
|
const { isLoggedIn } = useCurrentUser()
|
||||||
const { t, n } = useI18n()
|
const { t } = useI18n()
|
||||||
const { toastErrorHandler } = useErrorHandling()
|
const { toastErrorHandler } = useErrorHandling()
|
||||||
const commandStore = useCommandStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const queueStore = useQueueStore()
|
|
||||||
const executionStore = useExecutionStore()
|
|
||||||
const queueUIStore = useQueueUIStore()
|
const queueUIStore = useQueueUIStore()
|
||||||
const sidebarTabStore = useSidebarTabStore()
|
|
||||||
const { activeJobsCount } = storeToRefs(queueStore)
|
|
||||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
|
||||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||||
useConflictAcknowledgment()
|
useConflictAcknowledgment()
|
||||||
const isTopMenuHovered = ref(false)
|
const isTopMenuHovered = ref(false)
|
||||||
@@ -206,25 +162,19 @@ const isActionbarEnabled = computed(
|
|||||||
const isActionbarFloating = computed(
|
const isActionbarFloating = computed(
|
||||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||||
)
|
)
|
||||||
const activeJobsLabel = computed(() => {
|
|
||||||
const count = activeJobsCount.value
|
|
||||||
return t(
|
|
||||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
|
||||||
{ count: n(count) },
|
|
||||||
count
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const isIntegratedTabBar = computed(
|
const isIntegratedTabBar = computed(
|
||||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||||
)
|
)
|
||||||
const isQueuePanelV2Enabled = computed(() =>
|
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||||
settingStore.get('Comfy.Queue.QPOV2')
|
useQueueFeatureFlags()
|
||||||
)
|
|
||||||
const isQueueProgressOverlayEnabled = computed(
|
const isQueueProgressOverlayEnabled = computed(
|
||||||
() => !isQueuePanelV2Enabled.value
|
() => !isQueuePanelV2Enabled.value
|
||||||
)
|
)
|
||||||
const shouldShowInlineProgressSummary = computed(
|
const shouldShowInlineProgressSummary = computed(
|
||||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
() =>
|
||||||
|
isQueuePanelV2Enabled.value &&
|
||||||
|
isActionbarEnabled.value &&
|
||||||
|
isRunProgressBarEnabled.value
|
||||||
)
|
)
|
||||||
const shouldShowQueueNotificationBanners = computed(
|
const shouldShowQueueNotificationBanners = computed(
|
||||||
() => isActionbarEnabled.value
|
() => isActionbarEnabled.value
|
||||||
@@ -239,30 +189,18 @@ const inlineProgressSummaryTarget = computed(() => {
|
|||||||
}
|
}
|
||||||
return progressTarget.value
|
return progressTarget.value
|
||||||
})
|
})
|
||||||
const queueHistoryTooltipConfig = computed(() =>
|
const shouldHideInlineProgressSummary = computed(
|
||||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
|
||||||
)
|
)
|
||||||
const customNodesManagerTooltipConfig = computed(() =>
|
const customNodesManagerTooltipConfig = computed(() =>
|
||||||
buildTooltipConfig(t('menu.manageExtensions'))
|
buildTooltipConfig(t('menu.manageExtensions'))
|
||||||
)
|
)
|
||||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
|
||||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
|
||||||
{
|
|
||||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
|
||||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
|
||||||
class: '*:text-destructive-background',
|
|
||||||
disabled: queueStore.pendingTasks.length === 0,
|
|
||||||
command: () => {
|
|
||||||
void handleClearQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const shouldShowRedDot = computed((): boolean => {
|
const shouldShowRedDot = computed((): boolean => {
|
||||||
return shouldShowConflictRedDot.value
|
return shouldShowConflictRedDot.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const { hasAnyError } = storeToRefs(executionStore)
|
const { hasAnyError } = storeToRefs(executionErrorStore)
|
||||||
|
|
||||||
// Right side panel toggle
|
// Right side panel toggle
|
||||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||||
@@ -279,27 +217,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleQueueOverlay = () => {
|
|
||||||
if (isQueuePanelV2Enabled.value) {
|
|
||||||
sidebarTabStore.toggleSidebarTab('assets')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
|
||||||
}
|
|
||||||
|
|
||||||
const showQueueContextMenu = (event: MouseEvent) => {
|
|
||||||
queueContextMenu.value?.show(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearQueue = async () => {
|
|
||||||
const pendingJobIds = queueStore.pendingTasks
|
|
||||||
.map((task) => task.jobId)
|
|
||||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
||||||
|
|
||||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
|
||||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCustomNodeManager = async () => {
|
const openCustomNodeManager = async () => {
|
||||||
try {
|
try {
|
||||||
await managerState.openManager({
|
await managerState.openManager({
|
||||||
|
|||||||
98
src/components/actionbar/BatchCountEdit.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
import BatchCountEdit from './BatchCountEdit.vue'
|
||||||
|
|
||||||
|
const maxBatchCount = 16
|
||||||
|
|
||||||
|
vi.mock('@/platform/settings/settingStore', () => ({
|
||||||
|
useSettingStore: () => ({
|
||||||
|
get: (settingId: string) =>
|
||||||
|
settingId === 'Comfy.QueueButton.BatchCountLimit' ? maxBatchCount : 1
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
g: {
|
||||||
|
increment: 'Increment',
|
||||||
|
decrement: 'Decrement'
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
batchCount: 'Batch Count'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function createWrapper(initialBatchCount = 1) {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
createSpy: vi.fn,
|
||||||
|
stubActions: false,
|
||||||
|
initialState: {
|
||||||
|
queueSettingsStore: {
|
||||||
|
batchCount: initialBatchCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(BatchCountEdit, {
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, i18n],
|
||||||
|
directives: {
|
||||||
|
tooltip: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queueSettingsStore = useQueueSettingsStore()
|
||||||
|
|
||||||
|
return { wrapper, queueSettingsStore }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BatchCountEdit', () => {
|
||||||
|
it('doubles the current batch count when increment is clicked', async () => {
|
||||||
|
const { wrapper, queueSettingsStore } = createWrapper(3)
|
||||||
|
|
||||||
|
await wrapper.get('button[aria-label="Increment"]').trigger('click')
|
||||||
|
|
||||||
|
expect(queueSettingsStore.batchCount).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('halves the current batch count when decrement is clicked', async () => {
|
||||||
|
const { wrapper, queueSettingsStore } = createWrapper(9)
|
||||||
|
|
||||||
|
await wrapper.get('button[aria-label="Decrement"]').trigger('click')
|
||||||
|
|
||||||
|
expect(queueSettingsStore.batchCount).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps typed values to queue limits on blur', async () => {
|
||||||
|
const { wrapper, queueSettingsStore } = createWrapper(2)
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
|
await input.setValue('999')
|
||||||
|
await input.trigger('blur')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(queueSettingsStore.batchCount).toBe(maxBatchCount)
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe(
|
||||||
|
String(maxBatchCount)
|
||||||
|
)
|
||||||
|
|
||||||
|
await input.setValue('0')
|
||||||
|
await input.trigger('blur')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(queueSettingsStore.batchCount).toBe(1)
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,71 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-tooltip.bottom="{
|
v-tooltip.bottom="{
|
||||||
value: $t('menu.batchCount'),
|
value: t('menu.batchCount'),
|
||||||
showDelay: 600
|
showDelay: 600
|
||||||
}"
|
}"
|
||||||
class="batch-count"
|
class="batch-count h-full"
|
||||||
:aria-label="$t('menu.batchCount')"
|
:aria-label="t('menu.batchCount')"
|
||||||
>
|
>
|
||||||
<InputNumber
|
<div
|
||||||
v-model="batchCount"
|
class="flex h-full w-14 overflow-hidden rounded-l-lg bg-secondary-background"
|
||||||
class="w-14"
|
>
|
||||||
:min="minQueueCount"
|
<input
|
||||||
:max="maxQueueCount"
|
ref="batchCountInputRef"
|
||||||
fluid
|
v-model="batchCountInput"
|
||||||
show-buttons
|
type="text"
|
||||||
:pt="{
|
inputmode="numeric"
|
||||||
incrementButton: {
|
:aria-label="t('menu.batchCount')"
|
||||||
class: 'w-6',
|
:class="inputClass"
|
||||||
onmousedown: () => {
|
@focus="onInputFocus"
|
||||||
handleClick(true)
|
@input="onInput"
|
||||||
}
|
@blur="onInputBlur"
|
||||||
},
|
@keydown.enter.prevent="onInputEnter"
|
||||||
decrementButton: {
|
/>
|
||||||
class: 'w-6',
|
<div class="flex h-full w-6 flex-col">
|
||||||
onmousedown: () => {
|
<Button
|
||||||
handleClick(false)
|
variant="secondary"
|
||||||
}
|
size="unset"
|
||||||
}
|
:aria-label="t('g.increment')"
|
||||||
}"
|
:class="cn(stepButtonClass, incrementButtonClass)"
|
||||||
/>
|
:disabled="isIncrementDisabled"
|
||||||
|
@click="incrementBatchCount"
|
||||||
|
>
|
||||||
|
<TinyChevronIcon rotate-up />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="unset"
|
||||||
|
:aria-label="t('g.decrement')"
|
||||||
|
:class="cn(stepButtonClass, decrementButtonClass)"
|
||||||
|
:disabled="isDecrementDisabled"
|
||||||
|
@click="decrementBatchCount"
|
||||||
|
>
|
||||||
|
<TinyChevronIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import InputNumber from 'primevue/inputnumber'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import TinyChevronIcon from './TinyChevronIcon.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const queueSettingsStore = useQueueSettingsStore()
|
const queueSettingsStore = useQueueSettingsStore()
|
||||||
const { batchCount } = storeToRefs(queueSettingsStore)
|
const { batchCount } = storeToRefs(queueSettingsStore)
|
||||||
const minQueueCount = 1
|
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
const minQueueCount = 1
|
||||||
const maxQueueCount = computed(() =>
|
const maxQueueCount = computed(() =>
|
||||||
settingStore.get('Comfy.QueueButton.BatchCountLimit')
|
settingStore.get('Comfy.QueueButton.BatchCountLimit')
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = (increment: boolean) => {
|
const batchCountInputRef = ref<HTMLInputElement | null>(null)
|
||||||
let newCount: number
|
const batchCountInput = ref(String(batchCount.value))
|
||||||
if (increment) {
|
const isEditing = ref(false)
|
||||||
const originalCount = batchCount.value - 1
|
|
||||||
newCount = Math.min(originalCount * 2, maxQueueCount.value)
|
|
||||||
} else {
|
|
||||||
const originalCount = batchCount.value + 1
|
|
||||||
newCount = Math.floor(originalCount / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
batchCount.value = newCount
|
const isIncrementDisabled = computed(
|
||||||
|
() => batchCount.value >= maxQueueCount.value
|
||||||
|
)
|
||||||
|
const isDecrementDisabled = computed(() => batchCount.value <= minQueueCount)
|
||||||
|
const inputClass =
|
||||||
|
'h-full min-w-0 flex-1 border-none bg-secondary-background pl-1 pr-0 text-center text-sm font-normal tabular-nums text-base-foreground outline-none'
|
||||||
|
const stepButtonClass =
|
||||||
|
'h-1/2 w-full rounded-none border-none p-0 text-muted-foreground hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
const incrementButtonClass = 'rounded-tr-none border-b border-border-subtle'
|
||||||
|
const decrementButtonClass = 'rounded-br-none'
|
||||||
|
|
||||||
|
watch(batchCount, (nextBatchCount) => {
|
||||||
|
if (!isEditing.value) {
|
||||||
|
batchCountInput.value = String(nextBatchCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const clampBatchCount = (nextBatchCount: number): number =>
|
||||||
|
Math.min(Math.max(nextBatchCount, minQueueCount), maxQueueCount.value)
|
||||||
|
|
||||||
|
const setBatchCount = (nextBatchCount: number) => {
|
||||||
|
batchCount.value = clampBatchCount(nextBatchCount)
|
||||||
|
batchCountInput.value = String(batchCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const incrementBatchCount = () => {
|
||||||
|
setBatchCount(batchCount.value * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrementBatchCount = () => {
|
||||||
|
setBatchCount(Math.floor(batchCount.value / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputFocus = () => {
|
||||||
|
isEditing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
batchCountInput.value = input.value.replace(/[^0-9]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputBlur = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
const parsedInput = Number.parseInt(batchCountInput.value, 10)
|
||||||
|
setBatchCount(Number.isNaN(parsedInput) ? minQueueCount : parsedInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputEnter = () => {
|
||||||
|
batchCountInputRef.value?.blur()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.p-inputtext) {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
101
src/components/actionbar/ComfyActionbar.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
|
||||||
|
const configureSettings = (
|
||||||
|
pinia: ReturnType<typeof createTestingPinia>,
|
||||||
|
showRunProgressBar: boolean
|
||||||
|
) => {
|
||||||
|
const settingStore = useSettingStore(pinia)
|
||||||
|
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||||
|
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||||
|
if (key === 'Comfy.Queue.QPOV2') return true
|
||||||
|
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountActionbar = (showRunProgressBar: boolean) => {
|
||||||
|
const topMenuContainer = document.createElement('div')
|
||||||
|
document.body.appendChild(topMenuContainer)
|
||||||
|
|
||||||
|
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||||
|
configureSettings(pinia, showRunProgressBar)
|
||||||
|
|
||||||
|
const wrapper = mount(ComfyActionbar, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
topMenuContainer,
|
||||||
|
queueOverlayExpanded: false
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, i18n],
|
||||||
|
stubs: {
|
||||||
|
ContextMenu: {
|
||||||
|
name: 'ContextMenu',
|
||||||
|
template: '<div />'
|
||||||
|
},
|
||||||
|
Panel: {
|
||||||
|
name: 'Panel',
|
||||||
|
template: '<div><slot /></div>'
|
||||||
|
},
|
||||||
|
StatusBadge: true,
|
||||||
|
ComfyRunButton: {
|
||||||
|
name: 'ComfyRunButton',
|
||||||
|
template: '<button type="button">Run</button>'
|
||||||
|
},
|
||||||
|
QueueInlineProgress: true
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
tooltip: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
wrapper,
|
||||||
|
topMenuContainer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ComfyActionbar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.global.locale.value = 'en'
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('teleports inline progress when run progress bar is enabled', async () => {
|
||||||
|
const { wrapper, topMenuContainer } = mountActionbar(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
topMenuContainer.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not teleport inline progress when run progress bar is disabled', async () => {
|
||||||
|
const { wrapper, topMenuContainer } = mountActionbar(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
|
||||||
|
).toBeNull()
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
topMenuContainer.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,12 +42,44 @@
|
|||||||
>
|
>
|
||||||
<i class="icon-[lucide--x] size-4" />
|
<i class="icon-[lucide--x] size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
:aria-pressed="
|
||||||
|
isQueuePanelV2Enabled
|
||||||
|
? activeSidebarTabId === 'job-history'
|
||||||
|
: queueOverlayExpanded
|
||||||
|
"
|
||||||
|
class="relative px-3"
|
||||||
|
data-testid="queue-overlay-toggle"
|
||||||
|
@click="toggleQueueOverlay"
|
||||||
|
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-normal tabular-nums">
|
||||||
|
{{ activeJobsLabel }}
|
||||||
|
</span>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="activeJobsCount > 0"
|
||||||
|
data-testid="active-jobs-indicator"
|
||||||
|
variant="dot"
|
||||||
|
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||||
|
/>
|
||||||
|
<span class="sr-only">
|
||||||
|
{{
|
||||||
|
isQueuePanelV2Enabled
|
||||||
|
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||||
|
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||||
<QueueInlineProgress
|
<QueueInlineProgress
|
||||||
:hidden="queueOverlayExpanded"
|
:hidden="shouldHideInlineProgress"
|
||||||
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
|
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
|
||||||
data-testid="queue-inline-progress"
|
data-testid="queue-inline-progress"
|
||||||
/>
|
/>
|
||||||
@@ -65,18 +97,24 @@ import {
|
|||||||
} from '@vueuse/core'
|
} from '@vueuse/core'
|
||||||
import { clamp } from 'es-toolkit/compat'
|
import { clamp } from 'es-toolkit/compat'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import Panel from 'primevue/panel'
|
import Panel from 'primevue/panel'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import type { ComponentPublicInstance } from 'vue'
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import ComfyRunButton from './ComfyRunButton'
|
import ComfyRunButton from './ComfyRunButton'
|
||||||
@@ -90,16 +128,20 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:progressTarget', target: HTMLElement | null): void
|
(event: 'update:progressTarget', target: HTMLElement | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const settingsStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const { t } = useI18n()
|
const executionStore = useExecutionStore()
|
||||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
const queueStore = useQueueStore()
|
||||||
|
const sidebarTabStore = useSidebarTabStore()
|
||||||
|
const { t, n } = useI18n()
|
||||||
|
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||||
|
const { activeJobsCount } = storeToRefs(queueStore)
|
||||||
|
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||||
|
|
||||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||||
const visible = computed(() => position.value !== 'Disabled')
|
const visible = computed(() => position.value !== 'Disabled')
|
||||||
const isQueuePanelV2Enabled = computed(() =>
|
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||||
settingsStore.get('Comfy.Queue.QPOV2')
|
useQueueFeatureFlags()
|
||||||
)
|
|
||||||
|
|
||||||
const panelRef = ref<ComponentPublicInstance | null>(null)
|
const panelRef = ref<ComponentPublicInstance | null>(null)
|
||||||
const panelElement = computed<HTMLElement | null>(() => {
|
const panelElement = computed<HTMLElement | null>(() => {
|
||||||
@@ -283,10 +325,19 @@ const onMouseLeaveDropZone = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inlineProgressTarget = computed(() => {
|
const inlineProgressTarget = computed(() => {
|
||||||
if (!visible.value || !isQueuePanelV2Enabled.value) return null
|
if (
|
||||||
|
!visible.value ||
|
||||||
|
!isQueuePanelV2Enabled.value ||
|
||||||
|
!isRunProgressBarEnabled.value
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (isDocked.value) return topMenuContainer ?? null
|
if (isDocked.value) return topMenuContainer ?? null
|
||||||
return panelElement.value
|
return panelElement.value
|
||||||
})
|
})
|
||||||
|
const shouldHideInlineProgress = computed(
|
||||||
|
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
|
||||||
|
)
|
||||||
watch(
|
watch(
|
||||||
panelElement,
|
panelElement,
|
||||||
(target) => {
|
(target) => {
|
||||||
@@ -315,11 +366,52 @@ watch(isDragging, (dragging) => {
|
|||||||
const cancelJobTooltipConfig = computed(() =>
|
const cancelJobTooltipConfig = computed(() =>
|
||||||
buildTooltipConfig(t('menu.interrupt'))
|
buildTooltipConfig(t('menu.interrupt'))
|
||||||
)
|
)
|
||||||
|
const queueHistoryTooltipConfig = computed(() =>
|
||||||
|
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||||
|
)
|
||||||
|
const activeJobsLabel = computed(() => {
|
||||||
|
const count = activeJobsCount.value
|
||||||
|
return t(
|
||||||
|
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||||
|
{ count: n(count) },
|
||||||
|
count
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||||
|
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||||
|
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||||
|
class: '*:text-destructive-background',
|
||||||
|
disabled: queueStore.pendingTasks.length === 0,
|
||||||
|
command: () => {
|
||||||
|
void handleClearQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
const cancelCurrentJob = async () => {
|
const cancelCurrentJob = async () => {
|
||||||
if (isExecutionIdle.value) return
|
if (isExecutionIdle.value) return
|
||||||
await commandStore.execute('Comfy.Interrupt')
|
await commandStore.execute('Comfy.Interrupt')
|
||||||
}
|
}
|
||||||
|
const toggleQueueOverlay = () => {
|
||||||
|
if (isQueuePanelV2Enabled.value) {
|
||||||
|
sidebarTabStore.toggleSidebarTab('job-history')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||||
|
}
|
||||||
|
const showQueueContextMenu = (event: MouseEvent) => {
|
||||||
|
queueContextMenu.value?.show(event)
|
||||||
|
}
|
||||||
|
const handleClearQueue = async () => {
|
||||||
|
const pendingJobIds = queueStore.pendingTasks
|
||||||
|
.map((task) => task.jobId)
|
||||||
|
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||||
|
|
||||||
|
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||||
|
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||||
|
}
|
||||||
|
|
||||||
const actionbarClass = computed(() =>
|
const actionbarClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { defineComponent, nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -41,28 +41,9 @@ vi.mock('@/stores/workspaceStore', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const SplitButtonStub = defineComponent({
|
const BatchCountEditStub = {
|
||||||
name: 'SplitButton',
|
template: '<div data-testid="batch-count-edit" />'
|
||||||
props: {
|
}
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
severity: {
|
|
||||||
type: String,
|
|
||||||
default: 'primary'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<button
|
|
||||||
data-testid="split-button"
|
|
||||||
:data-label="label"
|
|
||||||
:data-severity="severity"
|
|
||||||
>
|
|
||||||
<slot name="icon" />
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
@@ -107,14 +88,26 @@ function createWrapper() {
|
|||||||
tooltip: () => {}
|
tooltip: () => {}
|
||||||
},
|
},
|
||||||
stubs: {
|
stubs: {
|
||||||
SplitButton: SplitButtonStub,
|
BatchCountEdit: BatchCountEditStub,
|
||||||
BatchCountEdit: true
|
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||||
|
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ComfyQueueButton', () => {
|
describe('ComfyQueueButton', () => {
|
||||||
|
it('renders the batch count control before the run button', () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
const controls = wrapper.get('.queue-button-group').element.children
|
||||||
|
|
||||||
|
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
|
||||||
|
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the run instant presentation while idle even with active jobs', async () => {
|
it('keeps the run instant presentation while idle even with active jobs', async () => {
|
||||||
const wrapper = createWrapper()
|
const wrapper = createWrapper()
|
||||||
const queueSettingsStore = useQueueSettingsStore()
|
const queueSettingsStore = useQueueSettingsStore()
|
||||||
@@ -124,10 +117,10 @@ describe('ComfyQueueButton', () => {
|
|||||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||||
|
|
||||||
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
|
expect(queueButton.text()).toContain('Run (Instant)')
|
||||||
expect(splitButton.attributes('data-severity')).toBe('primary')
|
expect(queueButton.attributes('data-variant')).toBe('primary')
|
||||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,10 +131,10 @@ describe('ComfyQueueButton', () => {
|
|||||||
queueSettingsStore.mode = 'instant-running'
|
queueSettingsStore.mode = 'instant-running'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||||
|
|
||||||
expect(splitButton.attributes('data-label')).toBe('Stop Run (Instant)')
|
expect(queueButton.text()).toContain('Stop Run (Instant)')
|
||||||
expect(splitButton.attributes('data-severity')).toBe('danger')
|
expect(queueButton.attributes('data-variant')).toBe('destructive')
|
||||||
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
|
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,19 +152,17 @@ describe('ComfyQueueButton', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||||
const splitButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
|
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
|
||||||
expect(splitButtonWhileStopping.attributes('data-label')).toBe(
|
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
|
||||||
'Run (Instant)'
|
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
|
||||||
)
|
|
||||||
expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
|
|
||||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||||
|
|
||||||
expect(commandStore.execute).not.toHaveBeenCalled()
|
expect(commandStore.execute).not.toHaveBeenCalled()
|
||||||
|
|
||||||
const splitButton = wrapper.get('[data-testid="queue-button"]')
|
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||||
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
|
expect(queueButton.text()).toContain('Run (Instant)')
|
||||||
expect(splitButton.attributes('data-severity')).toBe('primary')
|
expect(queueButton.attributes('data-variant')).toBe('primary')
|
||||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,83 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="queue-button-group flex">
|
<ButtonGroup
|
||||||
<SplitButton
|
class="queue-button-group h-8 rounded-lg bg-secondary-background"
|
||||||
|
>
|
||||||
|
<BatchCountEdit />
|
||||||
|
<Button
|
||||||
v-tooltip.bottom="{
|
v-tooltip.bottom="{
|
||||||
value: queueButtonTooltip,
|
value: queueButtonTooltip,
|
||||||
showDelay: 600
|
showDelay: 600
|
||||||
}"
|
}"
|
||||||
class="comfyui-queue-button"
|
:variant="queueButtonVariant"
|
||||||
:label="queueButtonLabel"
|
size="unset"
|
||||||
:severity="queueButtonSeverity"
|
:class="queueActionButtonClass"
|
||||||
size="small"
|
|
||||||
:model="queueModeMenuItems"
|
|
||||||
data-testid="queue-button"
|
data-testid="queue-button"
|
||||||
|
:data-variant="queueButtonVariant"
|
||||||
@click="queuePrompt"
|
@click="queuePrompt"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<i :class="cn(iconClass, 'size-4')" />
|
||||||
<i :class="iconClass" />
|
{{ queueButtonLabel }}
|
||||||
</template>
|
</Button>
|
||||||
<template #item="{ item }">
|
|
||||||
|
<DropdownMenuRoot>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip="{
|
variant="secondary"
|
||||||
value: item.tooltip,
|
size="unset"
|
||||||
showDelay: 600
|
:class="queueMenuTriggerClass"
|
||||||
}"
|
:aria-label="t('menu.run')"
|
||||||
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
|
data-testid="queue-mode-menu-trigger"
|
||||||
size="sm"
|
|
||||||
class="w-full justify-start"
|
|
||||||
>
|
>
|
||||||
<i v-if="item.icon" :class="item.icon" />
|
<TinyChevronIcon />
|
||||||
{{ String(item.label ?? '') }}
|
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</DropdownMenuTrigger>
|
||||||
</SplitButton>
|
<DropdownMenuPortal>
|
||||||
<BatchCountEdit />
|
<DropdownMenuContent
|
||||||
</div>
|
:side-offset="4"
|
||||||
|
class="z-1000 min-w-44 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="item in queueModeMenuItems"
|
||||||
|
:key="item.key"
|
||||||
|
as-child
|
||||||
|
@select.prevent="item.command"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-tooltip="{
|
||||||
|
value: item.tooltip,
|
||||||
|
showDelay: 600
|
||||||
|
}"
|
||||||
|
:variant="
|
||||||
|
item.key === selectedQueueMode ? 'primary' : 'secondary'
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
:class="queueMenuItemButtonClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
</ButtonGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRoot,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from 'reka-ui'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
|
||||||
import SplitButton from 'primevue/splitbutton'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
|
||||||
|
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
@@ -54,10 +89,9 @@ import {
|
|||||||
useQueueSettingsStore
|
useQueueSettingsStore
|
||||||
} from '@/stores/queueStore'
|
} from '@/stores/queueStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||||
|
|
||||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
|
||||||
|
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||||
|
|
||||||
@@ -69,50 +103,60 @@ const hasMissingNodes = computed(() =>
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
|
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
|
||||||
|
|
||||||
|
interface QueueModeMenuItem {
|
||||||
|
key: QueueModeMenuKey
|
||||||
|
label: string
|
||||||
|
tooltip: string
|
||||||
|
command: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const selectedQueueMode = computed<QueueModeMenuKey>(() =>
|
const selectedQueueMode = computed<QueueModeMenuKey>(() =>
|
||||||
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
|
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const queueModeMenuItemLookup = computed(() => {
|
const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||||
const items: Record<string, MenuItem> = {
|
() => {
|
||||||
disabled: {
|
const items: Record<string, QueueModeMenuItem> = {
|
||||||
key: 'disabled',
|
disabled: {
|
||||||
label: t('menu.run'),
|
key: 'disabled',
|
||||||
tooltip: t('menu.disabledTooltip'),
|
label: t('menu.run'),
|
||||||
command: () => {
|
tooltip: t('menu.disabledTooltip'),
|
||||||
queueMode.value = 'disabled'
|
command: () => {
|
||||||
}
|
queueMode.value = 'disabled'
|
||||||
},
|
}
|
||||||
change: {
|
},
|
||||||
key: 'change',
|
change: {
|
||||||
label: `${t('menu.run')} (${t('menu.onChange')})`,
|
key: 'change',
|
||||||
tooltip: t('menu.onChangeTooltip'),
|
label: `${t('menu.run')} (${t('menu.onChange')})`,
|
||||||
command: () => {
|
tooltip: t('menu.onChangeTooltip'),
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
command: () => {
|
||||||
button_id: 'queue_mode_option_run_on_change_selected'
|
useTelemetry()?.trackUiButtonClicked({
|
||||||
})
|
button_id: 'queue_mode_option_run_on_change_selected'
|
||||||
queueMode.value = 'change'
|
})
|
||||||
|
queueMode.value = 'change'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!isCloud) {
|
if (!isCloud) {
|
||||||
items['instant-idle'] = {
|
items['instant-idle'] = {
|
||||||
key: 'instant-idle',
|
key: 'instant-idle',
|
||||||
label: `${t('menu.run')} (${t('menu.instant')})`,
|
label: `${t('menu.run')} (${t('menu.instant')})`,
|
||||||
tooltip: t('menu.instantTooltip'),
|
tooltip: t('menu.instantTooltip'),
|
||||||
command: () => {
|
command: () => {
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
useTelemetry()?.trackUiButtonClicked({
|
||||||
button_id: 'queue_mode_option_run_instant_selected'
|
button_id: 'queue_mode_option_run_instant_selected'
|
||||||
})
|
})
|
||||||
queueMode.value = 'instant-idle'
|
queueMode.value = 'instant-idle'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
return items
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const activeQueueModeMenuItem = computed(() => {
|
const activeQueueModeMenuItem = computed(() => {
|
||||||
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
|
|
||||||
return (
|
return (
|
||||||
queueModeMenuItemLookup.value[selectedQueueMode.value] ||
|
queueModeMenuItemLookup.value[selectedQueueMode.value] ||
|
||||||
queueModeMenuItemLookup.value.disabled
|
queueModeMenuItemLookup.value.disabled
|
||||||
@@ -132,9 +176,13 @@ const queueButtonLabel = computed(() =>
|
|||||||
: String(activeQueueModeMenuItem.value?.label ?? '')
|
: String(activeQueueModeMenuItem.value?.label ?? '')
|
||||||
)
|
)
|
||||||
|
|
||||||
const queueButtonSeverity = computed(() =>
|
const queueButtonVariant = computed<'destructive' | 'primary'>(() =>
|
||||||
isStopInstantAction.value ? 'danger' : 'primary'
|
isStopInstantAction.value ? 'destructive' : 'primary'
|
||||||
)
|
)
|
||||||
|
const queueActionButtonClass = 'h-full rounded-lg gap-1.5 px-4 font-light'
|
||||||
|
const queueMenuTriggerClass =
|
||||||
|
'h-full w-6 rounded-l-none rounded-r-lg border-l border-border-subtle p-0 text-muted-foreground data-[state=open]:bg-secondary-background-hover'
|
||||||
|
const queueMenuItemButtonClass = 'w-full justify-start font-normal'
|
||||||
|
|
||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
if (isStopInstantAction.value) {
|
if (isStopInstantAction.value) {
|
||||||
@@ -201,10 +249,3 @@ const queuePrompt = async (e: Event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
26
src/components/actionbar/TinyChevronIcon.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="h-[5px] min-h-[5px] w-[8px] min-w-[8px]"
|
||||||
|
:class="{ 'rotate-180': rotateUp }"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="8"
|
||||||
|
height="5"
|
||||||
|
viewBox="0 0 8 5"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0.650391 0.649902L3.65039 3.6499L6.65039 0.649902"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { rotateUp = false } = defineProps<{
|
||||||
|
rotateUp?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<slot name="background" />
|
<slot name="background" />
|
||||||
<Button
|
<Button
|
||||||
v-if="!hideButtons"
|
v-if="!hideButtons"
|
||||||
:aria-label="t('g.ariaLabel.decrement')"
|
:aria-label="t('g.decrement')"
|
||||||
data-testid="decrement"
|
data-testid="decrement"
|
||||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||||
variant="muted-textonly"
|
variant="muted-textonly"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<slot />
|
<slot />
|
||||||
<Button
|
<Button
|
||||||
v-if="!hideButtons"
|
v-if="!hideButtons"
|
||||||
:aria-label="t('g.ariaLabel.increment')"
|
:aria-label="t('g.increment')"
|
||||||
data-testid="increment"
|
data-testid="increment"
|
||||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||||
variant="muted-textonly"
|
variant="muted-textonly"
|
||||||
|
|||||||
@@ -136,8 +136,7 @@ onMounted(async () => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('g.error'),
|
summary: t('g.error'),
|
||||||
detail: t('toastMessages.failedToFetchLogs'),
|
detail: t('toastMessages.failedToFetchLogs')
|
||||||
life: 5000
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ import { isCloud } from '@/platform/distribution/types'
|
|||||||
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import type { MissingNodeType } from '@/types/comfy'
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||||
@@ -244,6 +245,7 @@ const { missingNodeTypes } = defineProps<{
|
|||||||
const { missingCoreNodes } = useMissingNodes()
|
const { missingCoreNodes } = useMissingNodes()
|
||||||
const { replaceNodesInPlace } = useNodeReplacement()
|
const { replaceNodesInPlace } = useNodeReplacement()
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
|
|
||||||
interface ProcessedNode {
|
interface ProcessedNode {
|
||||||
label: string
|
label: string
|
||||||
@@ -338,6 +340,14 @@ function handleReplaceSelected() {
|
|||||||
replacedTypes.value = nextReplaced
|
replacedTypes.value = nextReplaced
|
||||||
selectedTypes.value = nextSelected
|
selectedTypes.value = nextSelected
|
||||||
|
|
||||||
|
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
|
||||||
|
// but the modal only updates its own local UI state above.
|
||||||
|
// Without this call the Errors Tab would still list the replaced nodes
|
||||||
|
// as missing because executionErrorStore is not aware of the replacement.
|
||||||
|
if (result.length > 0) {
|
||||||
|
executionErrorStore.removeMissingNodesByType(result)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
|
||||||
const allReplaced = replaceableNodes.value.every((n) =>
|
const allReplaced = replaceableNodes.value.every((n) =>
|
||||||
nextReplaced.has(n.label)
|
nextReplaced.has(n.label)
|
||||||
|
|||||||
@@ -275,8 +275,7 @@ async function handleBuy() {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('credits.topUp.purchaseError'),
|
summary: t('credits.topUp.purchaseError'),
|
||||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||||
life: 5000
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ async function onConfirmCancel() {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('subscription.cancelDialog.failed'),
|
summary: t('subscription.cancelDialog.failed'),
|
||||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||||
life: 5000
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|||||||
@@ -64,17 +64,17 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const executionStore = useExecutionStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
|
||||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||||
|
|
||||||
const errorCountLabel = computed(() =>
|
const errorCountLabel = computed(() =>
|
||||||
@@ -90,7 +90,7 @@ const isVisible = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
executionStore.dismissErrorOverlay()
|
executionErrorStore.dismissErrorOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
function seeErrors() {
|
function seeErrors() {
|
||||||
@@ -100,6 +100,6 @@ function seeErrors() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rightSidePanelStore.openPanel('errors')
|
rightSidePanelStore.openPanel('errors')
|
||||||
executionStore.dismissErrorOverlay()
|
executionErrorStore.dismissErrorOverlay()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
:key="nodeData.id"
|
:key="nodeData.id"
|
||||||
:node-data="nodeData"
|
:node-data="nodeData"
|
||||||
:error="
|
:error="
|
||||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
executionErrorStore.lastExecutionError?.node_id === nodeData.id
|
||||||
? 'Execution error'
|
? 'Execution error'
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
@@ -170,6 +170,7 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||||
@@ -196,6 +197,7 @@ const workspaceStore = useWorkspaceStore()
|
|||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
const colorPaletteStore = useColorPaletteStore()
|
||||||
const colorPaletteService = useColorPaletteService()
|
const colorPaletteService = useColorPaletteService()
|
||||||
@@ -376,7 +378,7 @@ watch(
|
|||||||
// Update node slot errors for LiteGraph nodes
|
// Update node slot errors for LiteGraph nodes
|
||||||
// (Vue nodes read from store directly)
|
// (Vue nodes read from store directly)
|
||||||
watch(
|
watch(
|
||||||
() => executionStore.lastNodeErrors,
|
() => executionErrorStore.lastNodeErrors,
|
||||||
(lastNodeErrors) => {
|
(lastNodeErrors) => {
|
||||||
if (!comfyApp.graph) return
|
if (!comfyApp.graph) return
|
||||||
|
|
||||||
|
|||||||
116
src/components/graph/widgets/DomWidget.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||||
|
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||||
|
import type { DomWidgetState } from '@/stores/domWidgetStore'
|
||||||
|
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||||
|
|
||||||
|
import DomWidget from './DomWidget.vue'
|
||||||
|
|
||||||
|
const mockUpdatePosition = vi.fn()
|
||||||
|
const mockUpdateClipPath = vi.fn()
|
||||||
|
const mockCanvasElement = document.createElement('canvas')
|
||||||
|
const mockCanvasStore = {
|
||||||
|
canvas: {
|
||||||
|
graph: {
|
||||||
|
getNodeById: vi.fn(() => true)
|
||||||
|
},
|
||||||
|
ds: {
|
||||||
|
offset: [0, 0],
|
||||||
|
scale: 1
|
||||||
|
},
|
||||||
|
canvas: mockCanvasElement,
|
||||||
|
selected_nodes: {}
|
||||||
|
},
|
||||||
|
getCanvas: () => ({ canvas: mockCanvasElement }),
|
||||||
|
linearMode: false
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/composables/element/useAbsolutePosition', () => ({
|
||||||
|
useAbsolutePosition: () => ({
|
||||||
|
style: reactive<Record<string, string>>({}),
|
||||||
|
updatePosition: mockUpdatePosition
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/element/useDomClipping', () => ({
|
||||||
|
useDomClipping: () => ({
|
||||||
|
style: reactive<Record<string, string>>({}),
|
||||||
|
updateClipPath: mockUpdateClipPath
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||||
|
useCanvasStore: () => mockCanvasStore
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/settings/settingStore', () => ({
|
||||||
|
useSettingStore: () => ({
|
||||||
|
get: vi.fn(() => false)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||||
|
const domWidgetStore = useDomWidgetStore()
|
||||||
|
const node = createMockLGraphNode({
|
||||||
|
id: 1,
|
||||||
|
constructor: {
|
||||||
|
nodeData: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const widget = {
|
||||||
|
id: 'dom-widget-id',
|
||||||
|
name: 'test_widget',
|
||||||
|
type: 'custom',
|
||||||
|
value: '',
|
||||||
|
options: {},
|
||||||
|
node,
|
||||||
|
computedDisabled: false
|
||||||
|
} as unknown as BaseDOMWidget<object | string>
|
||||||
|
|
||||||
|
domWidgetStore.registerWidget(widget)
|
||||||
|
domWidgetStore.setPositionOverride(widget.id, {
|
||||||
|
node: createMockLGraphNode({ id: 2 }),
|
||||||
|
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = domWidgetStore.widgetStates.get(widget.id)
|
||||||
|
if (!state) throw new Error('Expected registered DomWidgetState')
|
||||||
|
|
||||||
|
state.zIndex = 2
|
||||||
|
state.size = [100, 40]
|
||||||
|
|
||||||
|
return reactive(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DomWidget disabled style', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useDomWidgetStore().clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||||
|
const widgetState = createWidgetState(true)
|
||||||
|
const wrapper = mount(DomWidget, {
|
||||||
|
props: {
|
||||||
|
widgetState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
widgetState.zIndex = 3
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const root = wrapper.get('.dom-widget').element as HTMLElement
|
||||||
|
expect(root.style.pointerEvents).toBe('none')
|
||||||
|
expect(root.style.opacity).toBe('0.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -105,13 +105,17 @@ watch(
|
|||||||
updateDomClipping()
|
updateDomClipping()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const override = widgetState.positionOverride
|
||||||
|
const isDisabled = override
|
||||||
|
? (override.widget.computedDisabled ?? widget.computedDisabled)
|
||||||
|
: widget.computedDisabled
|
||||||
|
|
||||||
style.value = {
|
style.value = {
|
||||||
...positionStyle.value,
|
...positionStyle.value,
|
||||||
...(enableDomClipping.value ? clippingStyle.value : {}),
|
...(enableDomClipping.value ? clippingStyle.value : {}),
|
||||||
zIndex: widgetState.zIndex,
|
zIndex: widgetState.zIndex,
|
||||||
pointerEvents:
|
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
|
||||||
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
|
opacity: isDisabled ? 0.5 : 1
|
||||||
opacity: widget.computedDisabled ? 0.5 : 1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
|
|||||||
@@ -579,8 +579,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('g.error'),
|
summary: t('g.error'),
|
||||||
detail: error.value || t('helpCenter.updateComfyUIFailed'),
|
detail: error.value || t('helpCenter.updateComfyUIFailed')
|
||||||
life: 5000
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -597,8 +596,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('g.error'),
|
summary: t('g.error'),
|
||||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||||
life: 5000
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ function toggle() {
|
|||||||
v-if="visible"
|
v-if="visible"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
|
class="fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0 sm:w-min sm:min-w-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
|
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
|
||||||
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
|
isExpanded
|
||||||
|
? 'w-full max-h-100 sm:w-[max(400px,40vw)]'
|
||||||
|
: 'w-0 max-h-0'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
123
src/components/queue/JobHistoryActionsMenu.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||||
|
|
||||||
|
const popoverCloseSpy = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/Popover.vue', () => {
|
||||||
|
const PopoverStub = defineComponent({
|
||||||
|
name: 'Popover',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h('div', [
|
||||||
|
slots.button?.(),
|
||||||
|
slots.default?.({
|
||||||
|
close: () => {
|
||||||
|
popoverCloseSpy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { default: PopoverStub }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/platform/distribution/types', () => ({
|
||||||
|
isCloud: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
|
||||||
|
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
const mockSetSetting = vi.fn()
|
||||||
|
const mockSetMany = vi.fn()
|
||||||
|
const mockSidebarTabStore = {
|
||||||
|
activeSidebarTabId: null as string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/platform/settings/settingStore', () => ({
|
||||||
|
useSettingStore: () => ({
|
||||||
|
get: mockGetSetting,
|
||||||
|
set: mockSetSetting,
|
||||||
|
setMany: mockSetMany
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||||
|
useSidebarTabStore: () => mockSidebarTabStore
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mountMenu = () =>
|
||||||
|
mount(JobHistoryActionsMenu, {
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
directives: { tooltip: () => {} }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('JobHistoryActionsMenu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.global.locale.value = 'en'
|
||||||
|
popoverCloseSpy.mockClear()
|
||||||
|
mockSetSetting.mockClear()
|
||||||
|
mockSetMany.mockClear()
|
||||||
|
mockSidebarTabStore.activeSidebarTabId = null
|
||||||
|
mockGetSetting.mockImplementation((key: string) =>
|
||||||
|
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles show run progress bar setting from the menu', async () => {
|
||||||
|
const wrapper = mountMenu()
|
||||||
|
|
||||||
|
const showRunProgressBarButton = wrapper.get(
|
||||||
|
'[data-testid="show-run-progress-bar-action"]'
|
||||||
|
)
|
||||||
|
await showRunProgressBarButton.trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledWith(
|
||||||
|
'Comfy.Queue.ShowRunProgressBar',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens docked job history sidebar when enabling from the menu', async () => {
|
||||||
|
mockGetSetting.mockImplementation((key: string) => {
|
||||||
|
if (key === 'Comfy.Queue.QPOV2') return false
|
||||||
|
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const wrapper = mountMenu()
|
||||||
|
|
||||||
|
const dockedJobHistoryButton = wrapper.get(
|
||||||
|
'[data-testid="docked-job-history-action"]'
|
||||||
|
)
|
||||||
|
await dockedJobHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||||
|
expect(mockSetMany).not.toHaveBeenCalled()
|
||||||
|
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits clear history from the menu', async () => {
|
||||||
|
const wrapper = mountMenu()
|
||||||
|
|
||||||
|
const clearHistoryButton = wrapper.get(
|
||||||
|
'[data-testid="clear-history-action"]'
|
||||||
|
)
|
||||||
|
await clearHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
data-testid="docked-job-history-action"
|
data-testid="docked-job-history-action"
|
||||||
class="w-full justify-between text-sm font-light"
|
class="w-full justify-between text-sm font-light"
|
||||||
variant="textonly"
|
variant="textonly"
|
||||||
size="sm"
|
size="md"
|
||||||
@click="onToggleDockedJobHistory"
|
@click="onToggleDockedJobHistory(close)"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<i
|
<i
|
||||||
@@ -35,14 +35,32 @@
|
|||||||
class="icon-[lucide--check] size-4"
|
class="icon-[lucide--check] size-4"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="show-run-progress-bar-action"
|
||||||
|
class="w-full justify-between text-sm font-light"
|
||||||
|
variant="textonly"
|
||||||
|
size="md"
|
||||||
|
@click="onToggleRunProgressBar"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
|
||||||
|
<span>{{
|
||||||
|
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
v-if="isRunProgressBarEnabled"
|
||||||
|
class="icon-[lucide--check] size-4"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
|
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
|
||||||
<template v-if="showClearHistoryAction">
|
<template v-if="showClearHistoryAction">
|
||||||
<div class="my-1 border-t border-interface-stroke" />
|
<div class="my-1 border-t border-interface-stroke" />
|
||||||
<Button
|
<Button
|
||||||
data-testid="clear-history-action"
|
data-testid="clear-history-action"
|
||||||
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
|
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
|
||||||
variant="textonly"
|
variant="textonly"
|
||||||
size="sm"
|
size="md"
|
||||||
@click="onClearHistoryFromMenu(close)"
|
@click="onClearHistoryFromMenu(close)"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
@@ -76,9 +94,11 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import Popover from '@/components/ui/Popover.vue'
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'clearHistory'): void
|
(e: 'clearHistory'): void
|
||||||
@@ -86,11 +106,11 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
const sidebarTabStore = useSidebarTabStore()
|
||||||
|
|
||||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||||
const isQueuePanelV2Enabled = computed(() =>
|
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||||
settingStore.get('Comfy.Queue.QPOV2')
|
useQueueFeatureFlags()
|
||||||
)
|
|
||||||
const showClearHistoryAction = computed(() => !isCloud)
|
const showClearHistoryAction = computed(() => !isCloud)
|
||||||
|
|
||||||
const onClearHistoryFromMenu = (close: () => void) => {
|
const onClearHistoryFromMenu = (close: () => void) => {
|
||||||
@@ -98,7 +118,29 @@ const onClearHistoryFromMenu = (close: () => void) => {
|
|||||||
emit('clearHistory')
|
emit('clearHistory')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onToggleDockedJobHistory = async () => {
|
const onToggleDockedJobHistory = async (close: () => void) => {
|
||||||
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
|
close()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isQueuePanelV2Enabled.value) {
|
||||||
|
await settingStore.setMany({
|
||||||
|
'Comfy.Queue.QPOV2': false,
|
||||||
|
'Comfy.Queue.History.Expanded': true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarTabStore.activeSidebarTabId = 'job-history'
|
||||||
|
await settingStore.set('Comfy.Queue.QPOV2', true)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleRunProgressBar = async () => {
|
||||||
|
await settingStore.set(
|
||||||
|
'Comfy.Queue.ShowRunProgressBar',
|
||||||
|
!isRunProgressBarEnabled.value
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
<div class="flex w-full flex-col gap-4">
|
<div class="flex w-full flex-col gap-4">
|
||||||
<QueueOverlayHeader
|
<QueueOverlayHeader
|
||||||
:header-title="headerTitle"
|
:header-title="headerTitle"
|
||||||
:show-concurrent-indicator="showConcurrentIndicator"
|
|
||||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
|
||||||
:queued-count="queuedCount"
|
:queued-count="queuedCount"
|
||||||
@clear-history="$emit('clearHistory')"
|
@clear-history="$emit('clearHistory')"
|
||||||
@clear-queued="$emit('clearQueued')"
|
@clear-queued="$emit('clearQueued')"
|
||||||
@@ -60,8 +58,6 @@ import JobGroupsList from './job/JobGroupsList.vue'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
headerTitle: string
|
headerTitle: string
|
||||||
showConcurrentIndicator: boolean
|
|
||||||
concurrentWorkflowCount: number
|
|
||||||
queuedCount: number
|
queuedCount: number
|
||||||
selectedJobTab: JobTab
|
selectedJobTab: JobTab
|
||||||
selectedWorkflowFilter: 'all' | 'current'
|
selectedWorkflowFilter: 'all' | 'current'
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
|
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
|
||||||
const popoverCloseSpy = vi.fn()
|
const popoverCloseSpy = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/components/ui/Popover.vue', () => {
|
vi.mock('@/components/ui/Popover.vue', () => {
|
||||||
@@ -23,18 +24,29 @@ vi.mock('@/components/ui/Popover.vue', () => {
|
|||||||
return { default: PopoverStub }
|
return { default: PopoverStub }
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockGetSetting = vi.fn((key: string) =>
|
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
|
||||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
const mockSetSetting = vi.fn()
|
const mockSetSetting = vi.fn()
|
||||||
|
const mockSetMany = vi.fn()
|
||||||
|
const mockSidebarTabStore = {
|
||||||
|
activeSidebarTabId: null as string | null
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('@/platform/settings/settingStore', () => ({
|
vi.mock('@/platform/settings/settingStore', () => ({
|
||||||
useSettingStore: () => ({
|
useSettingStore: () => ({
|
||||||
get: mockGetSetting,
|
get: mockGetSetting,
|
||||||
set: mockSetSetting
|
set: mockSetSetting,
|
||||||
|
setMany: mockSetMany
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||||
|
useSidebarTabStore: () => mockSidebarTabStore
|
||||||
|
}))
|
||||||
|
|
||||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||||
|
|
||||||
@@ -43,32 +55,10 @@ const tooltipDirectiveStub = {
|
|||||||
updated: vi.fn()
|
updated: vi.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: 'en',
|
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
g: { more: 'More' },
|
|
||||||
sideToolbar: {
|
|
||||||
queueProgressOverlay: {
|
|
||||||
running: 'running',
|
|
||||||
queuedSuffix: 'queued',
|
|
||||||
clearQueued: 'Clear queued',
|
|
||||||
moreOptions: 'More options',
|
|
||||||
clearHistory: 'Clear history',
|
|
||||||
dockedJobHistory: 'Docked Job History'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const mountHeader = (props = {}) =>
|
const mountHeader = (props = {}) =>
|
||||||
mount(QueueOverlayHeader, {
|
mount(QueueOverlayHeader, {
|
||||||
props: {
|
props: {
|
||||||
headerTitle: 'Job queue',
|
headerTitle: 'Job queue',
|
||||||
showConcurrentIndicator: true,
|
|
||||||
concurrentWorkflowCount: 2,
|
|
||||||
queuedCount: 3,
|
queuedCount: 3,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -80,44 +70,38 @@ const mountHeader = (props = {}) =>
|
|||||||
|
|
||||||
describe('QueueOverlayHeader', () => {
|
describe('QueueOverlayHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
i18n.global.locale.value = 'en'
|
||||||
popoverCloseSpy.mockClear()
|
popoverCloseSpy.mockClear()
|
||||||
mockSetSetting.mockClear()
|
mockSetSetting.mockClear()
|
||||||
|
mockSetMany.mockClear()
|
||||||
|
mockSidebarTabStore.activeSidebarTabId = null
|
||||||
|
mockGetSetting.mockImplementation((key: string) =>
|
||||||
|
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders header title and concurrent indicator when enabled', () => {
|
it('renders header title', () => {
|
||||||
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Job queue')
|
expect(wrapper.text()).toContain('Job queue')
|
||||||
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
|
|
||||||
expect(indicator.exists()).toBe(true)
|
|
||||||
expect(indicator.text()).toContain('3')
|
|
||||||
expect(indicator.text()).toContain('running')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides concurrent indicator when flag is false', () => {
|
it('shows clear queue text and emits clear queued', async () => {
|
||||||
const wrapper = mountHeader({ showConcurrentIndicator: false })
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Job queue')
|
|
||||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows queued summary and emits clear queued', async () => {
|
|
||||||
const wrapper = mountHeader({ queuedCount: 4 })
|
const wrapper = mountHeader({ queuedCount: 4 })
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('4')
|
expect(wrapper.text()).toContain('Clear queue')
|
||||||
expect(wrapper.text()).toContain('queued')
|
expect(wrapper.text()).not.toContain('4 queued')
|
||||||
|
|
||||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||||
await clearQueuedButton.trigger('click')
|
await clearQueuedButton.trigger('click')
|
||||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides clear queued button when queued count is zero', () => {
|
it('disables clear queued button when queued count is zero', () => {
|
||||||
const wrapper = mountHeader({ queuedCount: 0 })
|
const wrapper = mountHeader({ queuedCount: 0 })
|
||||||
|
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||||
|
|
||||||
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
|
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
|
||||||
false
|
expect(wrapper.text()).toContain('Clear queue')
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits clear history from the menu', async () => {
|
it('emits clear history from the menu', async () => {
|
||||||
@@ -138,7 +122,7 @@ describe('QueueOverlayHeader', () => {
|
|||||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles docked job history setting from the menu', async () => {
|
it('opens floating queue progress overlay when disabling from the menu', async () => {
|
||||||
const wrapper = mountHeader()
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
const dockedJobHistoryButton = wrapper.get(
|
const dockedJobHistoryButton = wrapper.get(
|
||||||
@@ -146,7 +130,96 @@ describe('QueueOverlayHeader', () => {
|
|||||||
)
|
)
|
||||||
await dockedJobHistoryButton.trigger('click')
|
await dockedJobHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetMany).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetMany).toHaveBeenCalledWith({
|
||||||
|
'Comfy.Queue.QPOV2': false,
|
||||||
|
'Comfy.Queue.History.Expanded': true
|
||||||
|
})
|
||||||
|
expect(mockSetSetting).not.toHaveBeenCalled()
|
||||||
|
expect(mockSidebarTabStore.activeSidebarTabId).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens docked job history sidebar when enabling from the menu', async () => {
|
||||||
|
mockGetSetting.mockImplementation((key: string) =>
|
||||||
|
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||||
|
)
|
||||||
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
|
const dockedJobHistoryButton = wrapper.get(
|
||||||
|
'[data-testid="docked-job-history-action"]'
|
||||||
|
)
|
||||||
|
await dockedJobHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||||
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
|
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||||
|
expect(mockSetMany).not.toHaveBeenCalled()
|
||||||
|
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps docked target open even when enabling persistence fails', async () => {
|
||||||
|
mockGetSetting.mockImplementation((key: string) =>
|
||||||
|
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||||
|
)
|
||||||
|
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
|
||||||
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
|
const dockedJobHistoryButton = wrapper.get(
|
||||||
|
'[data-testid="docked-job-history-action"]'
|
||||||
|
)
|
||||||
|
await dockedJobHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||||
|
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps docked target open even when enabling persistence fails', async () => {
|
||||||
|
mockGetSetting.mockImplementation((key: string) =>
|
||||||
|
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||||
|
)
|
||||||
|
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
|
||||||
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
|
const dockedJobHistoryButton = wrapper.get(
|
||||||
|
'[data-testid="docked-job-history-action"]'
|
||||||
|
)
|
||||||
|
await dockedJobHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||||
|
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the menu when disabling persistence fails', async () => {
|
||||||
|
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
|
||||||
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
|
const dockedJobHistoryButton = wrapper.get(
|
||||||
|
'[data-testid="docked-job-history-action"]'
|
||||||
|
)
|
||||||
|
await dockedJobHistoryButton.trigger('click')
|
||||||
|
|
||||||
|
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetMany).toHaveBeenCalledWith({
|
||||||
|
'Comfy.Queue.QPOV2': false,
|
||||||
|
'Comfy.Queue.History.Expanded': true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles show run progress bar setting from the menu', async () => {
|
||||||
|
const wrapper = mountHeader()
|
||||||
|
|
||||||
|
const showRunProgressBarButton = wrapper.get(
|
||||||
|
'[data-testid="show-run-progress-bar-action"]'
|
||||||
|
)
|
||||||
|
await showRunProgressBarButton.trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSetSetting).toHaveBeenCalledWith(
|
||||||
|
'Comfy.Queue.ShowRunProgressBar',
|
||||||
|
false
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,34 +4,19 @@
|
|||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
||||||
<span>{{ headerTitle }}</span>
|
<span>{{ headerTitle }}</span>
|
||||||
<span
|
|
||||||
v-if="showConcurrentIndicator"
|
|
||||||
class="ml-4 inline-flex items-center gap-1 text-blue-100"
|
|
||||||
>
|
|
||||||
<span class="inline-block size-2 rounded-full bg-blue-100" />
|
|
||||||
<span>
|
|
||||||
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
|
|
||||||
<span class="ml-1">{{
|
|
||||||
t('sideToolbar.queueProgressOverlay.running')
|
|
||||||
}}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
||||||
>
|
>
|
||||||
<span class="opacity-90">
|
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
|
||||||
<span class="font-bold">{{ queuedCount }}</span>
|
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||||
<span class="ml-1">{{
|
}}</span>
|
||||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
|
||||||
}}</span>
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="queuedCount > 0"
|
|
||||||
v-tooltip.top="clearAllJobsTooltip"
|
v-tooltip.top="clearAllJobsTooltip"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||||
|
:disabled="queuedCount === 0"
|
||||||
@click="$emit('clearQueued')"
|
@click="$emit('clearQueued')"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--list-x] size-4" />
|
<i class="icon-[lucide--list-x] size-4" />
|
||||||
@@ -51,8 +36,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
headerTitle: string
|
headerTitle: string
|
||||||
showConcurrentIndicator: boolean
|
|
||||||
concurrentWorkflowCount: number
|
|
||||||
queuedCount: number
|
queuedCount: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
v-model:selected-sort-mode="selectedSortMode"
|
v-model:selected-sort-mode="selectedSortMode"
|
||||||
class="flex-1 min-h-0"
|
class="flex-1 min-h-0"
|
||||||
:header-title="headerTitle"
|
:header-title="headerTitle"
|
||||||
:show-concurrent-indicator="showConcurrentIndicator"
|
|
||||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
|
||||||
:queued-count="queuedCount"
|
:queued-count="queuedCount"
|
||||||
:displayed-job-groups="displayedJobGroups"
|
:displayed-job-groups="displayedJobGroups"
|
||||||
:has-failed-jobs="hasFailedJobs"
|
:has-failed-jobs="hasFailedJobs"
|
||||||
@@ -183,13 +181,6 @@ const headerTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const concurrentWorkflowCount = computed(
|
|
||||||
() => executionStore.runningWorkflowCount
|
|
||||||
)
|
|
||||||
const showConcurrentIndicator = computed(
|
|
||||||
() => concurrentWorkflowCount.value > 1
|
|
||||||
)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedJobTab,
|
selectedJobTab,
|
||||||
selectedWorkflowFilter,
|
selectedWorkflowFilter,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
|||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||||
@@ -36,12 +36,12 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
|||||||
import TabErrors from './errors/TabErrors.vue'
|
import TabErrors from './errors/TabErrors.vue'
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||||
|
|
||||||
const { findParentGroup } = useGraphHierarchy()
|
const { findParentGroup } = useGraphHierarchy()
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ type RightSidePanelTabList = Array<{
|
|||||||
|
|
||||||
const hasDirectNodeError = computed(() =>
|
const hasDirectNodeError = computed(() =>
|
||||||
selectedNodes.value.some((node) =>
|
selectedNodes.value.some((node) =>
|
||||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ const hasContainerInternalError = computed(() => {
|
|||||||
if (allErrorExecutionIds.value.length === 0) return false
|
if (allErrorExecutionIds.value.length === 0) return false
|
||||||
return selectedNodes.value.some((node) => {
|
return selectedNodes.value.some((node) => {
|
||||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||||
return executionStore.hasInternalErrorForNode(node.id)
|
return executionErrorStore.isContainerWithInternalError(node)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<p
|
<p
|
||||||
v-if="error.message && !compact"
|
v-if="error.message"
|
||||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5 max-h-[4lh] overflow-y-auto"
|
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5 max-h-[4lh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('TabErrors.vue', () => {
|
|||||||
|
|
||||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
execution: {
|
executionError: {
|
||||||
lastPromptError: {
|
lastPromptError: {
|
||||||
type: 'prompt_no_outputs',
|
type: 'prompt_no_outputs',
|
||||||
message: 'Server Error: No outputs',
|
message: 'Server Error: No outputs',
|
||||||
@@ -118,7 +118,7 @@ describe('TabErrors.vue', () => {
|
|||||||
} as ReturnType<typeof getNodeByExecutionId>)
|
} as ReturnType<typeof getNodeByExecutionId>)
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
execution: {
|
executionError: {
|
||||||
lastNodeErrors: {
|
lastNodeErrors: {
|
||||||
'6': {
|
'6': {
|
||||||
class_type: 'CLIPTextEncode',
|
class_type: 'CLIPTextEncode',
|
||||||
@@ -143,7 +143,7 @@ describe('TabErrors.vue', () => {
|
|||||||
} as ReturnType<typeof getNodeByExecutionId>)
|
} as ReturnType<typeof getNodeByExecutionId>)
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
execution: {
|
executionError: {
|
||||||
lastExecutionError: {
|
lastExecutionError: {
|
||||||
prompt_id: 'abc',
|
prompt_id: 'abc',
|
||||||
node_id: '10',
|
node_id: '10',
|
||||||
@@ -167,7 +167,7 @@ describe('TabErrors.vue', () => {
|
|||||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
execution: {
|
executionError: {
|
||||||
lastNodeErrors: {
|
lastNodeErrors: {
|
||||||
'1': {
|
'1': {
|
||||||
class_type: 'CLIPTextEncode',
|
class_type: 'CLIPTextEncode',
|
||||||
@@ -198,7 +198,7 @@ describe('TabErrors.vue', () => {
|
|||||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
execution: {
|
executionError: {
|
||||||
lastNodeErrors: {
|
lastNodeErrors: {
|
||||||
'1': {
|
'1': {
|
||||||
class_type: 'TestNode',
|
class_type: 'TestNode',
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import type { Ref } from 'vue'
|
|||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import type { IFuseOptions } from 'fuse.js'
|
import type { IFuseOptions } from 'fuse.js'
|
||||||
|
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getNodeByExecutionId,
|
getNodeByExecutionId,
|
||||||
|
getExecutionIdByNode,
|
||||||
getRootParentNode
|
getRootParentNode
|
||||||
} from '@/utils/graphTraversalUtil'
|
} from '@/utils/graphTraversalUtil'
|
||||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||||
@@ -19,6 +21,7 @@ import { isLGraphNode } from '@/utils/litegraphUtil'
|
|||||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||||
import { st } from '@/i18n'
|
import { st } from '@/i18n'
|
||||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||||
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||||
|
|
||||||
const PROMPT_CARD_ID = '__prompt__'
|
const PROMPT_CARD_ID = '__prompt__'
|
||||||
@@ -192,38 +195,42 @@ export function useErrorGroups(
|
|||||||
searchQuery: Ref<string>,
|
searchQuery: Ref<string>,
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
) {
|
) {
|
||||||
const executionStore = useExecutionStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const collapseState = reactive<Record<string, boolean>>({})
|
const collapseState = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
const selectedNodeInfo = computed(() => {
|
const selectedNodeInfo = computed(() => {
|
||||||
const items = canvasStore.selectedItems
|
const items = canvasStore.selectedItems
|
||||||
const nodeIds = new Set<string>()
|
const nodeIds = new Set<string>()
|
||||||
const containerIds = new Set<string>()
|
const containerExecutionIds = new Set<NodeExecutionId>()
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!isLGraphNode(item)) continue
|
if (!isLGraphNode(item)) continue
|
||||||
nodeIds.add(String(item.id))
|
nodeIds.add(String(item.id))
|
||||||
if (item instanceof SubgraphNode || isGroupNode(item)) {
|
if (
|
||||||
containerIds.add(String(item.id))
|
(item instanceof SubgraphNode || isGroupNode(item)) &&
|
||||||
|
app.rootGraph
|
||||||
|
) {
|
||||||
|
const execId = getExecutionIdByNode(app.rootGraph, item)
|
||||||
|
if (execId) containerExecutionIds.add(execId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
||||||
containerIds
|
containerExecutionIds
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const isSingleNodeSelected = computed(
|
const isSingleNodeSelected = computed(
|
||||||
() =>
|
() =>
|
||||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||||
selectedNodeInfo.value.containerIds.size === 0
|
selectedNodeInfo.value.containerExecutionIds.size === 0
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorNodeCache = computed(() => {
|
const errorNodeCache = computed(() => {
|
||||||
const map = new Map<string, LGraphNode>()
|
const map = new Map<string, LGraphNode>()
|
||||||
for (const execId of executionStore.allErrorExecutionIds) {
|
for (const execId of executionErrorStore.allErrorExecutionIds) {
|
||||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||||
if (node) map.set(execId, node)
|
if (node) map.set(execId, node)
|
||||||
}
|
}
|
||||||
@@ -237,8 +244,9 @@ export function useErrorGroups(
|
|||||||
const graphNode = errorNodeCache.value.get(executionNodeId)
|
const graphNode = errorNodeCache.value.get(executionNodeId)
|
||||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||||
|
|
||||||
for (const containerId of selectedNodeInfo.value.containerIds) {
|
for (const containerExecId of selectedNodeInfo.value
|
||||||
if (executionNodeId.startsWith(`${containerId}:`)) return true
|
.containerExecutionIds) {
|
||||||
|
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -262,10 +270,10 @@ export function useErrorGroups(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
|
||||||
return
|
return
|
||||||
|
|
||||||
const error = executionStore.lastPromptError
|
const error = executionErrorStore.lastPromptError
|
||||||
const groupTitle = error.message
|
const groupTitle = error.message
|
||||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||||
@@ -293,10 +301,10 @@ export function useErrorGroups(
|
|||||||
groupsMap: Map<string, GroupEntry>,
|
groupsMap: Map<string, GroupEntry>,
|
||||||
filterBySelection = false
|
filterBySelection = false
|
||||||
) {
|
) {
|
||||||
if (!executionStore.lastNodeErrors) return
|
if (!executionErrorStore.lastNodeErrors) return
|
||||||
|
|
||||||
for (const [nodeId, nodeError] of Object.entries(
|
for (const [nodeId, nodeError] of Object.entries(
|
||||||
executionStore.lastNodeErrors
|
executionErrorStore.lastNodeErrors
|
||||||
)) {
|
)) {
|
||||||
addNodeErrorToGroup(
|
addNodeErrorToGroup(
|
||||||
groupsMap,
|
groupsMap,
|
||||||
@@ -316,9 +324,9 @@ export function useErrorGroups(
|
|||||||
groupsMap: Map<string, GroupEntry>,
|
groupsMap: Map<string, GroupEntry>,
|
||||||
filterBySelection = false
|
filterBySelection = false
|
||||||
) {
|
) {
|
||||||
if (!executionStore.lastExecutionError) return
|
if (!executionErrorStore.lastExecutionError) return
|
||||||
|
|
||||||
const e = executionStore.lastExecutionError
|
const e = executionErrorStore.lastExecutionError
|
||||||
addNodeErrorToGroup(
|
addNodeErrorToGroup(
|
||||||
groupsMap,
|
groupsMap,
|
||||||
String(e.node_id),
|
String(e.node_id),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|||||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
@@ -62,7 +62,7 @@ watchEffect(() => (widgets.value = widgetsProp))
|
|||||||
provide(HideLayoutFieldKey, true)
|
provide(HideLayoutFieldKey, true)
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -110,7 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
|
|||||||
|
|
||||||
const hasDirectError = computed(() => {
|
const hasDirectError = computed(() => {
|
||||||
if (!targetNode.value) return false
|
if (!targetNode.value) return false
|
||||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
return executionErrorStore.activeGraphErrorNodeIds.has(
|
||||||
|
String(targetNode.value.id)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasContainerInternalError = computed(() => {
|
const hasContainerInternalError = computed(() => {
|
||||||
@@ -119,7 +121,7 @@ const hasContainerInternalError = computed(() => {
|
|||||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||||
if (!isContainer) return false
|
if (!isContainer) return false
|
||||||
|
|
||||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
return executionErrorStore.isContainerWithInternalError(targetNode.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodeHasError = computed(() => {
|
const nodeHasError = computed(() => {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import NodeSearchCategoryTreeNode, {
|
|||||||
CATEGORY_UNSELECTED_CLASS
|
CATEGORY_UNSELECTED_CLASS
|
||||||
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||||
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
import { NodeSourceType } from '@/types/nodeSource'
|
import { NodeSourceType } from '@/types/nodeSource'
|
||||||
@@ -64,6 +65,7 @@ const selectedCategory = defineModel<string>('selectedCategory', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
|
|
||||||
const topCategories = computed(() => [
|
const topCategories = computed(() => [
|
||||||
@@ -79,7 +81,7 @@ const hasEssentialNodes = computed(() =>
|
|||||||
|
|
||||||
const sourceCategories = computed(() => {
|
const sourceCategories = computed(() => {
|
||||||
const categories = []
|
const categories = []
|
||||||
if (hasEssentialNodes.value) {
|
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
|
||||||
categories.push({ id: 'essentials', label: t('g.essentials') })
|
categories.push({ id: 'essentials', label: t('g.essentials') })
|
||||||
}
|
}
|
||||||
categories.push({ id: 'custom', label: t('g.custom') })
|
categories.push({ id: 'custom', label: t('g.custom') })
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||||
t(label)
|
st(label, label)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -50,12 +50,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { st } from '@/i18n'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const {
|
const {
|
||||||
icon = '',
|
icon = '',
|
||||||
selected = false,
|
selected = false,
|
||||||
@@ -83,7 +81,7 @@ const overlayValue = computed(() =>
|
|||||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||||
)
|
)
|
||||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||||
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ const sampleAssets: AssetItem[] = [
|
|||||||
created_at: baseTimestamp,
|
created_at: baseTimestamp,
|
||||||
size: 134217728,
|
size: 134217728,
|
||||||
tags: []
|
tags: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asset-text-1',
|
||||||
|
name: 'generation-notes.txt',
|
||||||
|
created_at: baseTimestamp,
|
||||||
|
preview_url: '/assets/images/default-template.png',
|
||||||
|
size: 2048,
|
||||||
|
tags: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asset-other-1',
|
||||||
|
name: 'workflow-payload.bin',
|
||||||
|
created_at: baseTimestamp,
|
||||||
|
preview_url: '/assets/images/default-template.png',
|
||||||
|
size: 4096,
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -134,6 +150,16 @@ export const RunningAndGenerated: Story = {
|
|||||||
render: renderAssetsSidebarListView
|
render: renderAssetsSidebarListView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TextAndMiscGeneratedAssets: Story = {
|
||||||
|
args: {
|
||||||
|
assets: sampleAssets.filter((asset) =>
|
||||||
|
['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix))
|
||||||
|
),
|
||||||
|
jobs: []
|
||||||
|
},
|
||||||
|
render: renderAssetsSidebarListView
|
||||||
|
}
|
||||||
|
|
||||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||||
return {
|
return {
|
||||||
components: { AssetsSidebarListView },
|
components: { AssetsSidebarListView },
|
||||||
|
|||||||
@@ -89,4 +89,21 @@ describe('AssetsSidebarListView', () => {
|
|||||||
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
|
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
|
||||||
expect(assetListItem?.props('isVideoPreview')).toBe(true)
|
expect(assetListItem?.props('isVideoPreview')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses icon fallback for text assets even when preview_url exists', () => {
|
||||||
|
const textAsset = {
|
||||||
|
...buildAsset('text-asset', 'notes.txt'),
|
||||||
|
preview_url: '/api/view/notes.txt',
|
||||||
|
user_metadata: {}
|
||||||
|
} satisfies AssetItem
|
||||||
|
|
||||||
|
const wrapper = mountListView([buildOutputItem(textAsset)])
|
||||||
|
|
||||||
|
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||||
|
const assetListItem = listItems.at(-1)
|
||||||
|
|
||||||
|
expect(assetListItem).toBeDefined()
|
||||||
|
expect(assetListItem?.props('previewUrl')).toBe('')
|
||||||
|
expect(assetListItem?.props('isVideoPreview')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
item.isChild && 'pl-6'
|
item.isChild && 'pl-6'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
:preview-url="item.asset.preview_url"
|
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||||
:preview-alt="item.asset.name"
|
:preview-alt="item.asset.name"
|
||||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||||
:is-video-preview="isVideoAsset(item.asset)"
|
:is-video-preview="isVideoAsset(item.asset)"
|
||||||
@@ -142,6 +142,14 @@ function isVideoAsset(asset: AssetItem): boolean {
|
|||||||
return getAssetMediaType(asset) === 'video'
|
return getAssetMediaType(asset) === 'video'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAssetPreviewUrl(asset: AssetItem): string {
|
||||||
|
const mediaType = getAssetMediaType(asset)
|
||||||
|
if (mediaType === 'image' || mediaType === 'video') {
|
||||||
|
return asset.preview_url || ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function getAssetSecondaryText(asset: AssetItem): string {
|
function getAssetSecondaryText(asset: AssetItem): string {
|
||||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||||
|
|||||||
@@ -204,13 +204,22 @@ import {
|
|||||||
} from '@vueuse/core'
|
} from '@vueuse/core'
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
defineAsyncComponent,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch
|
||||||
|
} from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||||
const Load3dViewerContent = () =>
|
const Load3dViewerContent = defineAsyncComponent(
|
||||||
import('@/components/load3d/Load3dViewerContent.vue')
|
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||||
|
)
|
||||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||||
@@ -235,7 +244,11 @@ import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil
|
|||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import { ResultItemImpl } from '@/stores/queueStore'
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
import {
|
||||||
|
formatDuration,
|
||||||
|
getMediaTypeFromFilename,
|
||||||
|
isPreviewableMediaType
|
||||||
|
} from '@/utils/formatUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -396,6 +409,12 @@ const visibleAssets = computed(() => {
|
|||||||
return listViewSelectableAssets.value
|
return listViewSelectableAssets.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const previewableVisibleAssets = computed(() =>
|
||||||
|
visibleAssets.value.filter((asset) =>
|
||||||
|
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||||
|
|
||||||
const isBulkMode = computed(
|
const isBulkMode = computed(
|
||||||
@@ -421,12 +440,10 @@ watch(visibleAssets, (newAssets) => {
|
|||||||
// so selection stays consistent with what this view can act on.
|
// so selection stays consistent with what this view can act on.
|
||||||
reconcileSelection(newAssets)
|
reconcileSelection(newAssets)
|
||||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||||
const newIndex = newAssets.findIndex(
|
const newIndex = previewableVisibleAssets.value.findIndex(
|
||||||
(asset) => asset.id === currentGalleryAssetId.value
|
(asset) => asset.id === currentGalleryAssetId.value
|
||||||
)
|
)
|
||||||
if (newIndex !== -1) {
|
galleryActiveIndex.value = newIndex
|
||||||
galleryActiveIndex.value = newIndex
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -437,7 +454,7 @@ watch(galleryActiveIndex, (index) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const galleryItems = computed(() => {
|
const galleryItems = computed(() => {
|
||||||
return visibleAssets.value.map((asset) => {
|
return previewableVisibleAssets.value.map((asset) => {
|
||||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||||
const resultItem = new ResultItemImpl({
|
const resultItem = new ResultItemImpl({
|
||||||
filename: asset.name,
|
filename: asset.name,
|
||||||
@@ -543,6 +560,9 @@ const handleDeleteSelected = async () => {
|
|||||||
|
|
||||||
const handleZoomClick = (asset: AssetItem) => {
|
const handleZoomClick = (asset: AssetItem) => {
|
||||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||||
|
if (!isPreviewableMediaType(mediaType)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaType === '3D') {
|
if (mediaType === '3D') {
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
@@ -562,7 +582,9 @@ const handleZoomClick = (asset: AssetItem) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentGalleryAssetId.value = asset.id
|
currentGalleryAssetId.value = asset.id
|
||||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
const index = previewableVisibleAssets.value.findIndex(
|
||||||
|
(a) => a.id === asset.id
|
||||||
|
)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
galleryActiveIndex.value = index
|
galleryActiveIndex.value = index
|
||||||
}
|
}
|
||||||
@@ -592,8 +614,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('sideToolbar.folderView.errorSummary'),
|
summary: t('sideToolbar.folderView.errorSummary'),
|
||||||
detail: t('sideToolbar.folderView.errorDetail'),
|
detail: t('sideToolbar.folderView.errorDetail')
|
||||||
life: 5000
|
|
||||||
})
|
})
|
||||||
exitFolderView()
|
exitFolderView()
|
||||||
}
|
}
|
||||||
@@ -639,8 +660,7 @@ const copyJobId = async () => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('mediaAsset.jobIdToast.error'),
|
summary: t('mediaAsset.jobIdToast.error'),
|
||||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
|
||||||
life: 3000
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
:value="tab.value"
|
:value="tab.value"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
|
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
|
||||||
'text-sm text-foreground transition-colors',
|
'text-sm text-foreground transition-colors',
|
||||||
selectedTab === tab.value
|
selectedTab === tab.value
|
||||||
? 'bg-comfy-input font-bold'
|
? 'bg-comfy-input font-bold'
|
||||||
@@ -70,7 +70,9 @@
|
|||||||
<!-- Tab content (scrollable) -->
|
<!-- Tab content (scrollable) -->
|
||||||
<TabsRoot v-model="selectedTab" class="h-full">
|
<TabsRoot v-model="selectedTab" class="h-full">
|
||||||
<EssentialNodesPanel
|
<EssentialNodesPanel
|
||||||
v-if="selectedTab === 'essentials'"
|
v-if="
|
||||||
|
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
|
||||||
|
"
|
||||||
v-model:expanded-keys="expandedKeys"
|
v-model:expanded-keys="expandedKeys"
|
||||||
:root="renderedEssentialRoot"
|
:root="renderedEssentialRoot"
|
||||||
@node-click="handleNodeClick"
|
@node-click="handleNodeClick"
|
||||||
@@ -109,10 +111,11 @@ import {
|
|||||||
TabsRoot,
|
TabsRoot,
|
||||||
TabsTrigger
|
TabsTrigger
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||||
import { usePerTabState } from '@/composables/usePerTabState'
|
import { usePerTabState } from '@/composables/usePerTabState'
|
||||||
import {
|
import {
|
||||||
@@ -136,11 +139,22 @@ import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
|||||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
|
||||||
const selectedTab = useLocalStorage<TabId>(
|
const selectedTab = useLocalStorage<TabId>(
|
||||||
'Comfy.NodeLibrary.Tab',
|
'Comfy.NodeLibrary.Tab',
|
||||||
DEFAULT_TAB_ID
|
DEFAULT_TAB_ID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (
|
||||||
|
!flags.nodeLibraryEssentialsEnabled &&
|
||||||
|
selectedTab.value === 'essentials'
|
||||||
|
) {
|
||||||
|
selectedTab.value = DEFAULT_TAB_ID
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
||||||
'Comfy.NodeLibrary.SortByTab',
|
'Comfy.NodeLibrary.SortByTab',
|
||||||
{
|
{
|
||||||
@@ -324,11 +338,21 @@ async function handleSearch() {
|
|||||||
expandedKeys.value = allKeys
|
expandedKeys.value = allKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => {
|
||||||
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
|
const baseTabs: Array<{ value: TabId; label: string }> = [
|
||||||
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
|
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
|
||||||
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
|
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
|
||||||
])
|
]
|
||||||
|
return flags.nodeLibraryEssentialsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: 'essentials' as TabId,
|
||||||
|
label: t('sideToolbar.nodeLibraryTab.essentials')
|
||||||
|
},
|
||||||
|
...baseTabs
|
||||||
|
]
|
||||||
|
: baseTabs
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searchBoxRef.value?.focus()
|
searchBoxRef.value?.focus()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
||||||
|
|
||||||
@@ -31,10 +30,8 @@ withDefaults(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const cloudBadge = computed<TopbarBadgeType>(() => ({
|
const cloudBadge = computed<TopbarBadgeType>(() => ({
|
||||||
label: t('g.beta'),
|
icon: 'icon-[lucide--cloud]',
|
||||||
text: 'Comfy Cloud'
|
text: 'Comfy Cloud'
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
26
src/components/ui/button-group/ButtonGroup.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import { Primitive, useForwardProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as = 'div', class: className = '', ...restProps } = defineProps<Props>()
|
||||||
|
const forwardedProps = useForwardProps(restProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:as
|
||||||
|
:class="
|
||||||
|
cn('inline-flex items-stretch overflow-hidden rounded-md', className)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
|||||||
import { cva } from 'cva'
|
import { cva } from 'cva'
|
||||||
|
|
||||||
export const buttonVariants = cva({
|
export const buttonVariants = cva({
|
||||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
54
src/components/ui/textarea/Textarea.stories.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import Textarea from './Textarea.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Textarea> = {
|
||||||
|
title: 'UI/Textarea',
|
||||||
|
component: Textarea,
|
||||||
|
tags: ['autodocs']
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Textarea>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { Textarea },
|
||||||
|
setup() {
|
||||||
|
const value = ref('Hello world')
|
||||||
|
return { value }
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<Textarea v-model="value" placeholder="Type something..." class="max-w-sm" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { Textarea },
|
||||||
|
template:
|
||||||
|
'<Textarea model-value="Disabled textarea" disabled class="max-w-sm" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithLabel: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { Textarea },
|
||||||
|
setup() {
|
||||||
|
const value = ref('Content that sits below the label')
|
||||||
|
return { value }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
|
||||||
|
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
v-model="value"
|
||||||
|
class="size-full resize-none border-none bg-transparent pt-5 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
24
src/components/ui/textarea/Textarea.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { class: className, ...restAttrs } = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<string | number>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
v-bind="restAttrs"
|
||||||
|
v-model="modelValue"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||