Compare commits

..

9 Commits

Author SHA1 Message Date
jaeone94
db424661e7 Merge branch 'main' into feat/error-tab-node-installation 2026-02-24 14:23:26 +09:00
jaeone94
d9b2e1204f fix(useApplyChanges): catch rejection from fire-and-forget conflict analysis
void discards the promise entirely; .catch() preserves the
fire-and-forget intent while preventing a silent unhandled rejection
if runFullConflictAnalysis throws asynchronously.
2026-02-24 14:01:08 +09:00
jaeone94
a7d33edc4d fix(useApplyChanges): add concurrency guard to prevent re-entrant invocations
Claim the isRestarting lock synchronously at the top of applyChanges
before any await. A concurrent caller that arrives while a restart is
already in flight now returns immediately rather than clobbering the
shared flags and registering a second reconnected listener.
Previously, isRestarting was reset to false at entry and only set back
to true inside the try block after the first await, leaving a window
where a second call could pass the guard and register a duplicate
listener — causing post-reconnect work to run twice on a single event.
2026-02-24 13:49:06 +09:00
jaeone94
bdc080823f fix(manager): stop opening dialog for legacy-only commands and localize install button label
useManagerState.ts:
- Remove managerDialog.show(ManagerTab.All) from the isLegacyOnly branch;
  when a command has no NEW_UI equivalent the toast alone should be shown
  and the manager must not open
- Convert ManagerTab import to import type since it is now type-only
PackInstallButton.vue:
- Remove hardcoded default 'Install' from the label prop so computedLabel
  can reach its i18n fallback (t('g.install') / t('manager.installSelected'));
  the previous default made that branch permanently dead code
2026-02-24 13:30:27 +09:00
jaeone94
8dbd3b206d fix(useApplyChanges): reset state on re-entry and surface post-reconnect errors
Reset isRestarting and isRestartCompleted at the start of applyChanges so
that a second invocation on the shared singleton does not begin with stale
state from the previous restart cycle.
Add a catch block inside onReconnect so that errors thrown by
RefreshNodeDefinitions or reloadCurrentWorkflow are logged to the console
rather than silently becoming unhandled promise rejections. The finally
block continues to guarantee state cleanup regardless of outcome.
2026-02-24 13:26:03 +09:00
jaeone94
efd32f0f13 fix(manager): address additional CodeRabbit review items
useApplyChanges.ts:
- Hoist onReconnect and stopReconnectListener before the try block so
  the variable can be declared with const; behavior is identical since
  the reconnected event cannot fire before rebootComfyUI() is called

usePackInstall.ts:
- Hoist useConflictDetection() to composable setup scope; calling a
  Vue composable inside an async runtime function skips lifecycle
  registration and leaks reactive state on every invocation
- Wrap Promise.all in try/finally so managerStore.installPack.clear()
  is always called even when an install fails, preventing the UI from
  staying stuck in the installing state
- Guard hasConflict with an early return when conflictInfo is null,
  blocking installation instead of silently bypassing the conflict check

executionErrorStore.ts:
- Remove missingAncestorExecutionIds and errorAncestorExecutionIds
  from the store's public API; both are internal implementation
  details only consumed by their respective isContainer* helpers
2026-02-24 12:47:38 +09:00
jaeone94
3fc401a892 refactor(errors): extract execution utilities and fix nested subgraph error detection
Add nodeExecutionUtil.ts with getAncestorExecutionIds, getParentExecutionIds,
and buildSubgraphExecutionPaths, consolidating duplicated prefix-generation logic
from executionErrorStore.ts and app.ts.
Refactor executionErrorStore.ts:
- Extract long lastNodeErrors watcher into module-level helpers:
  clearAllNodeErrorFlags, markNodeSlotErrors, applyNodeError
- Add missingAncestorExecutionIds, activeMissingNodeGraphIds,
  isContainerWithMissingNode following the same pattern as
  errorAncestorExecutionIds / isContainerWithInternalError
- Update LGraphNode.vue hasAnyError to include isContainerWithMissingNode,
  so subgraph container nodes highlight when a nested node is missing
Fix buildSubgraphExecutionPaths to return Map<string, string[]> instead of
Map<string, string>, resolving a bug where only the first container of a
given subgraph definition was tracked; all instances are now processed.
Address CodeRabbit review items:
- Fix race condition in useErrorGroups.ts: mark all pending types RESOLVING
  synchronously before the first await to prevent duplicate resolutions
- Fix NaN sort for composite execution IDs (e.g. '10:5') by replacing
  Number() subtraction with localeCompare({ numeric: true })
- Localize hardcoded error strings in usePackInstall.ts and app.ts
  (manager.packInstall.nodeIdRequired, g.inSubgraph)
2026-02-24 00:11:47 +09:00
jaeone94
6a756d8de4 Merge branch 'main' into feat/error-tab-node-installation 2026-02-23 20:44:49 +09:00
jaeone94
c1e54f4839 feat(error-tab): integrate Missing Node support and related UI/functional improvements
- Add MissingNodeCard and MissingPackGroupRow components to support error summaries and actions for Missing Nodes in the right panel TabErrors
- Improve parsing and grouping states to extract detailed info (nodeId, cnrId) when collecting Missing Nodes in app.ts and executionErrorStore
- Extract and refactor usePackInstall and useApplyChanges composables to allow node pack installation and ComfyUI restart directly from Missing Node info
- Add optional parameters to ManagerDialog to support deep linking (initialPackId) for immediate search and auto-selection of specific packages
- Add i18n support for Missing Node texts and reflect UI feedback regarding button height and layout in ErrorNodeCard
2026-02-23 03:25:29 +09:00
246 changed files with 2038 additions and 10661 deletions

View File

@@ -1,110 +0,0 @@
name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
uses: actions/upload-artifact@v6
with:
name: perf-metrics
path: test-results/perf-metrics.json
retention-days: 30
if-no-files-found: warn
report:
needs: perf-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Download PR perf metrics
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: perf-metrics
path: test-results/
- name: Download baseline perf metrics
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ github.event.pull_request.base.ref }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Read perf report
id: perf-report
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./perf-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
body: |
${{ steps.perf-report.outputs.content }}
<!-- COMFYUI_FRONTEND_PERF -->
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'

View File

@@ -53,13 +53,7 @@ jobs:
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
# 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 build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
@@ -68,7 +62,6 @@ jobs:
path: |
dist/
dist.zip
dist-desktop.zip
draft_release:
needs: build
@@ -86,7 +79,6 @@ jobs:
with:
files: |
dist.zip
dist-desktop.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: >-

View File

@@ -40,12 +40,12 @@
"block-no-empty": true,
"no-descending-specificity": null,
"no-duplicate-at-import-rules": true,
"at-rule-disallowed-list": ["apply"],
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"layer",
"config",
"theme",

View File

@@ -61,7 +61,8 @@
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
"cwd": "apps/desktop-ui",
"command": "vite build --config vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"

View File

@@ -4,39 +4,3 @@
position: absolute;
inset: 0;
}
.p-button-secondary {
border: none;
background-color: var(--color-neutral-600);
color: var(--color-white);
}
.p-button-secondary:hover {
background-color: var(--color-neutral-550);
}
.p-button-secondary:active {
background-color: var(--color-neutral-500);
}
.p-button-danger {
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
}
.task-div .p-card {
transition: opacity var(--default-transition-duration);
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
}
.task-div .p-card:hover {
opacity: 1;
}

View File

@@ -101,15 +101,13 @@ onUnmounted(() => {
</script>
<style scoped>
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
* required to style those generated nodes.
*/
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
overflow: hidden;
@apply overflow-hidden;
}
:deep(.p-terminal) .xterm-screen {
overflow: hidden;
background-color: var(--color-neutral-900);
@apply bg-neutral-900 overflow-hidden;
}
</style>

View File

@@ -187,17 +187,13 @@ async function onLocaleChange(event: SelectChangeEvent) {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
transition-property: color, background-color, border-color;
transition-duration: var(--default-transition-duration);
@apply transition-colors;
}
:deep(.p-dropdown) {
&:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-neutral-900),
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
}
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
}
</style>

View File

@@ -269,43 +269,26 @@ const onFocus = async () => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
padding-inline: calc(var(--spacing) * 12);
@apply px-12;
.p-accordionpanel {
border: 0;
background-color: transparent;
@apply border-0 bg-transparent;
}
.p-accordionheader {
margin-top: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-700) 50%,
transparent
);
}
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
@apply rounded-t-xl rounded-b-none;
}
.p-accordionheader-toggle-icon {
@@ -316,24 +299,11 @@ const onFocus = async () => {
}
.p-accordioncontent {
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: var(--radius-xl);
border-bottom-left-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
background-color: transparent;
padding-top: calc(var(--spacing) * 3);
padding-right: calc(var(--spacing) * 5);
padding-bottom: calc(var(--spacing) * 5);
padding-left: calc(var(--spacing) * 5);
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */

View File

@@ -1,20 +1,11 @@
<template>
<div
:class="
cn(
'task-div group/task-card relative grid min-h-52 max-w-48',
isLoading && 'opacity-75'
)
"
class="task-div relative grid min-h-52 max-w-48"
:class="{ 'opacity-75': isLoading }"
>
<Card
:class="
cn(
'relative h-full max-w-48 overflow-hidden',
runner.state !== 'error' && 'opacity-65'
)
"
:pt="cardPt"
class="relative h-full max-w-48 overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
@@ -52,7 +43,7 @@
<i
v-if="!isLoading && runner.state === 'OK'"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
class="task-card-ok pi pi-check"
/>
</div>
</template>
@@ -64,7 +55,6 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@/utils/tailwindUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
@@ -93,9 +83,51 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
const cardPt = {
header: { class: 'z-0' },
body: { class: 'z-[1] grow justify-between' }
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
{{ $t(`desktopDialogs.${id}.title`, title) }}
{{ t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">
{{ $t(`desktopDialogs.${id}.message`, message) }}
{{ t(`desktopDialogs.${id}.message`, message) }}
</p>
<div class="flex w-full gap-2">
<Button
@@ -12,7 +12,7 @@
:key="button.label"
class="rounded-lg first:mr-auto"
:label="
$t(
t(
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
button.label
)
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
@@ -40,3 +41,31 @@ const handleButtonClick = async (button: DialogAction) => {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>
<style scoped>
@reference '../assets/css/style.css';
.p-button-secondary {
@apply text-white border-none bg-neutral-600;
}
.p-button-secondary:hover {
@apply bg-neutral-550;
}
.p-button-secondary:active {
@apply bg-neutral-500;
}
.p-button-danger {
@apply bg-coral-red-600;
}
.p-button-danger:hover {
@apply bg-coral-red-500;
}
.p-button-danger:active {
@apply bg-coral-red-400;
}
</style>

View File

@@ -6,11 +6,11 @@
<div class="relative m-8 text-center">
<!-- Header -->
<h1 class="download-bg pi-download text-4xl font-bold">
{{ $t('desktopUpdate.title') }}
{{ t('desktopUpdate.title') }}
</h1>
<div class="m-8">
<span>{{ $t('desktopUpdate.description') }}</span>
<span>{{ t('desktopUpdate.description') }}</span>
</div>
<ProgressSpinner class="m-8 w-48 h-48" />
@@ -19,7 +19,7 @@
<Button
style="transform: translateX(-50%)"
class="fixed bottom-0 left-1/2 my-8"
:label="$t('maintenance.consoleLogs')"
:label="t('maintenance.consoleLogs')"
icon="pi pi-desktop"
icon-pos="left"
severity="secondary"
@@ -28,8 +28,8 @@
<TerminalOutputDrawer
v-model="terminalVisible"
:header="$t('g.terminal')"
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
:header="t('g.terminal')"
:default-message="t('desktopUpdate.terminalDefaultMessage')"
/>
</div>
</div>
@@ -44,6 +44,7 @@ import Toast from 'primevue/toast'
import { onUnmounted, ref } from 'vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
@@ -60,10 +61,10 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
.download-bg::before {
position: absolute;
margin: 0;
color: var(--muted-foreground);
@apply m-0 absolute text-muted;
font-family: 'primeicons', sans-serif;
top: -2rem;
right: 2rem;

View File

@@ -183,37 +183,33 @@ onMounted(async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-steppanel) {
margin-top: calc(var(--spacing) * 8);
display: flex;
justify-content: center;
background-color: transparent;
@apply mt-8 flex justify-center bg-transparent;
}
/* Remove default padding/margin from StepPanels to make scrollbar flush */
:deep(.p-steppanels) {
margin: 0;
padding: 0;
@apply p-0 m-0;
}
/* Ensure StepPanel content container has no top/bottom padding */
:deep(.p-steppanel-content) {
padding: 0;
@apply p-0;
}
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
:deep(.p-steppanels::-webkit-scrollbar) {
width: calc(var(--spacing) * 4);
@apply w-4;
}
:deep(.p-steppanels::-webkit-scrollbar-track) {
background-color: transparent;
@apply bg-transparent;
}
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
border: 4px solid transparent;
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
background-clip: content-box;
}
</style>

View File

@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
@@ -129,7 +129,6 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
const electron = electronAPI()
const toast = useToast()
const { t } = useI18n()
const taskStore = useMaintenanceTaskStore()
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
@@ -221,14 +220,14 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-tag) {
--p-tag-gap: 0.375rem;
}
.backspan::before {
position: absolute;
margin: 0;
color: var(--muted-foreground);
@apply m-0 absolute text-muted;
font-family: 'primeicons', sans-serif;
top: -2rem;
right: -2rem;

View File

@@ -1,6 +1,6 @@
<template>
<BaseViewTemplate>
<div class="sad-container grid items-center justify-evenly">
<div class="sad-container">
<!-- Right side image -->
<img
class="sad-girl"
@@ -79,7 +79,10 @@ const continueToInstall = async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
.sad-container {
@apply grid items-center justify-evenly;
grid-template-columns: 25rem 1fr;
& > * {

View File

@@ -232,6 +232,8 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../assets/css/style.css';
/* Hide the xterm scrollbar completely */
:deep(.p-terminal) .xterm-viewport {
overflow: hidden !important;

View File

@@ -24,7 +24,6 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
@@ -186,7 +185,6 @@ export class ComfyPage {
public readonly dragDrop: DragDropHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -231,7 +229,6 @@ export class ComfyPage {
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
}
get visibleToasts() {
@@ -439,13 +436,7 @@ export const comfyPageFixture = base.extend<{
}
await comfyPage.setup()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
await use(comfyPage)
if (isPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -1,96 +0,0 @@
import type { CDPSession, Page } from '@playwright/test'
interface PerfSnapshot {
RecalcStyleCount: number
RecalcStyleDuration: number
LayoutCount: number
LayoutDuration: number
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
}
export interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
export class PerformanceHelper {
private cdp: CDPSession | null = null
private snapshot: PerfSnapshot | null = null
constructor(private readonly page: Page) {}
async init(): Promise<void> {
this.cdp = await this.page.context().newCDPSession(this.page)
await this.cdp.send('Performance.enable')
}
async dispose(): Promise<void> {
this.snapshot = null
if (this.cdp) {
try {
await this.cdp.send('Performance.disable')
} finally {
await this.cdp.detach()
this.cdp = null
}
}
}
private async getSnapshot(): Promise<PerfSnapshot> {
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
metrics: { name: string; value: number }[]
}
function get(name: string): number {
return metrics.find((m) => m.name === name)?.value ?? 0
}
return {
RecalcStyleCount: get('RecalcStyleCount'),
RecalcStyleDuration: get('RecalcStyleDuration'),
LayoutCount: get('LayoutCount'),
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
}
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(
'Measurement already in progress — call stopMeasuring() first'
)
}
this.snapshot = await this.getSnapshot()
}
async stopMeasuring(name: string): Promise<PerfMeasurement> {
if (!this.snapshot) throw new Error('Call startMeasuring() first')
const after = await this.getSnapshot()
const before = this.snapshot
this.snapshot = null
function delta(key: keyof PerfSnapshot): number {
return after[key] - before[key]
}
return {
name,
durationMs: delta('Timestamp') * 1000,
styleRecalcs: delta('RecalcStyleCount'),
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
}
}
}

View File

@@ -44,12 +44,6 @@ export const TestIds = {
node: {
titleInput: 'node-title-input'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
},
widgets: {
decrement: 'decrement',
increment: 'increment',
@@ -80,7 +74,6 @@ export type TestIdValue =
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

@@ -1,14 +1,11 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenv.config()
export default function globalTeardown(_config: FullConfig) {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])

View File

@@ -1,49 +0,0 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
export interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const TEMP_DIR = join('test-results', 'perf-temp')
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let tempFiles: string[]
try {
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
} catch {
return
}
if (tempFiles.length === 0) return
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
)
const report: PerfReport = {
timestamp: new Date().toISOString(),
gitSha,
branch,
measurements
}
writeFileSync(
join('test-results', 'perf-metrics.json'),
JSON.stringify(report, null, 2)
)
}

View File

@@ -104,13 +104,15 @@ test.describe('Missing models warning', () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
@@ -121,13 +123,15 @@ test.describe('Missing models warning', () => {
'missing/missing_models_from_node_properties'
)
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
@@ -168,10 +172,8 @@ test.describe('Missing models warning', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
@@ -184,10 +186,8 @@ test.describe('Missing models warning', () => {
)
// The missing models warning should NOT appear
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
// Flaky test after parallelization
@@ -199,15 +199,13 @@ test.describe('Missing models warning', () => {
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadButton = comfyPage.page.getByText('Download')
await expect(downloadButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadAllButton.click()
await downloadButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
@@ -231,13 +229,12 @@ test.describe('Missing models warning', () => {
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await checkbox.click()
await changeSettingPromise
await closeButton.click()
await changeSettingPromise
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -1,70 +0,0 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Let the canvas idle for 2 seconds — no user interaction.
// Measures baseline style recalcs from reactive state + render loop.
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
recordMeasurement(m)
console.log(
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('canvas mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Sweep mouse across the canvas — crosses nodes, empty space, slots
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('canvas-mouse-sweep')
recordMeasurement(m)
console.log(
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
// Load default workflow which has DOM widgets (text inputs, combos)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Select and deselect nodes rapidly to trigger clipping recalculation
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
// Click on canvas area (nodes occupy various positions)
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-clipping')
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
})

View File

@@ -1,8 +1,6 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const test = comfyPageFixture
@@ -12,17 +10,6 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
@@ -145,24 +132,28 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should be visible
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toBeVisible()
// Click color picker button
await colorPickerButton.click()
// Color picker dropdown should be visible
const colorPickerGroup = getColorPickerGroup(comfyPage)
await expect(colorPickerGroup).toBeVisible()
const colorPickerDropdown = comfyPage.page.locator(
'.color-picker-container'
)
await expect(colorPickerDropdown).toBeVisible()
// Select a color (e.g., blue)
const blueColorOption = colorPickerGroup.getByTestId(
TestIds.selectionToolbox.colorBlue
const blueColorOption = colorPickerDropdown.locator(
'i[data-testid="blue"]'
)
await blueColorOption.click()
// Dropdown should close after selection
await expect(colorPickerGroup).not.toBeVisible()
await expect(colorPickerDropdown).not.toBeVisible()
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
@@ -181,21 +172,22 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
'CLIP Text Encode (Prompt)'
])
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
// Initially should show default color
await expect(colorPickerButton).not.toHaveAttribute('color')
// Click color picker and select a color
await colorPickerButton.click()
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
TestIds.selectionToolbox.colorRed
const redColorOption = comfyPage.page.locator(
'.color-picker-container i[data-testid="red"]'
)
await redColorOption.click()
// Button should now show the selected color
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
})
test('color picker shows mixed state for differently colored selections', async ({
@@ -203,17 +195,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select first node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Select second node and color it differently
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorRed)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="red"]')
.click()
// Select both nodes
@@ -223,7 +215,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
])
// Color picker should show null/mixed state
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).not.toHaveAttribute('color')
})
@@ -232,9 +226,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// First color a node
await comfyPage.nodeOps.selectNodes(['KSampler'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
// Clear selection
@@ -244,8 +238,10 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should show the correct color
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
})
test('colorization via color picker can be undone', async ({
@@ -253,9 +249,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select a node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
// Undo the colorization

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { TestIds } from '../../../fixtures/selectors'
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,16 +19,10 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
const colorPickerButton = comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerButton
)
await colorPickerButton.click()
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
await colorPickerGroup
.getByTestId(TestIds.selectionToolbox.colorBlue)
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(

View File

@@ -39,10 +39,6 @@ Prefer Vue native options when available:
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
- Exception: when third-party libraries render runtime DOM outside Vue templates
(for example xterm internals inside PrimeVue terminal wrappers), scoped
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
exception is required.
## Best Practices

View File

@@ -22,9 +22,7 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly'
} as const
const settings = {

View File

@@ -4,11 +4,6 @@ export default {
'tests-ui/**': () =>
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.{css,vue}': (stagedFiles: string[]) => {
const joinedPaths = toJoinedRelativePaths(stagedFiles)
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
},
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
@@ -27,17 +22,12 @@ export default {
}
function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}
function toJoinedRelativePaths(fileNames: string[]) {
const relativePaths = fileNames.map((f) =>
path.relative(process.cwd(), f).replace(/\\/g, '/')
)
return relativePaths.map((p) => `"${p}"`).join(' ')
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.6",
"version": "1.41.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -29,10 +29,10 @@
"knip": "knip --cache",
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
"lint:no-cache": "oxlint src --type-aware && eslint src",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",

View File

@@ -156,7 +156,6 @@
:root {
--fg-color: #000;
--bg-color: #fff;
--default-transition-duration: 0.1s;
--comfy-menu-bg: #353535;
--comfy-menu-secondary-bg: #292929;
--comfy-topbar-height: 2.5rem;

View File

@@ -3952,7 +3952,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/**
* @description The subscription billing duration
* @enum {string}

View File

@@ -36,18 +36,7 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
},
{
name: 'performance',
use: {
...devices['Desktop Chrome'],
trace: 'retain-on-failure'
},
timeout: 60_000,
grep: /@perf/,
fullyParallel: false
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
},
{

View File

@@ -1,125 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
if (!existsSync(CURRENT_PATH)) {
process.stdout.write(
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
)
process.exit(0)
}
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')
lines.push('```json')
lines.push(JSON.stringify(current, null, 2))
lines.push('```')
lines.push('\n</details>')
process.stdout.write(lines.join('\n') + '\n')
}
main()

View File

@@ -1,14 +1,9 @@
import zipdir from 'zip-dir'
const sourceDir = process.argv[2] || './dist'
const outputPath = process.argv[3] || './dist.zip'
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
if (err) {
console.error(`Error zipping "${sourceDir}" directory:`, err)
console.error('Error zipping "dist" directory:', err)
} else {
process.stdout.write(
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
)
console.log('Successfully zipped "dist" directory.')
}
})

View File

@@ -51,6 +51,7 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -2,28 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadFile,
extractFilenameFromContentDisposition,
openFileInNewTab
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
const { mockIsCloud } = vi.hoisted(() => ({
mockIsCloud: { value: false }
}))
let mockIsCloud = false
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
return mockIsCloud
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
}))
// Global stubs
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
@@ -37,7 +26,7 @@ describe('downloadUtil', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
mockIsCloud.value = false
mockIsCloud = false
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
@@ -165,7 +154,7 @@ describe('downloadUtil', () => {
})
it('streams downloads via blob when running in cloud', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -184,7 +173,6 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -195,7 +183,7 @@ describe('downloadUtil', () => {
})
it('logs an error when cloud fetch fails', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
@@ -209,15 +197,14 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob throw
await Promise.resolve() // let .catch handler run
await Promise.resolve()
expect(consoleSpy).toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -236,7 +223,6 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -245,7 +231,7 @@ describe('downloadUtil', () => {
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -267,7 +253,6 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -275,7 +260,7 @@ describe('downloadUtil', () => {
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud.value = true
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -293,7 +278,6 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -301,99 +285,6 @@ 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', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()

View File

@@ -1,9 +1,7 @@
/**
* Utility functions for downloading files
*/
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
@@ -114,23 +112,14 @@ export function extractFilenameFromContentDisposition(
return null
}
/**
* 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(
const downloadViaBlobFetch = async (
href: string,
fallbackFilename: string
): Promise<void> {
const response = await fetchAsBlob(href)
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
@@ -140,44 +129,3 @@ async function downloadViaBlobFetch(
const blob = await response.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)
})
)
}
}

View File

@@ -25,7 +25,7 @@
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
"
:class="
sidebarLocation === 'left'
@@ -85,7 +85,7 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
"
:class="
sidebarLocation === 'right'
@@ -124,7 +124,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -145,13 +144,9 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const appModeStore = useAppModeStore()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)

View File

@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
)
})
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
})
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)

View File

@@ -62,7 +62,7 @@
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
@@ -127,15 +127,13 @@
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<QueueInlineProgressSummary
:hidden="shouldHideInlineProgressSummary"
/>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="shouldHideInlineProgressSummary"
:hidden="isQueueOverlayExpanded"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
@@ -243,9 +241,6 @@ const inlineProgressSummaryTarget = computed(() => {
}
return progressTarget.value
})
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
@@ -288,7 +283,7 @@ onMounted(() => {
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')

View File

@@ -47,7 +47,7 @@
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="shouldHideInlineProgress"
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
@@ -287,9 +287,6 @@ const inlineProgressTarget = computed(() => {
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
const shouldHideInlineProgress = computed(
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
)
watch(
panelElement,
(target) => {

View File

@@ -103,12 +103,13 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
overflow: hidden;
@apply overflow-hidden;
}
:deep(.p-terminal) .xterm-screen {
overflow: hidden;
background-color: var(--color-neutral-900);
@apply bg-neutral-900 overflow-hidden;
}
</style>

View File

@@ -195,6 +195,8 @@ onUpdated(() => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
@@ -203,7 +205,7 @@ onUpdated(() => {
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
overflow: hidden;
@apply overflow-hidden;
}
:deep(.p-breadcrumb) {
@@ -212,10 +214,7 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
display: flex;
align-items: center;
overflow: hidden;
height: calc(var(--spacing) * 8);
@apply flex items-center overflow-hidden h-8;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
border: 1px solid transparent;
background-color: transparent;
@@ -237,7 +236,7 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:hover) {
border-radius: var(--radius-lg);
@apply rounded-lg;
border-color: var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -271,16 +270,18 @@ onUpdated(() => {
</style>
<style>
@reference '../../assets/css/style.css';
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
display: none;
@apply hidden;
}
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
display: flex;
@apply flex;
}
}
</style>

View File

@@ -186,19 +186,19 @@ const inputBlur = async (doRename: boolean) => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
user-select: none;
@apply select-none;
}
.p-breadcrumb-item-link {
overflow: hidden;
@apply overflow-hidden;
}
.p-breadcrumb-item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
.active-breadcrumb-item {

View File

@@ -1,324 +0,0 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
type BoundStyle = { top: string; left: string; width: string; height: string }
const appModeStore = useAppModeStore()
const canvasInteractions = useCanvasInteractions()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
const { graph } = canvas
if (!canvas || !graph) return
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (!e) return
canvas.adjustMouseEvent(e)
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
if (!node) return
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
const height =
(widget.computedHeight !== undefined
? widget.computedHeight - 4
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
return {
width: `${node.size[0] - marginX * 2}px`,
height: `${height}px`,
left: `${node.pos[0] + marginX}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
}
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}
function nodeToDisplayTuple(
n: LGraphNode
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{ t('linearMode.builder.title') }}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<PropertiesAccordionItem
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId === id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'bg-warning-background/40 p-2 my-2 rounded-lg',
index === 0 && 'ring-warning-background ring-2'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport to="body">
<div
:class="
cn(
'absolute w-full h-full pointer-events-auto',
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
)
"
@pointerdown="handleDown"
@pointermove="hoveringSelectable = !!getHovered($event)"
@click="handleClick"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</div>
</TransformPane>
</div>
</Teleport>
</template>

View File

@@ -62,7 +62,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
@@ -76,14 +75,6 @@ import type { BuilderToolbarStep } from './types'
const { t } = useI18n()
const appModeStore = useAppModeStore()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const { rename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
remove?: () => void
}>()
const entries = computed(() => {
const items = []
if (rename)
items.push({
label: t('g.rename'),
command: rename,
icon: 'icon-[lucide--pencil]'
})
if (remove)
items.push({
label: t('g.delete'),
command: remove,
icon: 'icon-[lucide--trash-2]'
})
return items
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
</template>

View File

@@ -33,10 +33,12 @@ export function useBuilderSave() {
return
}
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
if (!workflow.isTemporary) {
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
@@ -73,7 +75,6 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp

View File

@@ -1,59 +0,0 @@
<script setup lang="ts" generic="T">
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
import { DraggableList } from '@/scripts/ui/draggableList'
const modelValue = defineModel<T[]>({ required: true })
const draggableList = ref<DraggableList>()
const draggableItems = useTemplateRef('draggableItems')
watchPostEffect(() => {
void modelValue.value.length
draggableList.value?.dispose()
if (!draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const itemList = modelValue.value
const [item] = itemList.splice(oldPosition, 1)
itemList.splice(newPosition, 0, item)
modelValue.value = [...itemList]
}
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<slot
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
/>
</div>
</template>

View File

@@ -0,0 +1,148 @@
<!-- A Electron-backed download button with a label, size hint and progress bar -->
<template>
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2">
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
</div>
</div>
<div class="file-action flex flex-row items-center gap-2">
<Button
v-if="status === null || status === 'error'"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!props.url"
variant="secondary"
size="sm"
@click="copyURL"
>
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
<div
v-if="status === 'in_progress' || status === 'paused'"
class="flex flex-row items-center gap-2"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="downloadProgress"
:show-value="downloadProgress > 10"
/>
<Button
v-if="status === 'in_progress'"
v-tooltip.top="t('electronFileDownload.pause')"
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
</Button>
<Button
v-if="status === 'paused'"
v-tooltip.top="t('electronFileDownload.resume')"
class="file-action-button"
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!props.error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
</Button>
<Button
v-tooltip.top="t('electronFileDownload.cancel')"
class="file-action-button"
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!props.error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const { t } = useI18n()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
downloadProgress.value = Number((download.progress * 100).toFixed(1))
// @ts-expect-error fixme ts strict error
status.value = download.status
}
})
const triggerDownload = async () => {
await electronDownloadStore.start({
url: props.url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const copyURL = async () => {
await copyToClipboard(props.url)
}
</script>

View File

@@ -0,0 +1,69 @@
<!-- A file download button with a label and a size hint -->
<template>
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="hint">{{ label }}</span>
</div>
<Message
v-if="props.error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="props.error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!props.error"
:title="props.url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -117,18 +117,20 @@ function getFormComponent(item: FormItem): Component {
</script>
<style scoped>
@reference '../../assets/css/style.css';
.form-input :deep(.input-slider) .p-inputnumber input,
.form-input :deep(.input-slider) .slider-part {
width: 5rem;
@apply w-20;
}
.form-input :deep(.input-knob) .p-inputnumber input,
.form-input :deep(.input-knob) .knob-part {
width: 8rem;
@apply w-32;
}
.form-input :deep(.p-inputtext),
.form-input :deep(.p-select) {
width: 11rem;
@apply w-44;
}
</style>

View File

@@ -133,6 +133,8 @@ const wrapperStyle = computed(() => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}

View File

@@ -1,6 +1,6 @@
<template>
<Chip removable @remove="emit('remove', $event)">
<Badge size="small" :class="semanticBadgeClass">
<Chip removable @remove="$emit('remove', $event)">
<Badge size="small" :class="badgeClass">
{{ badge }}
</Badge>
{{ text }}
@@ -10,7 +10,6 @@
<script setup lang="ts">
import Badge from 'primevue/badge'
import Chip from 'primevue/chip'
import { computed } from 'vue'
export interface SearchFilter {
text: string
@@ -19,19 +18,26 @@ export interface SearchFilter {
id: string | number
}
const semanticClassMap: Record<string, string> = {
'i-badge': 'bg-green-500 text-white',
'o-badge': 'bg-red-500 text-white',
'c-badge': 'bg-blue-500 text-white',
's-badge': 'bg-yellow-500'
defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.i-badge) {
@apply bg-green-500 text-white;
}
const props = defineProps<Omit<SearchFilter, 'id'>>()
const emit = defineEmits<{
(e: 'remove', event: Event): void
}>()
:deep(.o-badge) {
@apply bg-red-500 text-white;
}
const semanticBadgeClass = computed(() => {
return semanticClassMap[props.badgeClass] ?? props.badgeClass
})
</script>
:deep(.c-badge) {
@apply bg-blue-500 text-white;
}
:deep(.s-badge) {
@apply bg-yellow-500;
}
</style>

View File

@@ -1,113 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'
function mountEditor(points: CurvePoint[], extraProps = {}) {
return mount(CurveEditor, {
props: { modelValue: points, ...extraProps }
})
}
function getCurvePath(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-testid="curve-path"]')
}
describe('CurveEditor', () => {
it('renders SVG with curve path', () => {
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('svg').exists()).toBe(true)
const curvePath = getCurvePath(wrapper)
expect(curvePath.exists()).toBe(true)
expect(curvePath.attributes('d')).toBeTruthy()
})
it('renders a circle for each control point', () => {
const wrapper = mountEditor([
[0, 0],
[0.5, 0.7],
[1, 1]
])
expect(wrapper.findAll('circle')).toHaveLength(3)
})
it('renders histogram path when provided', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const wrapper = mountEditor(
[
[0, 0],
[1, 1]
],
{ histogram }
)
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
expect(histogramPath.exists()).toBe(true)
expect(histogramPath.attributes('d')).toContain('M0,1')
})
it('does not render histogram path when not provided', () => {
const wrapper = mountEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
})
it('returns empty path with fewer than 2 points', () => {
const wrapper = mountEditor([[0.5, 0.5]])
expect(getCurvePath(wrapper).attributes('d')).toBe('')
})
it('generates path starting with M and containing L segments', () => {
const wrapper = mountEditor([
[0, 0],
[0.5, 0.8],
[1, 1]
])
const d = getCurvePath(wrapper).attributes('d')!
expect(d).toMatch(/^M/)
expect(d).toContain('L')
})
it('curve path only spans the x-range of control points', () => {
const wrapper = mountEditor([
[0.2, 0.3],
[0.8, 0.9]
])
const d = getCurvePath(wrapper).attributes('d')!
const xValues = d
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[0]))
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
})
it('deletes a point on right-click but keeps minimum 2', async () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.5],
[1, 1]
]
const wrapper = mountEditor(points)
expect(wrapper.findAll('circle')).toHaveLength(3)
await wrapper.findAll('circle')[1].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
await wrapper.findAll('circle')[0].trigger('pointerdown', {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
})
})

View File

@@ -1,104 +0,0 @@
<template>
<svg
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
@contextmenu.prevent.stop
>
<line
v-for="v in [0.25, 0.5, 0.75]"
:key="'h' + v"
:x1="0"
:y1="v"
:x2="1"
:y2="v"
stroke="currentColor"
stroke-opacity="0.1"
stroke-width="0.003"
/>
<line
v-for="v in [0.25, 0.5, 0.75]"
:key="'v' + v"
:x1="v"
:y1="0"
:x2="v"
:y2="1"
stroke="currentColor"
stroke-opacity="0.1"
stroke-width="0.003"
/>
<line
x1="0"
y1="1"
x2="1"
y2="0"
stroke="currentColor"
stroke-opacity="0.15"
stroke-width="0.003"
/>
<path
v-if="histogramPath"
data-testid="histogram-path"
:d="histogramPath"
:fill="curveColor"
fill-opacity="0.15"
stroke="none"
/>
<path
data-testid="curve-path"
:d="curvePath"
fill="none"
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
/>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</svg>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const { curveColor = 'white', histogram } = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
}>()
const modelValue = defineModel<CurvePoint[]>({
required: true
})
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -1,16 +0,0 @@
<template>
<CurveEditor v-model="modelValue" />
</template>
<script setup lang="ts">
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
})
</script>

View File

@@ -1,141 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import {
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
} from './curveUtils'
describe('createMonotoneInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createMonotoneInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('produces monotone output for monotone input', () => {
const points: CurvePoint[] = [
[0, 0],
[0.25, 0.2],
[0.5, 0.5],
[0.75, 0.8],
[1, 1]
]
const interpolate = createMonotoneInterpolator(points)
let prev = -Infinity
for (let x = 0; x <= 1; x += 0.01) {
const y = interpolate(x)
expect(y).toBeGreaterThanOrEqual(prev)
prev = y
}
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createMonotoneInterpolator(points)
expect(interpolate(0)).toBeCloseTo(0, 5)
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
expect(interpolate(1)).toBeCloseTo(1, 5)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
expect(lut).toBeInstanceOf(Uint8Array)
expect(lut.length).toBe(256)
})
it('produces identity LUT for diagonal curve', () => {
const lut = curvesToLUT([
[0, 0],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeCloseTo(i, 0)
}
})
it('clamps output to [0, 255]', () => {
const lut = curvesToLUT([
[0, 0],
[0.5, 1.5],
[1, 1]
])
for (let i = 0; i < 256; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(0)
expect(lut[i]).toBeLessThanOrEqual(255)
}
})
})
describe('histogramToPath', () => {
it('returns empty string for empty histogram', () => {
expect(histogramToPath(new Uint32Array(0))).toBe('')
})
it('returns empty string when all bins are zero', () => {
expect(histogramToPath(new Uint32Array(256))).toBe('')
})
it('returns a closed SVG path for valid histogram', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const path = histogramToPath(histogram)
expect(path).toMatch(/^M0,1/)
expect(path).toMatch(/L1,1 Z$/)
})
it('normalizes using 99.5th percentile to suppress outliers', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = 100
histogram[255] = 100000
const path = histogramToPath(histogram)
// Most bins should map to y=0 (1 - 100/100 = 0) since
// the 99.5th percentile is 100, not the outlier 100000
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
expect(nearZero.length).toBeGreaterThan(200)
})
})

View File

@@ -1,120 +0,0 @@
import type { CurvePoint } from './types'
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points
* without overshooting (monotone property).
*
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createMonotoneInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
const deltas: number[] = []
const slopes: number[] = []
for (let i = 0; i < n - 1; i++) {
const dx = xs[i + 1] - xs[i]
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
}
slopes.push(deltas[0] ?? 0)
for (let i = 1; i < n - 1; i++) {
if (deltas[i - 1] * deltas[i] <= 0) {
slopes.push(0)
} else {
slopes.push((deltas[i - 1] + deltas[i]) / 2)
}
}
slopes.push(deltas[n - 2] ?? 0)
for (let i = 0; i < n - 1; i++) {
if (deltas[i] === 0) {
slopes[i] = 0
slopes[i + 1] = 0
} else {
const alpha = slopes[i] / deltas[i]
const beta = slopes[i + 1] / deltas[i]
const s = alpha * alpha + beta * beta
if (s > 9) {
const t = 3 / Math.sqrt(s)
slopes[i] = t * alpha * deltas[i]
slopes[i + 1] = t * beta * deltas[i]
}
}
}
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
const t2 = t * t
const t3 = t2 * t
const h00 = 2 * t3 - 3 * t2 + 1
const h10 = t3 - 2 * t2 + t
const h01 = -2 * t3 + 3 * t2
const h11 = t3 - t2
return (
h00 * ys[lo] +
h10 * dx * slopes[lo] +
h01 * ys[hi] +
h11 * dx * slopes[hi]
)
}
}
/**
* Convert a 256-bin histogram into an SVG path string.
* Normalizes using the 99.5th percentile to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
if (!histogram.length) return ''
const sorted = Array.from(histogram).sort((a, b) => a - b)
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const parts: string[] = ['M0,1']
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
}
parts.push('L1,1 Z')
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createMonotoneInterpolator(points)
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = interpolate(x)
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
}
return lut
}

View File

@@ -1 +0,0 @@
export type CurvePoint = [x: number, y: number]

View File

@@ -438,6 +438,7 @@ onMounted(() => {
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]

View File

@@ -71,30 +71,20 @@ function getDialogPt(item: {
</script>
<style>
@reference '../../assets/css/style.css';
.global-dialog {
max-width: calc(100vw - 1rem);
}
.global-dialog .p-dialog-header {
padding: calc(var(--spacing) * 2);
padding-bottom: 0;
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
@apply pb-0;
}
.global-dialog .p-dialog-content {
padding: calc(var(--spacing) * 2);
padding-top: 0;
}
@media (min-width: 1536px) {
.global-dialog .p-dialog-header {
padding: var(--p-dialog-header-padding);
padding-bottom: 0;
}
.global-dialog .p-dialog-content {
padding: var(--p-dialog-content-padding);
padding-top: 0;
}
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */

View File

@@ -1,173 +0,0 @@
<template>
<div
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<p class="m-0 text-sm leading-5 text-muted-foreground">
{{ $t('missingModelsDialog.description') }}
</p>
<div
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="model in processedModels"
:key="model.name"
class="flex items-center justify-between px-3 py-2"
>
<div class="flex items-center gap-2 overflow-hidden">
<span
class="min-w-0 truncate text-sm text-foreground"
:title="model.name"
>
{{ model.name }}
</span>
<span
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
>
{{ model.badgeLabel }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
v-if="model.isDownloadable && fileSizes.get(model.url)"
class="text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<Button
v-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.copyURL')"
@click="void copyToClipboard(model.url)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
</div>
<div
v-if="totalDownloadSize > 0"
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
>
<span class="text-xs font-medium text-muted-foreground">
{{ $t('missingModelsDialog.totalSize') }}
</span>
<span class="text-xs text-muted-foreground">
{{ formatSize(totalDownloadSize) }}
</span>
</div>
</div>
<p
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
>
{{ $t('missingModelsDialog.footerDescription') }}
</p>
<div
v-if="hasCustomModels"
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<div class="flex flex-col gap-1">
<p
class="m-0 text-xs font-semibold leading-5 text-warning-background"
>
{{ $t('missingModelsDialog.customModelsWarning') }}
</p>
<p class="m-0 text-xs leading-5 text-warning-background">
{{ $t('missingModelsDialog.customModelsInstruction') }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { formatSize } from '@/utils/formatUtil'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
getBadgeLabel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
interface ProcessedModel {
name: string
url: string
directory: string
badgeLabel: string
isDownloadable: boolean
}
const processedModels = computed<ProcessedModel[]>(() =>
missingModels.map((model) => ({
name: model.name,
url: model.url,
directory: model.directory,
badgeLabel: getBadgeLabel(model.directory),
isDownloadable:
hasValidDirectory(model, paths) && isModelDownloadable(model)
}))
)
const hasCustomModels = computed(() =>
processedModels.value.some((m) => !m.isDownloadable)
)
const fileSizes = reactive(new Map<string, number>())
const totalDownloadSize = computed(() =>
processedModels.value
.filter((model) => model.isDownloadable)
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
)
onMounted(async () => {
const downloadableUrls = processedModels.value
.filter((m) => m.isDownloadable)
.map((m) => m.url)
await Promise.allSettled(
downloadableUrls.map(async (url) => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return
const size = response.headers.get('content-length')
if (size) fileSizes.set(url, parseInt(size, 10))
} catch {
// Silently skip size fetch failures
}
})
)
})
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -1,103 +0,0 @@
<template>
<div class="flex w-full flex-col gap-2 px-4 py-2">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainModels"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainModels">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="ml-6 text-sm text-muted-foreground"
>
<template #link>
<Button
variant="textonly"
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<div class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleAction">
{{ buttonLabel }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { ModelWithUrl } from './missingModelsUtils'
import {
downloadModel,
hasValidDirectory,
isModelDownloadable
} from './missingModelsUtils'
const { missingModels, paths } = defineProps<{
missingModels: ModelWithUrl[]
paths: Record<string, string[]>
}>()
const DIALOG_KEY = 'global-missing-models-warning'
const { t } = useI18n()
const dialogStore = useDialogStore()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
})
function openShowMissingModelsSetting() {
dialogStore.closeDialog({ key: DIALOG_KEY })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const downloadableModels = computed(() =>
missingModels.filter(
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
)
)
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
const hasCustom = computed(
() => downloadableModels.value.length < missingModels.length
)
const buttonLabel = computed(() => {
if (hasDownloadable.value && hasCustom.value)
return t('missingModelsDialog.downloadAvailable')
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
return t('missingModelsDialog.gotIt')
})
function handleAction() {
if (hasDownloadable.value) {
for (const model of downloadableModels.value) {
downloadModel(model, paths)
}
}
dialogStore.closeDialog({ key: DIALOG_KEY })
}
</script>

View File

@@ -1,10 +0,0 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm">
{{ $t('missingModelsDialog.title') }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,177 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<Suspense v-if="isDesktop">
<ElectronFileDownload
:url="option.url"
:label="option.label"
:error="option.error"
/>
</Suspense>
<FileDownload
v-else
:url="option.url"
:label="option.label"
:error="option.error"
/>
</template>
</ListBox>
</template>
<script setup lang="ts">
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {
name: string
directory: string
url: string
downloading?: boolean
completed?: boolean
progress?: number
error?: string
folder_path?: string
}
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (!paths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
downloading: false,
completed: false,
progress: 0,
error: null,
name: model.name,
directory: model.directory,
url: model.url,
folder_path: paths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!whiteListedUrls.has(model.url)) {
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
}
return {
url: model.url,
label: `${model.directory} / ${model.name}`,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path
}
})
})
onBeforeUnmount(async () => {
if (doNotAskAgain.value) {
await useSettingStore().set(
'Comfy.Workflow.ShowMissingModelsWarning',
false
)
}
})
</script>
<style scoped>
.comfy-missing-models {
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -1,83 +0,0 @@
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:'
] as const
const ALLOWED_SUFFIXES = [
'.safetensors',
'.sft',
'.ckpt',
'.pth',
'.pt'
] as const
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const DIRECTORY_BADGE_MAP = {
vae: 'VAE',
diffusion_models: 'DIFFUSION',
text_encoders: 'TEXT ENCODER',
loras: 'LORA',
checkpoints: 'CHECKPOINT'
} as const
export interface ModelWithUrl {
name: string
url: string
directory: string
}
export function isModelDownloadable(model: ModelWithUrl): boolean {
if (WHITE_LISTED_URLS.has(model.url)) return true
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
return false
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
return false
return true
}
export function hasValidDirectory(
model: ModelWithUrl,
paths: Record<string, string[]>
): boolean {
return !!paths[model.directory]
}
export function getBadgeLabel(directory: string): string {
if (directory in DIRECTORY_BADGE_MAP) {
return DIRECTORY_BADGE_MAP[directory as keyof typeof DIRECTORY_BADGE_MAP]
}
return directory.toUpperCase()
}
export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
}

View File

@@ -17,9 +17,9 @@
}"
@row-dblclick="editKeybinding($event.data)"
>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<Column field="actions" header="">
<template #body="slotProps">
<div class="actions flex flex-row">
<div class="actions invisible flex flex-row">
<Button
variant="textonly"
size="icon"
@@ -56,7 +56,6 @@
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div class="truncate" :title="slotProps.data.id">
@@ -64,11 +63,7 @@
</div>
</template>
</Column>
<Column
field="keybinding"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<Column field="keybinding" :header="$t('g.keybinding')">
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
@@ -80,11 +75,7 @@
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<Column field="source" :header="$t('g.source')">
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
@@ -302,3 +293,17 @@ async function resetAllKeybindings() {
})
}
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-datatable-tbody) > tr > td {
@apply p-1;
min-height: 2rem;
}
:deep(.p-datatable-row-selected) .actions,
:deep(.p-datatable-selectable-row:hover) .actions {
@apply visible;
}
</style>

View File

@@ -98,17 +98,16 @@ describe('SignInForm', () => {
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
@@ -153,7 +152,7 @@ describe('SignInForm', () => {
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link

View File

@@ -34,13 +34,10 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
:class="
cn('text-base font-medium text-muted select-none', {
'cursor-not-allowed opacity-50':
!$form.email?.value || $form.email?.invalid,
'cursor-pointer': $form.email?.value && !$form.email?.invalid
})
"
class="cursor-pointer text-base font-medium text-muted select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
@@ -92,7 +89,6 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -130,3 +126,11 @@ const handleForgotPassword = async (
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -38,8 +38,7 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -127,7 +126,6 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'

View File

@@ -8,38 +8,20 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraph } from '@/scripts/defaultGraph'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
function createMockWorkflow(
overrides: Partial<LoadedComfyWorkflow> = {}
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflow({
path: 'workflows/color-picker-test.json',
modified: 0,
size: 0
})
const changeTracker = Object.assign(
new ChangeTracker(workflow, structuredClone(defaultGraph)),
{
return {
changeTracker: {
checkState: vi.fn() as Mock
}
)
const workflowOverrides = {
changeTracker,
},
...overrides
} satisfies Partial<LoadedComfyWorkflow>
return Object.assign(workflow, workflowOverrides) as LoadedComfyWorkflow
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
}
// Mock the litegraph module
@@ -128,14 +110,12 @@ describe('ColorPickerButton', () => {
const wrapper = createWrapper()
const button = wrapper.find('button')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
await button.trigger('click')
const picker = wrapper.findComponent({ name: 'SelectButton' })
expect(picker.exists()).toBe(true)
expect(picker.findAll('button').length).toBeGreaterThan(0)
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
await button.trigger('click')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
})
})

View File

@@ -11,17 +11,13 @@
@click="() => (showColorPicker = !showColorPicker)"
>
<div class="flex items-center gap-1 px-0">
<i
class="pi pi-circle-fill"
data-testid="color-picker-current-color"
:style="{ color: currentColor ?? '' }"
/>
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i class="icon-[lucide--chevron-down]" />
</div>
</Button>
<div
v-if="showColorPicker"
class="absolute -top-10 left-1/2 -translate-x-1/2"
class="color-picker-container absolute -top-10 left-1/2"
>
<SelectButton
:model-value="selectedColorOption"
@@ -163,7 +159,13 @@ watch(
</script>
<style scoped>
@reference '../../../assets/css/style.css';
.color-picker-container {
transform: translateX(-50%);
}
:deep(.p-togglebutton) {
padding: calc(var(--spacing) * 2) var(--spacing);
@apply py-2 px-1;
}
</style>

View File

@@ -2,14 +2,13 @@
<div
v-show="widgetState.visible"
ref="widgetElement"
class="dom-widget h-full w-full"
class="dom-widget"
:title="tooltip"
:style="style"
>
<component
:is="widget.component"
v-if="isComponentWidget(widget)"
class="h-full w-full"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
@@ -175,8 +174,6 @@ const mountElementIfVisible = () => {
if (widgetElement.value.contains(widget.element)) {
return
}
widget.element.classList.add('h-full', 'w-full')
widgetElement.value.appendChild(widget.element)
}
@@ -199,3 +196,11 @@ watch(
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
</script>
<style scoped>
@reference '../../../assets/css/style.css';
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -8,14 +8,11 @@
<!-- Markdown fetched successfully -->
<div
v-else-if="!error"
class="markdown-content overflow-visible text-sm leading-(--text-sm--line-height)"
class="markdown-content"
v-html="renderedHelpHtml"
/>
<!-- Fallback: markdown not found or fetch error -->
<div
v-else
class="fallback-content space-y-6 text-sm leading-(--text-sm--line-height)"
>
<div v-else class="fallback-content space-y-6 text-sm">
<p v-if="node.description">
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
</p>
@@ -25,52 +22,48 @@
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
</p>
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<table class="overflow-x-auto">
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="outputList.length">
<p>
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
</p>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<table class="overflow-x-auto">
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -107,59 +100,39 @@ const outputList = computed(() =>
</script>
<style scoped>
@reference './../../assets/css/style.css';
.node-help-content :deep(:is(img, video)) {
display: block;
max-width: 100%;
height: auto;
margin-bottom: calc(var(--spacing) * 4);
@apply max-w-full h-auto block mb-4;
}
.markdown-content,
.fallback-content {
@apply text-sm overflow-visible;
}
.markdown-content :deep(h1),
.fallback-content h1 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h2),
.fallback-content h2 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h3),
.fallback-content h3 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-base);
font-weight: var(--font-weight-bold);
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h4),
.fallback-content h4 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h5),
.fallback-content h5 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-bold);
}
.markdown-content :deep(h6),
.fallback-content h4,
.fallback-content h5,
.fallback-content h6 {
margin-top: calc(var(--spacing) * 8);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-xs);
font-weight: var(--font-weight-bold);
@apply mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(td),
@@ -182,8 +155,7 @@ const outputList = computed(() =>
.markdown-content :deep(ol),
.fallback-content ul,
.fallback-content ol {
margin-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 8);
@apply pl-8 my-2;
}
.markdown-content :deep(ul ul),
@@ -194,42 +166,36 @@ const outputList = computed(() =>
.fallback-content ol ol,
.fallback-content ul ol,
.fallback-content ol ul {
margin-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 6);
@apply pl-6 my-2;
}
.markdown-content :deep(li),
.fallback-content li {
margin-block: calc(var(--spacing) * 2);
@apply my-2;
}
.markdown-content :deep(*:first-child),
.fallback-content > *:first-child {
margin-top: 0;
@apply mt-0;
}
.markdown-content :deep(code),
.fallback-content code {
color: var(--code-text-color);
background-color: var(--code-bg-color);
border-radius: var(--radius);
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
@apply rounded px-1.5 py-0.5;
}
.markdown-content :deep(table),
.fallback-content table {
border-collapse: collapse;
}
.fallback-content table {
width: 100%;
@apply w-full border-collapse;
}
.markdown-content :deep(th),
.markdown-content :deep(td),
.fallback-content th,
.fallback-content td {
padding: calc(var(--spacing) * 2);
@apply px-2 py-2;
}
.markdown-content :deep(tr),
@@ -249,22 +215,16 @@ const outputList = computed(() =>
.markdown-content :deep(pre),
.fallback-content pre {
margin-block: calc(var(--spacing) * 4);
overflow-x: auto;
border-radius: var(--radius);
padding: calc(var(--spacing) * 4);
@apply rounded p-4 my-4 overflow-x-auto;
background-color: var(--code-block-bg-color);
code {
background-color: transparent;
padding: 0;
@apply bg-transparent p-0;
color: var(--p-text-color);
}
}
.markdown-content :deep(table) {
display: block;
width: 100%;
overflow-x: auto;
@apply overflow-x-auto;
}
</style>

View File

@@ -1,359 +0,0 @@
<template>
<div
class="widget-expands flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<div
class="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-lg bg-node-component-surface"
>
<div class="relative max-h-full w-full" :style="canvasContainerStyle">
<img
v-if="inputImageUrl"
:src="inputImageUrl"
class="absolute inset-0 size-full"
draggable="false"
@load="handleInputImageLoad"
@dragstart.prevent
/>
<canvas
ref="canvasEl"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
/>
<div
v-show="cursorVisible"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
/>
</div>
</div>
<div
v-if="isImageInputConnected"
class="text-center text-xs text-muted-foreground"
>
{{ canvasWidth }} x {{ canvasHeight }}
</div>
<div
ref="controlsEl"
:class="
cn(
'grid shrink-0 gap-x-1 gap-y-1',
compact ? 'grid-cols-1' : 'grid-cols-[auto_1fr]'
)
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
</div>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.BRUSH
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.BRUSH"
>
{{ $t('painter.brush') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.ERASER
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.ERASER"
>
{{ $t('painter.eraser') }}
</Button>
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushSize]"
:min="1"
:max="200"
:step="1"
class="flex-1"
@update:model-value="(v) => v?.length && (brushSize = v[0])"
/>
<span class="w-8 text-center text-xs text-node-text-muted">{{
brushSize
}}</span>
</div>
<template v-if="tool === PAINTER_TOOLS.BRUSH">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.color') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="brushColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) => (brushColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
brushColorDisplay
}}</span>
<span class="ml-auto flex items-center text-xs text-node-text-muted">
<input
type="number"
:value="brushOpacityPercent"
min="0"
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.prevent
@change="
(e) => {
const val = Math.min(
100,
Math.max(0, Number((e.target as HTMLInputElement).value))
)
brushOpacityPercent = val
;(e.target as HTMLInputElement).value = String(val)
}
"
/>%</span
>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushHardnessPercent]"
:min="0"
:max="100"
:step="1"
class="flex-1"
@update:model-value="
(v) => v?.length && (brushHardnessPercent = v[0])
"
/>
<span class="w-8 text-center text-xs text-node-text-muted"
>{{ brushHardnessPercent }}%</span
>
</div>
</template>
<template v-if="!isImageInputConnected">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasWidth]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasWidth = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasWidth
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasHeight]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasHeight = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasHeight
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="backgroundColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) =>
(backgroundColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
backgroundColorDisplay
}}</span>
</div>
</template>
<Button
variant="secondary"
size="md"
:class="
cn(
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
!compact && 'col-span-2'
)
"
@click="handleClear"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('painter.clear') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, useTemplateRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
import { toHexFromFormat } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const { nodeId } = defineProps<{
nodeId: string
}>()
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
() => controlsWidth.value > 0 && controlsWidth.value < 350
)
const {
tool,
brushSize,
brushColor,
brushOpacity,
brushHardness,
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
isImageInputConnected,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerEnter,
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
backgroundColor: isImageInputConnected.value
? undefined
: backgroundColor.value
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),
set: (val: number) => {
brushOpacity.value = val / 100
}
})
const brushHardnessPercent = computed({
get: () => Math.round(brushHardness.value * 100),
set: (val: number) => {
brushHardness.value = val / 100
}
})
const brushColorDisplay = computed({
get: () => toHexFromFormat(brushColor.value, 'hex'),
set: (val: unknown) => {
brushColor.value = toHexFromFormat(val, 'hex')
}
})
const backgroundColorDisplay = computed({
get: () => toHexFromFormat(backgroundColor.value, 'hex'),
set: (val: unknown) => {
backgroundColor.value = toHexFromFormat(val, 'hex')
}
})
</script>

View File

@@ -1,146 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('vue-i18n', () => {
return {
createI18n: () => ({
global: {
t: (key: string) => key,
te: () => true,
d: (value: string) => value
}
}),
useI18n: () => ({
t: (key: string) => key
})
}
})
const createResultItem = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
...overrides
})
const mountJobAssetsList = (jobs: JobListItem[]) => {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
return mount(JobAssetsList, {
props: { displayedJobGroups }
})
}
describe('JobAssetsList', () => {
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
listItem.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
expect(listItem.props('isVideoPreview')).toBe(true)
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.find('i').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toBeUndefined()
})
})

View File

@@ -12,8 +12,7 @@
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
@@ -24,8 +23,6 @@
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
@@ -81,7 +78,7 @@ import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
@@ -103,28 +100,6 @@ const isCancelable = (job: JobListItem) =>
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
const getJobPreviewUrl = (job: JobListItem) => {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
}
return job.iconImageUrl
}
const isVideoPreviewJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
const isPreviewableCompletedJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)
const emitViewItem = (job: JobListItem) => {
if (isPreviewableCompletedJob(job)) {
emit('viewItem', job)
}
}
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {

View File

@@ -131,11 +131,11 @@ export const Queued: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const priority = 104
const queueIndex = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 90_000)
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const priority = 210
const queueIndex = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, priority, Date.now() - 120_000)
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
@@ -238,9 +238,9 @@ export const Running: Story = {
const exec = useExecutionStore()
const jobId = 'job-running-1'
const priority = 300
const queueIndex = 300
queue.runningTasks = [
makeRunningTask(jobId, priority, Date.now() - 65_000)
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const priority = 510
const queueIndex = 510
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 45_000)
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
]
queue.historyTasks = [
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const priority = 520
const queueIndex = 520
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 20_000)
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const priority = 410
const queueIndex = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
priority,
queueIndex,
12,
false,
'Example error: invalid inputs for node X'

View File

@@ -168,14 +168,14 @@ const queuedAtValue = computed(() =>
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.job.priority) : null
return task ? Number(task.queueIndex) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.job.priority) < idx
(t: TaskItemImpl) => Number(t.queueIndex) < idx
)
return ahead.length
})

View File

@@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import RangeEditor from './RangeEditor.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
props,
global: { plugins: [i18n] }
})
}
describe('RangeEditor', () => {
it('renders with min and max handles', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
})
it('highlights selected range in plain mode', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
expect(highlight.attributes('width')).toBe('0.6000000000000001')
})
it('dims area outside the range in histogram mode', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})
const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
})
it('hides midpoint handle by default', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
})
it('shows midpoint handle when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
})
it('renders gradient background when display is gradient', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
})
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
})
it('renders histogram path when display is histogram with data', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
})
it('renders inputs for min and max', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(2)
})
it('renders midpoint input when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(3)
})
it('normalizes handle positions with custom value range', () => {
const wrapper = mountEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})
const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')
// 64/255 ≈ 25.1%, 192/255 ≈ 75.3%
expect(minHandle.attributes('style')).toContain('left: 25.0')
expect(maxHandle.attributes('style')).toContain('left: 75.')
})
})

View File

@@ -1,283 +0,0 @@
<template>
<div>
<div
ref="trackRef"
class="relative select-none"
@pointerdown.stop="handleTrackPointerDown"
@contextmenu.prevent.stop
>
<!-- Track -->
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
:class="
cn(
'block w-full rounded-sm bg-node-component-surface',
display === 'histogram' ? 'aspect-[3/2]' : 'h-8'
)
"
>
<defs v-if="display === 'gradient'">
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<stop
v-for="(stop, i) in computedStops"
:key="i"
:offset="stop.offset"
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
/>
</linearGradient>
</defs>
<rect
v-if="display === 'gradient'"
data-testid="gradient-bg"
x="0"
y="0"
width="1"
height="1"
:fill="`url(#${gradientId})`"
/>
<path
v-if="display === 'histogram' && histogramPath"
data-testid="histogram-path"
:d="histogramPath"
fill="currentColor"
fill-opacity="0.3"
/>
<rect
v-if="display === 'plain'"
data-testid="range-highlight"
:x="minNorm"
y="0"
:width="Math.max(0, maxNorm - minNorm)"
height="1"
fill="white"
fill-opacity="0.15"
/>
<template v-if="display === 'histogram'">
<rect
v-if="minNorm > 0"
data-testid="range-dim-left"
x="0"
y="0"
:width="minNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
<rect
v-if="maxNorm < 1"
data-testid="range-dim-right"
:x="maxNorm"
y="0"
:width="1 - maxNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
</template>
</svg>
<!-- Min handle -->
<div
data-testid="handle-min"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('min', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#333"
stroke="#aaa"
stroke-width="0.5"
/>
</svg>
</div>
<!-- Midpoint handle -->
<div
v-if="showMidpoint && modelValue.midpoint !== undefined"
data-testid="handle-midpoint"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('midpoint', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#888"
stroke="#ccc"
stroke-width="0.5"
/>
</svg>
</div>
<!-- Max handle -->
<div
data-testid="handle-max"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('max', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="white"
stroke="#555"
stroke-width="0.5"
/>
</svg>
</div>
</div>
<!-- Value inputs -->
<div class="mt-3 flex items-center justify-between" @pointerdown.stop>
<ScrubableNumberInput
v-model="minValue"
:display-value="formatValue(minValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-if="showMidpoint && modelValue.midpoint !== undefined"
v-model="midpointValue"
:display-value="midpointValue.toFixed(2)"
:min="midpointScale === 'gamma' ? 0.01 : 0"
:max="midpointScale === 'gamma' ? 9.99 : 1"
:step="0.01"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-model="maxValue"
:display-value="formatValue(maxValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef, useId, useTemplateRef } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { cn } from '@/utils/tailwindUtil'
import { histogramToPath } from '@/components/curve/curveUtils'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import {
clamp,
clamp01,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
const {
display = 'plain',
gradientStops,
showMidpoint = false,
midpointScale = 'linear',
histogram,
valueMin = 0,
valueMax = 1
} = defineProps<{
display?: 'plain' | 'gradient' | 'histogram'
gradientStops?: ColorStop[]
showMidpoint?: boolean
midpointScale?: 'linear' | 'gamma'
histogram?: Uint32Array | null
valueMin?: number
valueMax?: number
}>()
const modelValue = defineModel<RangeValue>({ required: true })
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
const gradientId = useId()
const { handleTrackPointerDown, startDrag } = useRangeEditor({
trackRef,
modelValue,
valueMin: toRef(() => valueMin),
valueMax: toRef(() => valueMax)
})
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
function formatValue(v: number): string {
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
}
/** Normalize an actual value to 0-1 for SVG positioning */
const minNorm = computed(() =>
normalize(modelValue.value.min, valueMin, valueMax)
)
const maxNorm = computed(() =>
normalize(modelValue.value.max, valueMin, valueMax)
)
const computedStops = computed(
() =>
gradientStops ?? [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
)
const midpointPercent = computed(() => {
const { min, max, midpoint } = modelValue.value
if (midpoint === undefined) return 0
const midAbs = min + midpoint * (max - min)
return normalize(midAbs, valueMin, valueMax) * 100
})
const minValue = computed({
get: () => modelValue.value.min,
set: (min) => {
modelValue.value = {
...modelValue.value,
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
}
}
})
const maxValue = computed({
get: () => modelValue.value.max,
set: (max) => {
modelValue.value = {
...modelValue.value,
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
}
}
})
const midpointValue = computed({
get: () => {
const pos = modelValue.value.midpoint ?? 0.5
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
},
set: (val) => {
const position =
midpointScale === 'gamma' ? clamp01(gammaToPosition(val)) : clamp01(val)
modelValue.value = { ...modelValue.value, midpoint: position }
}
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -1,30 +0,0 @@
<template>
<RangeEditor
v-model="modelValue"
:display="widget?.options?.display"
:gradient-stops="widget?.options?.gradient_stops"
:show-midpoint="widget?.options?.show_midpoint"
:midpoint-scale="widget?.options?.midpoint_scale"
:histogram="widget?.options?.histogram"
:value-min="widget?.options?.value_min"
:value-max="widget?.options?.value_max"
/>
</template>
<script setup lang="ts">
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RangeEditor from './RangeEditor.vue'
defineProps<{
widget?: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}>()
const modelValue = defineModel<RangeValue>({
default: () => ({ min: 0, max: 1 })
})
</script>

View File

@@ -1,131 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
clamp,
clamp01,
constrainRange,
denormalize,
formatMidpointLabel,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
describe('clamp', () => {
it('clamps to arbitrary range', () => {
expect(clamp(128, 0, 255)).toBe(128)
expect(clamp(-10, 0, 255)).toBe(0)
expect(clamp(300, 0, 255)).toBe(255)
})
})
describe('normalize', () => {
it('normalizes value to 0-1', () => {
expect(normalize(128, 0, 256)).toBe(0.5)
expect(normalize(0, 0, 255)).toBe(0)
expect(normalize(255, 0, 255)).toBe(1)
})
it('returns 0 when min equals max', () => {
expect(normalize(5, 5, 5)).toBe(0)
})
})
describe('denormalize', () => {
it('converts normalized value back to range', () => {
expect(denormalize(0.5, 0, 256)).toBe(128)
expect(denormalize(0, 0, 255)).toBe(0)
expect(denormalize(1, 0, 255)).toBe(255)
})
it('round-trips with normalize', () => {
expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100)
})
})
describe('clamp01', () => {
it('returns value within bounds unchanged', () => {
expect(clamp01(0.5)).toBe(0.5)
})
it('clamps values below 0', () => {
expect(clamp01(-0.5)).toBe(0)
})
it('clamps values above 1', () => {
expect(clamp01(1.5)).toBe(1)
})
it('returns 0 for 0', () => {
expect(clamp01(0)).toBe(0)
})
it('returns 1 for 1', () => {
expect(clamp01(1)).toBe(1)
})
})
describe('positionToGamma', () => {
it('converts 0.5 to gamma 1.0', () => {
expect(positionToGamma(0.5)).toBeCloseTo(1.0)
})
it('converts 0.25 to gamma 2.0', () => {
expect(positionToGamma(0.25)).toBeCloseTo(2.0)
})
})
describe('gammaToPosition', () => {
it('converts gamma 1.0 to position 0.5', () => {
expect(gammaToPosition(1.0)).toBeCloseTo(0.5)
})
it('converts gamma 2.0 to position 0.25', () => {
expect(gammaToPosition(2.0)).toBeCloseTo(0.25)
})
it('round-trips with positionToGamma', () => {
for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) {
expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos)
}
})
})
describe('formatMidpointLabel', () => {
it('formats linear scale as decimal', () => {
expect(formatMidpointLabel(0.5, 'linear')).toBe('0.50')
})
it('formats gamma scale as gamma value', () => {
expect(formatMidpointLabel(0.5, 'gamma')).toBe('1.00')
})
})
describe('constrainRange', () => {
it('passes through valid range unchanged', () => {
const result = constrainRange({ min: 0.2, max: 0.8 })
expect(result).toEqual({ min: 0.2, max: 0.8, midpoint: undefined })
})
it('clamps values to [0, 1]', () => {
const result = constrainRange({ min: -0.5, max: 1.5 })
expect(result.min).toBe(0)
expect(result.max).toBe(1)
})
it('enforces min <= max', () => {
const result = constrainRange({ min: 0.8, max: 0.3 })
expect(result.min).toBe(0.8)
expect(result.max).toBe(0.8)
})
it('preserves midpoint when present', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 0.5 })
expect(result.midpoint).toBe(0.5)
})
it('clamps midpoint to [0, 1]', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 1.5 })
expect(result.midpoint).toBe(1)
})
})

View File

@@ -1,48 +0,0 @@
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
export function clamp01(value: number): number {
return clamp(value, 0, 1)
}
export function normalize(value: number, min: number, max: number): number {
return max === min ? 0 : (value - min) / (max - min)
}
export function denormalize(
normalized: number,
min: number,
max: number
): number {
return min + normalized * (max - min)
}
export function positionToGamma(position: number): number {
const clamped = Math.max(0.001, Math.min(0.999, position))
return -Math.log2(clamped)
}
export function gammaToPosition(gamma: number): number {
return Math.pow(2, -gamma)
}
export function formatMidpointLabel(
position: number,
scale: 'linear' | 'gamma'
): string {
if (scale === 'gamma') {
return positionToGamma(position).toFixed(2)
}
return position.toFixed(2)
}
export function constrainRange(value: RangeValue): RangeValue {
const min = clamp01(value.min)
const max = clamp01(Math.max(min, value.max))
const midpoint =
value.midpoint !== undefined ? clamp01(value.midpoint) : undefined
return { min, max, midpoint }
}

View File

@@ -19,9 +19,7 @@
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
{{ group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack') }}
</span>
</p>
<Button
@@ -29,7 +27,6 @@
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
@@ -43,11 +40,6 @@
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
@click="toggleExpand"
>
<i
@@ -81,11 +73,9 @@
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
@@ -103,12 +93,14 @@
"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
<div
:class="
cn(
'flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 transition-colors select-none',
comfyManagerStore.isPackInstalled(group.packId)
? 'bg-secondary-background opacity-60 cursor-not-allowed'
: 'bg-secondary-background-hover cursor-pointer hover:bg-secondary-background-selected'
)
"
@click="handlePackInstallClick"
>
@@ -135,7 +127,7 @@
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
</div>
<!-- Registry still loading: packId known but result not yet available -->
@@ -158,10 +150,8 @@
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
<div
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background-hover cursor-pointer hover:bg-secondary-background-selected transition-colors select-none"
@click="
openManager({
initialTab: ManagerTab.All,
@@ -173,7 +163,7 @@
<span class="text-sm text-foreground truncate min-w-0">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</div>
</template>

View File

@@ -37,14 +37,10 @@
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.title
}}
{{ group.title }}
</span>
<span
v-if="group.type === 'execution' && group.cards.length > 1"
v-if="group.type !== 'missing_node' && group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
@@ -198,13 +194,11 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null

View File

@@ -14,11 +14,11 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
export type ErrorGroup =
| {
type: 'execution'
title: string
cards: ErrorCardData[]
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
export type ErrorGroupType = 'execution' | 'missing_node' | 'missing_model'
export interface ErrorGroup {
type: ErrorGroupType
title: string
cards: ErrorCardData[]
priority: number
}

View File

@@ -21,7 +21,12 @@ import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type {
ErrorCardData,
ErrorGroup,
ErrorGroupType,
ErrorItem
} from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
@@ -36,14 +41,20 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
/**
* A group of missing node types belonging to the same node pack.
*/
export interface MissingPackGroup {
/** Registry pack ID (cnrId). null = could not be resolved. */
packId: string | null
/** Missing node types belonging to this pack. */
nodeTypes: MissingNodeType[]
/** True while async pack inference is still in progress for this group. */
isResolving: boolean
}
interface GroupEntry {
type: 'execution'
type: ErrorGroupType
priority: number
cards: Map<string, ErrorCardData>
}
@@ -83,11 +94,12 @@ function resolveNodeInfo(nodeId: string) {
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
priority = 1
priority = 1,
type: ErrorGroupType = 'execution'
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { type: 'execution', priority, cards: new Map() }
entry = { type, priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
@@ -148,7 +160,7 @@ function addCardErrorToGroup(
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
type: 'execution' as const,
type: groupData.type,
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
@@ -165,7 +177,6 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
if (group.type !== 'execution') continue
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
searchableList.push({
@@ -173,12 +184,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
@@ -201,16 +208,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
)
return groups
.map((group, gi) => {
if (group.type !== 'execution') return group
return {
...group,
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
matchedCardKeys.has(`${gi}:${ci}`)
)
}
})
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
@@ -391,46 +393,25 @@ export function useErrorGroups(
watch(
pendingTypes,
async (pending, _, onCleanup) => {
async (pending) => {
const toResolve = pending.filter(
(n) => asyncResolvedIds.value.get(n.type) === undefined
(n) => !asyncResolvedIds.value.has(n.type)
)
if (!toResolve.length) return
const resolvingTypes = toResolve.map((n) => n.type)
let cancelled = false
onCleanup(() => {
cancelled = true
const next = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) {
if (next.get(type) === RESOLVING) next.delete(type)
}
asyncResolvedIds.value = next
})
const updated = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) updated.set(type, RESOLVING)
for (const nodeType of toResolve) {
updated.set(nodeType.type, RESOLVING)
}
asyncResolvedIds.value = updated
const results = await Promise.allSettled(
toResolve.map(async (n) => ({
type: n.type,
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
}))
)
if (cancelled) return
const final = new Map(asyncResolvedIds.value)
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
}
for (const nodeType of toResolve) {
const pack = await inferPackFromNodeName.call(nodeType.type)
asyncResolvedIds.value = new Map(asyncResolvedIds.value).set(
nodeType.type,
pack?.id ?? null
)
}
// Clear any remaining RESOLVING markers for failed lookups
for (const type of resolvingTypes) {
if (final.get(type) === RESOLVING) final.set(type, null)
}
asyncResolvedIds.value = final
},
{ immediate: true }
)
@@ -504,6 +485,17 @@ export function useErrorGroups(
{
type: 'missing_node' as const,
title: error.message,
cards: [
{
id: '__missing_nodes__',
title: error.message,
errors: [
{
message: error.message
}
]
}
],
priority: 0
}
]
@@ -541,15 +533,10 @@ export function useErrorGroups(
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
} else {
// Groups without cards (e.g. missing_node) surface their title as the message.
messages.add(group.title)
}
}
return Array.from(messages)

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import {
demoteWidget,
@@ -18,10 +17,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@/utils/tailwindUtil'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -31,6 +30,9 @@ const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
@@ -193,9 +195,54 @@ function showRecommended() {
}
}
function setDraggableState() {
draggableList.value?.dispose()
if (searchQuery.value || !draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const aw = activeWidgets.value
const [w] = aw.splice(oldPosition, 1)
aw.splice(newPosition, 0, w)
activeWidgets.value = aw
}
}
watch(filteredActive, () => {
setDraggableState()
})
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
@@ -233,18 +280,19 @@ onMounted(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.name"
:is-physical="node.id === -1"
:is-shown="true"
:is-draggable="!searchQuery"
:is-physical="node.id === -1"
@toggle-visibility="demote([node, widget])"
/>
</DraggableList>
</div>
</div>
<div

View File

@@ -29,7 +29,8 @@ function getIcon() {
cn(
'flex py-1 px-2 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable && 'hover:ring-1 ring-accent-background',
props.isDraggable &&
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
props.class
)
"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-2">
<div class="_content">
<SelectButton
v-model="selectedFilter"
class="filter-type-select"
@@ -16,7 +16,7 @@
auto-filter-focus
/>
</div>
<div class="flex flex-col items-end pt-4">
<div class="_footer">
<Button type="button" @click="submit">{{ $t('g.add') }}</Button>
</div>
</template>
@@ -67,3 +67,15 @@ const submit = () => {
})
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
._content {
@apply flex flex-col space-y-2;
}
._footer {
@apply flex flex-col pt-4 items-end;
}
</style>

View File

@@ -255,6 +255,8 @@ onMounted(() => {
</style>
<style scoped>
@reference "tailwindcss";
.floating-sidebar {
padding: var(--sidebar-padding);
}

View File

@@ -15,7 +15,7 @@
:aria-label="computedTooltip"
@click="emit('click', $event)"
>
<div class="side-bar-button-content flex flex-col items-center gap-2">
<div class="side-bar-button-content">
<slot name="icon">
<div class="sidebar-icon-wrapper relative">
<i
@@ -40,11 +40,9 @@
</span>
</div>
</slot>
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-[10px]"
>{{ st(label, label) }}</span
>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
st(label, label)
}}</span>
</div>
</Button>
</template>
@@ -106,6 +104,8 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</style>
<style scoped>
@reference '../../assets/css/style.css';
.side-bar-button {
width: var(--sidebar-width);
height: var(--sidebar-item-height);
@@ -117,7 +117,12 @@ const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
height: var(--sidebar-width);
}
.side-bar-button-content {
@apply flex flex-col items-center gap-2;
}
.side-bar-button-label {
@apply text-[10px] text-center;
line-height: 1;
}

View File

@@ -106,42 +106,4 @@ describe('AssetsSidebarListView', () => {
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
})
it('emits preview-asset when item preview is clicked', async () => {
const imageAsset = {
...buildAsset('image-asset', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
assetListItem!.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('emits preview-asset when item is double-clicked', async () => {
const imageAsset = {
...buildAsset('image-asset-dbl', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
await assetListItem!.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
})

View File

@@ -56,8 +56,6 @@
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
@@ -118,7 +116,6 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()

View File

@@ -95,7 +95,6 @@
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
@@ -217,6 +216,10 @@ import {
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
@@ -248,10 +251,6 @@ import {
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()

View File

@@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
@@ -86,17 +86,11 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t, n } = useI18n()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
@@ -157,24 +151,6 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
await openResultGallery(item)
})

View File

@@ -11,7 +11,6 @@
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
:pt="sidebarPt"
>
<template #start>
<span class="truncate font-bold" :title="props.title">
@@ -21,7 +20,7 @@
</template>
<template #end>
<div
class="touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
>
<slot name="tool-buttons" />
</div>
@@ -55,10 +54,19 @@ const props = defineProps<{
title: string
class?: string
}>()
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'
}
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>
<style scoped>
@reference '../../../assets/css/style.css';
:deep(.p-toolbar-end) .p-button {
@apply py-1 2xl:py-2;
}
:deep(.p-toolbar-start) {
@apply min-w-0 flex-1 overflow-hidden;
}
</style>

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