Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
0ed9a379c6 fix: test(assetsStore): improve test comprehensiveness and avoid inline type redefinition (#9728)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:15:24 +01:00
117 changed files with 517 additions and 7997 deletions

View File

@@ -1,150 +0,0 @@
---
name: backport-management
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
---
# Backport Management
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
## Quick Start
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
5. **Verify** — After each wave, verify branch integrity before proceeding
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
## System Context
| Item | Value |
| -------------- | ------------------------------------------------- |
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
| Tracking dir | `~/temp/backport-session/` |
## Branch Scope Rules
**Critical: Match PRs to the correct target branches.**
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ----------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
## ⚠️ Gotchas (Learn from Past Sessions)
### Use `gh api` for Labels — NOT `gh pr edit`
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
```bash
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
```
### Automation Over-Reports Conflicts
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
### Never Skip Based on Conflict File Count
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
| Type | Symptom | Resolution |
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
```python
# Accept theirs for content conflicts
import re
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
```
### Escalation Triggers (Flag for Human)
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
- **Core type definition changes** → requires human judgment
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
## Auto-Skip Categories
Skip these without discussion:
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
- **CI/tooling changes** — Not user-facing
- **Test-only / lint rule changes** — Not user-facing
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
## Wave Verification
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
```bash
# Fetch latest state of target branch
git fetch origin TARGET_BRANCH
# Quick smoke check: does the branch build?
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
## Continuous Backporting Recommendation
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
- Backport bug fixes as they merge to main (same day or next day)
- Use the automation labels immediately after merge
- Reserve session-style bulk backporting for catching up after gaps
- When a release branch is created, immediately start the continuous process
## Quick Reference
### Label-Driven Automation (default path)
```bash
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
```
### Manual Worktree Cherry-Pick (conflict fallback)
```bash
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
cd /tmp/backport-$BRANCH
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
git cherry-pick -m 1 $MERGE_SHA
# Resolve conflicts, push, create PR, merge
```
### PR Title Convention
```
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
```

View File

@@ -1,68 +0,0 @@
# Analysis & Decision Framework
## Categorization
| Category | Criteria | Action |
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
### MUST vs SHOULD Decision Guide
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
- **Yes** → MUST. The fix addresses broken behavior.
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
- **No, and it's cosmetic or internal** → SKIP.
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
## Branch Scope Filtering
**Before categorizing, filter by branch scope:**
| Target branch | Skip if PR is... |
| ------------- | ------------------------------------------------------------------- |
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
| `cloud/*` | Local-only features not present on cloud branch |
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
## Features Not on Stable Branches
Check before backporting — these don't exist on older branches:
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
- **GLSLShader** — not on core/1.40
- **App builder** — check per branch
- **appModeStore.ts** — not on core/1.40
## Dep Refresh PRs
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
## Revert Pairs
If PR A is reverted by PR B:
- Skip BOTH A and B
- If a fixed version exists (PR C), backport only C
## Dependency Analysis
```bash
# Find other PRs that touched the same files
gh pr view $PR --json files --jq '.files[].path' | while read f; do
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
done
```
## Human Review Checkpoint
Present decisions.md before execution. Include:
1. All MUST/SHOULD/SKIP categorizations with rationale
2. Questions for human (feature existence, scope, deps)
3. Estimated effort per branch

View File

@@ -1,42 +0,0 @@
# Discovery — Candidate Collection
## Source 1: Slack Backport-Checker Bot
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
```bash
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
```
Parse bot messages for PRs flagged "Might need backport" per release version.
## Source 2: Git Log Gap Analysis
```bash
# Count gap
git log --oneline origin/TARGET..origin/main | wc -l
# List gap commits
git log --oneline origin/TARGET..origin/main
# Check if a PR is already on target
git log --oneline origin/TARGET --grep="#PR_NUMBER"
# Check for existing backport PRs
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
```
## Source 3: GitHub PR Details
```bash
# Get merge commit SHA
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
# Get files changed
gh pr view $PR --json files --jq '.files[].path'
```
## Output: candidate_list.md
Table per target branch:
| PR# | Title | Category | Flagged by Bot? | Decision |

View File

@@ -1,150 +0,0 @@
# Execution Workflow
## Per-Branch Execution Order
1. Smallest gap first (validation run)
2. Medium gap next (quick win)
3. Largest gap last (main effort)
## Step 1: Label-Driven Automation (Batch)
```bash
# Add labels to all candidates for a target branch
for pr in $PR_LIST; do
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
sleep 2
done
# Wait 3 minutes for automation
sleep 180
# Check which got auto-PRs
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
```
## Step 2: Review & Merge Clean Auto-PRs
```bash
for pr in $AUTO_PRS; do
# Check size
gh pr view $pr --json title,additions,deletions,changedFiles \
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
# Admin merge
gh pr merge $pr --squash --admin
sleep 3
done
```
## Step 3: Manual Worktree for Conflicts
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
cd /tmp/backport-TARGET
for PR in ${CONFLICT_PRS[@]}; do
# Refresh target ref so each branch is based on current HEAD
git fetch origin TARGET_BRANCH
git checkout origin/TARGET_BRANCH
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
git cherry-pick -m 1 $MERGE_SHA
# If conflict — NEVER skip based on file count alone!
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
# See SKILL.md Conflict Triage table for resolution per type.
# Resolve all conflicts, then:
git add .
GIT_EDITOR=true git cherry-pick --continue
git push origin backport-$PR-to-TARGET
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
gh pr merge $NEW_PR --squash --admin
sleep 3
done
# Cleanup
cd -
git worktree remove /tmp/backport-TARGET --force
```
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
## Step 4: Wave Verification
After completing all PRs in a wave for a target branch:
```bash
git fetch origin TARGET_BRANCH
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
cd /tmp/verify-TARGET
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
git worktree remove /tmp/verify-TARGET --force
```
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
## Conflict Resolution Patterns
### 1. Content Conflicts (accept theirs)
```python
import re
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
```
### 2. Modify/Delete (two cases!)
```bash
# Case A: PR introduces NEW files not on target → keep them
git add $FILE
# Case B: Target REMOVED files the PR modifies → drop them
git rm $FILE
```
### 3. Binary Files (snapshots)
```bash
git checkout --theirs $FILE && git add $FILE
```
### 4. Locale Files
Usually adding new i18n keys — accept theirs, validate JSON:
```bash
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
```
## Merge Conflicts After Other Merges
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
```bash
git fetch origin TARGET_BRANCH
git rebase origin/TARGET_BRANCH
# Resolve new conflicts
git push --force origin backport-$PR-to-TARGET
sleep 20 # Wait for GitHub to recompute merge state
gh pr merge $PR --squash --admin
```
## Lessons Learned
1. **Automation reports more conflicts than reality**`cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.

View File

@@ -1,96 +0,0 @@
# Logging & Session Reports
## During Execution
Maintain `execution-log.md` with per-branch tables:
```markdown
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
```
## Wave Verification Log
Track verification results per wave:
```markdown
## Wave N Verification — TARGET_BRANCH
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
## Session Report Template
```markdown
# Backport Session Report
## Summary
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
| ------ | ---------- | ------ | ------- | -------- | ---- |
## Deferred Items (Needs Human)
| PR# | Title | Branch | Issue |
## Conflict Resolutions Requiring Review
| PR# | Branch | Conflict Type | Resolution Summary |
## Automation Performance
| Metric | Value |
| --------------------------- | ----- |
| Auto success rate | X% |
| Manual resolution rate | X% |
| Overall clean rate | X% |
| Wave verification pass rate | X% |
## Process Recommendations
- Were there clusters of related PRs that should have been backported together?
- Any PRs that should have been backported sooner (continuous backporting candidates)?
- Feature branches that need tracking for future sessions?
```
## Final Deliverable: Visual Summary
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
```mermaid
graph TD
subgraph branch1["☁️ cloud/X.XX — N PRs"]
C1["#XXXX title"]
C2["#XXXX title"]
end
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
M1["#XXXX title"]
end
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
S1["#XXXX-#XXXX N auto-merged"]
S2["#XXXX-#XXXX N manual picks"]
end
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
```
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
## Files to Track
- `candidate_list.md` — all candidates per branch
- `decisions.md` — MUST/SHOULD/SKIP with rationale
- `wave-plan.md` — execution order
- `execution-log.md` — real-time status
- `backport-session-report.md` — final summary
All in `~/temp/backport-session/`.

View File

@@ -4,8 +4,6 @@ name: 'CI: Tests Storybook'
on:
workflow_dispatch: # Allow manual triggering
pull_request:
push:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
@@ -140,29 +138,6 @@ jobs:
"${{ github.head_ref }}" \
"completed"
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler@^4.0.0 pages deploy storybook-static \
--project-name=comfy-storybook \
--branch=main
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]

1
.gitignore vendored
View File

@@ -26,7 +26,6 @@ dist-ssr
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
.claude/worktrees
CLAUDE.local.md
# Editor directories and files

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,91 +0,0 @@
# Change Tracker (Undo/Redo System)
The `ChangeTracker` class (`src/scripts/changeTracker.ts`) manages undo/redo
history by comparing serialized graph snapshots.
## How It Works
`checkState()` is the core method. It:
1. Serializes the current graph via `app.rootGraph.serialize()`
2. Deep-compares the result against the last known `activeState`
3. If different, pushes `activeState` onto `undoQueue` and replaces it
**It is not reactive.** Changes to the graph (widget values, node positions,
links, etc.) are only captured when `checkState()` is explicitly triggered.
## Automatic Triggers
These are set up once in `ChangeTracker.init()`:
| Trigger | Event / Hook | What It Catches |
| ----------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
| Keyboard (non-modifier, non-repeat) | `window` `keydown` | Shortcuts, typing in canvas |
| Modifier key release | `window` `keyup` | Releasing Ctrl/Shift/Alt/Meta |
| Mouse click | `window` `mouseup` | General clicks on native DOM |
| Canvas mouse up | `LGraphCanvas.processMouseUp` override | LiteGraph canvas interactions |
| Number/string dialog | `LGraphCanvas.prompt` override | Dialog popups for editing widgets |
| Context menu close | `LiteGraph.ContextMenu.close` override | COMBO widget menus in LiteGraph |
| Active input element | `bindInput` (change/input/blur on focused element) | Native HTML input edits |
| Prompt queued | `api` `promptQueued` event | Dynamic widget changes on queue |
| Graph cleared | `api` `graphCleared` event | Full graph clear |
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
## When You Must Call `checkState()` Manually
The automatic triggers above are designed around LiteGraph's native DOM
rendering. They **do not cover**:
- **Vue-rendered widgets** — Vue handles events internally without triggering
native DOM events that the tracker listens to (e.g., `mouseup` on a Vue
dropdown doesn't bubble the same way as a native LiteGraph widget click)
- **Programmatic graph mutations** — Any code that modifies the graph outside
of user interaction (e.g., applying a template, pasting nodes, aligning)
- **Async operations** — File uploads, API calls that change widget values
after the initial user gesture
### Pattern for Manual Calls
```typescript
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// After mutating the graph:
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
```
### Existing Manual Call Sites
These locations already call `checkState()` explicitly:
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
- `ColorPickerButton.vue` — After changing node colors
- `NodeSearchBoxPopover.vue` — After adding a node from search
- `useAppSetDefaultView.ts` — After setting default view
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
- `useGroupMenuOptions.ts` — After group operations
- `useSubgraphOperations.ts` — After subgraph enter/exit
- `useCanvasRefresh.ts` — After canvas refresh
- `useCoreCommands.ts` — After metadata/subgraph commands
- `workflowService.ts` — After workflow service operations
## Transaction Guards
For operations that make multiple changes that should be a single undo entry:
```typescript
changeTracker.beforeChange()
// ... multiple graph mutations ...
changeTracker.afterChange() // calls checkState() when nesting count hits 0
```
The `litegraph:canvas` custom event also supports this with `before-change` /
`after-change` sub-types.
## Key Invariants
- `checkState()` is a no-op during `loadGraphData` (guarded by
`isLoadingGraph`) to prevent cross-workflow corruption
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing

1
global.d.ts vendored
View File

@@ -35,7 +35,6 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -43,9 +43,6 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}

View File

@@ -8,7 +8,6 @@
<!-- Node -->
<div
v-if="item.value.type === 'node'"
v-bind="$attrs"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@@ -49,7 +48,6 @@
<!-- Folder -->
<div
v-else
v-bind="$attrs"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@@ -100,10 +98,6 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const ROW_CLASS =
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'

View File

@@ -1,153 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => ['V']
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('V')
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute,
getCommand: mockGetCommand,
formatKeySequence: mockFormatKeySequence
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { read_only: false }
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
graphCanvasMenu: {
select: 'Select',
hand: 'Hand',
canvasMode: 'Canvas Mode'
}
}
}
})
const mockPopoverHide = vi.fn()
function createWrapper() {
return mount(CanvasModeSelector, {
global: {
plugins: [i18n],
stubs: {
Popover: {
template: '<div><slot /></div>',
methods: {
toggle: vi.fn(),
hide: mockPopoverHide
}
}
}
}
})
}
describe('CanvasModeSelector', () => {
it('should render menu with menuitemradio roles and aria-checked', () => {
const wrapper = createWrapper()
const menu = wrapper.find('[role="menu"]')
expect(menu.exists()).toBe(true)
const menuItems = wrapper.findAll('[role="menuitemradio"]')
expect(menuItems).toHaveLength(2)
// Select mode is active (read_only: false), so select is checked
expect(menuItems[0].attributes('aria-checked')).toBe('true')
expect(menuItems[1].attributes('aria-checked')).toBe('false')
})
it('should render menu items as buttons with aria-labels', () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
menuItems.forEach((btn) => {
expect(btn.element.tagName).toBe('BUTTON')
expect(btn.attributes('type')).toBe('button')
})
expect(menuItems[0].attributes('aria-label')).toBe('Select')
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
})
it('should use roving tabindex based on active mode', () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
// Select is active (read_only: false) → tabindex 0
expect(menuItems[0].attributes('tabindex')).toBe('0')
// Hand is inactive → tabindex -1
expect(menuItems[1].attributes('tabindex')).toBe('-1')
})
it('should mark icons as aria-hidden', () => {
const wrapper = createWrapper()
const icons = wrapper.findAll('[role="menuitemradio"] i')
icons.forEach((icon) => {
expect(icon.attributes('aria-hidden')).toBe('true')
})
})
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
const wrapper = createWrapper()
const trigger = wrapper.find('[aria-haspopup="menu"]')
expect(trigger.exists()).toBe(true)
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
expect(trigger.attributes('aria-expanded')).toBe('false')
})
it('should call focus on next item when ArrowDown is pressed', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const secondItemEl = menuItems[1].element as HTMLElement
const focusSpy = vi.spyOn(secondItemEl, 'focus')
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
expect(focusSpy).toHaveBeenCalled()
})
it('should call focus on previous item when ArrowUp is pressed', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const firstItemEl = menuItems[0].element as HTMLElement
const focusSpy = vi.spyOn(firstItemEl, 'focus')
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
expect(focusSpy).toHaveBeenCalled()
})
it('should close popover on Escape and restore focus to trigger', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const trigger = wrapper.find('[aria-haspopup="menu"]')
const triggerEl = trigger.element as HTMLElement
const focusSpy = vi.spyOn(triggerEl, 'focus')
await menuItems[0].trigger('keydown', { key: 'Escape' })
expect(mockPopoverHide).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
})
})

View File

@@ -4,21 +4,15 @@
variant="secondary"
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
:style="buttonStyles"
:aria-label="$t('graphCanvasMenu.canvasMode')"
aria-haspopup="menu"
:aria-expanded="isOpen"
@click="toggle"
>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
>
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
<i :class="currentModeIcon" class="block size-4" />
</div>
<i
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
aria-hidden="true"
/>
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
</div>
</Button>
@@ -30,54 +24,31 @@
:close-on-escape="true"
unstyled
:pt="popoverPt"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<div
ref="menuRef"
class="flex flex-col gap-1"
role="menu"
:aria-label="$t('graphCanvasMenu.canvasMode')"
>
<button
type="button"
role="menuitemradio"
:aria-checked="!isCanvasReadOnly"
:tabindex="!isCanvasReadOnly ? 0 : -1"
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
:aria-label="$t('graphCanvasMenu.select')"
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('select')"
@keydown.arrow-down.prevent="focusNextItem"
@keydown.arrow-up.prevent="focusPrevItem"
@keydown.escape.prevent="closeAndRestoreFocus"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
<i class="icon-[lucide--mouse-pointer-2] size-4" />
<span>{{ $t('graphCanvasMenu.select') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{
unlockCommandText
}}</span>
</button>
</div>
<button
type="button"
role="menuitemradio"
:aria-checked="isCanvasReadOnly"
:tabindex="isCanvasReadOnly ? 0 : -1"
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
:aria-label="$t('graphCanvasMenu.hand')"
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('hand')"
@keydown.arrow-down.prevent="focusNextItem"
@keydown.arrow-up.prevent="focusPrevItem"
@keydown.escape.prevent="closeAndRestoreFocus"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
<i class="icon-[lucide--hand] size-4" />
<span>{{ $t('graphCanvasMenu.hand') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
</button>
</div>
</div>
</Popover>
</template>
@@ -85,7 +56,7 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -98,8 +69,6 @@ interface Props {
defineProps<Props>()
const buttonRef = ref<ComponentPublicInstance | null>(null)
const popover = ref<InstanceType<typeof Popover>>()
const menuRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
@@ -137,43 +106,6 @@ const setMode = (mode: 'select' | 'hand') => {
popover.value?.hide()
}
async function onPopoverShow() {
isOpen.value = true
await nextTick()
const checkedItem = menuRef.value?.querySelector<HTMLElement>(
'[aria-checked="true"]'
)
checkedItem?.focus()
}
function onPopoverHide() {
isOpen.value = false
}
function closeAndRestoreFocus() {
popover.value?.hide()
const el = buttonRef.value?.$el || buttonRef.value
;(el as HTMLElement)?.focus()
}
function focusNextItem(event: KeyboardEvent) {
const items = getMenuItems(event)
const index = items.indexOf(event.target as HTMLElement)
items[(index + 1) % items.length]?.focus()
}
function focusPrevItem(event: KeyboardEvent) {
const items = getMenuItems(event)
const index = items.indexOf(event.target as HTMLElement)
items[(index - 1 + items.length) % items.length]?.focus()
}
function getMenuItems(event: KeyboardEvent): HTMLElement[] {
const menu = (event.target as HTMLElement).closest('[role="menu"]')
if (!menu) return []
return Array.from(menu.querySelectorAll('[role="menuitemradio"]'))
}
const popoverPt = computed(() => ({
root: {
class: 'absolute z-50 -translate-y-2'

View File

@@ -538,7 +538,7 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()

View File

@@ -10,8 +10,6 @@
></div>
<ButtonGroup
role="toolbar"
:aria-label="t('graphCanvasMenu.canvasToolbar')"
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
@@ -32,7 +30,7 @@
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
<i class="icon-[lucide--focus] size-4" />
</Button>
<Button
@@ -46,7 +44,7 @@
>
<span class="inline-flex items-center gap-1 px-2 text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
<i class="icon-[lucide--chevron-down] size-4" />
</span>
</Button>
@@ -61,7 +59,7 @@
:class="minimapButtonClass"
@click="onMinimapToggleClick"
>
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
<i class="icon-[lucide--map] size-4" />
</Button>
<Button
@@ -80,7 +78,7 @@
:style="stringifiedMinimapStyles.buttonStyles"
@click="onLinkVisibilityToggleClick"
>
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
<i class="icon-[lucide--route-off] size-4" />
</Button>
</ButtonGroup>
</div>

View File

@@ -39,12 +39,11 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useCanvasMenuPositionSync } from '@/composables/graph/useCanvasMenuPositionSync'
import {
registerNodeOptionsInstance,
useMoreOptionsMenu
@@ -53,6 +52,7 @@ import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
@@ -67,11 +67,67 @@ const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
const canvasStore = useCanvasStore()
const { setWorldPositionFromEvent } = useCanvasMenuPositionSync(
contextMenu,
isOpen
)
// World position (canvas coordinates) where menu was opened
const worldPosition = ref({ x: 0, y: 0 })
// Get canvas bounding rect reactively
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
// Track last canvas transform to detect actual changes
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
// Update menu position based on canvas transform
const updateMenuPosition = () => {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
// Only update if canvas transform actually changed
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
// Convert world position to screen position
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
// Update menu position
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
// Sync with canvas transform using requestAnimationFrame
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
// Start/stop syncing based on menu visibility
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
// Close on touch outside to handle mobile devices where click might be swallowed
useEventListener(
@@ -151,7 +207,25 @@ const menuItems = computed<ExtendedMenuItem[]>(() =>
// Show context menu
function show(event: MouseEvent) {
bump()
setWorldPositionFromEvent(event)
// Convert screen position to world coordinates
// Screen position relative to canvas = event position - canvas offset
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
// Convert to world coordinates using canvas transform
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
// Initialize last* values to current transform to prevent updateMenuPosition
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}

View File

@@ -13,7 +13,6 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
@@ -37,7 +36,6 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -45,8 +43,6 @@ const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -122,21 +118,12 @@ const hasMissingNodeSelected = computed(
)
)
const hasMissingModelSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingModelGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value ||
hasMissingModelSelected.value
hasMissingNodeSelected.value
)
})
@@ -327,11 +314,7 @@ function handleTitleCancel() {
:value="tab.value"
>
{{ tab.label() }}
<i
v-if="tab.icon"
aria-hidden="true"
:class="cn(tab.icon, 'size-4')"
/>
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>

View File

@@ -12,7 +12,7 @@
</div>
<!-- Scrollable content -->
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<div class="min-w-0 flex-1 overflow-y-auto">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -32,7 +32,11 @@
:key="group.title"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
@update:collapse="setSectionCollapsed(group.title, $event)"
>
<template #label>
@@ -126,14 +130,6 @@
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
/>
</PropertiesAccordionItem>
</TransitionGroup>
</div>
@@ -191,14 +187,12 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
const { t } = useI18n()
@@ -217,15 +211,6 @@ const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
@@ -241,7 +226,6 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -299,10 +283,6 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateModel(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {

View File

@@ -23,4 +23,3 @@ export type ErrorGroup =
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }

View File

@@ -47,13 +47,6 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
clearMissingModelState: vi.fn()
})
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
@@ -527,115 +520,4 @@ describe('useErrorGroups', () => {
expect(typeof groups.collapseState).toBe('object')
})
})
describe('missingModelGroups', () => {
function makeModel(
name: string,
opts: {
nodeId?: string | number
widgetName?: string
directory?: string
isAssetSupported?: boolean
} = {}
) {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true as const,
directory: opts.directory
}
}
it('returns empty array when no missing models', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups.value).toEqual([])
})
it('groups asset-supported models by directory', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('lora_a.safetensors', {
nodeId: '3',
directory: 'loras',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const ckptGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'checkpoints'
)
expect(ckptGroup?.models).toHaveLength(2)
expect(ckptGroup?.isAssetSupported).toBe(true)
})
it('puts unsupported models in a separate group', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('custom_model.safetensors', {
nodeId: '2',
isAssetSupported: false
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const unsupported = groups.missingModelGroups.value.find(
(g) => !g.isAssetSupported
)
expect(unsupported?.models).toHaveLength(1)
})
it('merges same-named models into one view model with multiple referencingNodes', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('shared_model.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('shared_model.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(1)
const model = groups.missingModelGroups.value[0].models[0]
expect(model.name).toBe('shared_model.safetensors')
expect(model.referencingNodes).toHaveLength(2)
})
it('includes missing_model group in allErrorGroups', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([makeModel('model_a.safetensors')])
await nextTick()
const modelGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
})
})
})

View File

@@ -3,7 +3,6 @@ import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -24,11 +23,6 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import {
isNodeExecutionId,
compareExecutionId
@@ -45,9 +39,6 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
/** Sentinel key for grouping non-asset-supported missing models. */
const UNSUPPORTED = Symbol('unsupported')
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
@@ -240,7 +231,6 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -569,60 +559,6 @@ export function useErrorGroups(
return groups.sort((a, b) => a.priority - b.priority)
}
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
* Within each group, candidates with the same model name are merged into a single view model. */
const missingModelGroups = computed<MissingModelGroup[]>(() => {
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
type GroupKey = string | null | typeof UNSUPPORTED
const map = new Map<
GroupKey,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of candidates) {
const groupKey: GroupKey = c.isAssetSupported
? c.directory || null
: UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
})
function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -630,11 +566,7 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...toSortedGroups(groupsMap)
]
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -648,11 +580,7 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...executionGroups
]
return [...buildMissingNodeGroups(), ...executionGroups]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -687,7 +615,6 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
swapNodeGroups
}
}

View File

@@ -11,15 +11,13 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="grid place-items-center-safe gap-0.5">
<i
class="col-span-full row-span-full icon-[lucide--chevron-down] size-3 translate-x-4 text-muted-foreground"
/>
<div class="flex items-center gap-0.5">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo col-span-full row-span-full size-4.5"
class="comfyui-logo h-[18px] w-[18px]"
mode="fill"
/>
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
</div>
</div>

View File

@@ -64,7 +64,6 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
)
"
>
<slot name="prepend" />
<SelectScrollUpButton />
<SelectViewport
:class="

View File

@@ -1,90 +0,0 @@
import { useElementBounding, useRafFn } from '@vueuse/core'
import type ContextMenu from 'primevue/contextmenu'
import type { Ref } from 'vue'
import { ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Keeps a PrimeVue ContextMenu anchored to a world-space position while the
* canvas is panned/zoomed. Performs dirty-checking each RAF tick so the DOM
* is only touched when the transform actually changes.
*/
export function useCanvasMenuPositionSync(
contextMenu: Ref<InstanceType<typeof ContextMenu> | undefined>,
isOpen: Ref<boolean>
) {
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(
lgCanvas.canvas
)
const worldPosition = ref({ x: 0, y: 0 })
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
function updateMenuPosition() {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
const screenX =
(worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY =
(worldPosition.value.y + offset[1]) * scale + canvasTop.value
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
/** Convert a mouse event's screen position to world coordinates and store it. */
function setWorldPositionFromEvent(event: MouseEvent) {
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
}
return { setWorldPositionFromEvent }
}

View File

@@ -324,8 +324,7 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined,
tooltip: widget.tooltip
slotName: name !== widget.name ? widget.name : undefined
}
} catch (error) {
return {

View File

@@ -357,8 +357,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initialize viewer in standalone mode (for asset preview).
* Creates the Load3d instance once; subsequent calls reuse it.
* Initialize viewer in standalone mode (for asset preview)
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
@@ -367,11 +366,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (!containerRef) return
try {
if (load3d) {
await loadStandaloneModel(modelUrl)
return
}
isStandaloneMode.value = true
load3d = new Load3d(containerRef, {
@@ -398,23 +392,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
}
/**
* Load a new model into an existing standalone viewer,
* reusing the same WebGLRenderer.
*/
const loadStandaloneModel = async (modelUrl: string) => {
if (!load3d) return
try {
await load3d.loadModel(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
} catch (error) {
console.error('Error loading model in standalone viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
}
}

View File

@@ -27,7 +27,7 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
`${namePrefix}.${depth}.${inputIndex}`,
Array.isArray(input)
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
: [input, { tooltip: `${groupIndex}` }]
: [input, {}]
])
return {
key: `${groupIndex}`,
@@ -106,13 +106,6 @@ describe('Dynamic Combos', () => {
expect(node.inputs[1].name).toBe('0.0.0.0')
expect(node.inputs[3].name).toBe('2.2.0.0')
})
test('Dynamically added widgets have tooltips', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
expect.soft(node.widgets[1].tooltip).toBe('0')
node.widgets[0].value = '1'
expect.soft(node.widgets[1].tooltip).toBe('1')
})
})
describe('Autogrow', () => {
const inputsSpec = { required: { image: ['IMAGE', {}] } }

View File

@@ -35,10 +35,6 @@
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"previousImage": "Previous image",
"nextImage": "Next image",
"switchToGridView": "Switch to grid view",
"switchToSingleView": "Switch to single view",
"errorLoadingImage": "Error loading image",
"errorLoadingVideo": "Error loading video",
"failedToDownloadImage": "Failed to download image",
@@ -1056,8 +1052,6 @@
"logoProviderSeparator": " & "
},
"graphCanvasMenu": {
"canvasMode": "Canvas Mode",
"canvasToolbar": "Canvas Toolbar",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetView": "Reset View",
@@ -1961,7 +1955,6 @@
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"missingModelVerificationFailed": "Failed to verify missing models. Some models may not be shown in the Errors tab.",
"interrupted": "Execution has been interrupted",
"pendingTasksDeleted": "Pending tasks deleted",
"nothingToGroup": "Nothing to group",
@@ -3393,33 +3386,6 @@
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
"importing": "Importing...",
"imported": "Imported",
"importFailed": "Import failed",
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"confirmSelection": "Confirm selection",
"locateNode": "Locate node on canvas",
"cancelSelection": "Cancel selection",
"clearUrl": "Clear URL",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow."
}
},
"errorOverlay": {

View File

@@ -348,8 +348,8 @@
"tooltip": "The maximum number of tasks that show in the queue history."
},
"Comfy_Queue_QPOV2": {
"name": "Docked job history/queue panel",
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout."
"name": "Use the unified job queue in the Assets side panel",
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout."
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "Batch count limit",

View File

@@ -1,193 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template: '<div class="model-row" />',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
emits: ['locate-model']
}
}))
import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeViewModel(
name: string,
nodeId: string = '1'
): MissingModelViewModel {
return {
name,
representative: {
name,
nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
}
}
function makeGroup(
opts: {
directory?: string | null
isAssetSupported?: boolean
modelNames?: string[]
} = {}
): MissingModelGroup {
const names = opts.modelNames ?? ['model.safetensors']
return {
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
isAssetSupported: opts.isAssetSupported ?? true,
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
}
}
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props
},
global: {
plugins: [PrimeVue, i18n]
}
})
}
describe('MissingModelCard', () => {
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(wrapper.text()).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(wrapper.text()).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(wrapper.text()).toContain('(2)')
})
it('renders correct number of MissingModelRow components', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(3)
})
it('renders multiple groups', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(wrapper.text()).toContain('checkpoints')
expect(wrapper.text()).toContain('loras')
})
it('renders zero rows when missingModelGroups is empty', () => {
const wrapper = mountCard({ missingModelGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingModelRow children', () => {
const wrapper = mountCard({ showNodeIdBadge: true })
const row = wrapper.findComponent({ name: 'MissingModelRow' })
expect(row.props('showNodeIdBadge')).toBe(true)
expect(row.props('isAssetSupported')).toBe(true)
expect(row.props('directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(wrapper.text()).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
it('emits locateModel when child emits locate-model', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingModelRow' })
await row.vm.$emit('locate-model', '42')
expect(wrapper.emitted('locateModel')).toBeTruthy()
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
})
})
})

View File

@@ -1,78 +0,0 @@
<template>
<div class="px-4 pb-2">
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
!group.isAssetSupported || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="!group.isAssetSupported" class="text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="!group.isAssetSupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
</script>

View File

@@ -1,113 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,224 +0,0 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
: t('rightSidePanel.missingModels.expandNodes')
"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleModelExpand(modelKey)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-2"
>
<template v-if="isAssetSupported">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</template>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="model.representative.isAssetSupported"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const { model, directory, isAssetSupported } = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const modelKey = computed(() =>
getModelStateKey(model.name, directory, isAssetSupported)
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
} = useMissingModelInteractions()
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,
model.name,
model.referencingNodes,
directory
)
}
</script>

View File

@@ -1,108 +0,0 @@
<template>
<div
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
/>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="categoryMismatch"
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
/>
<i
v-else-if="downloadStatus?.status === 'failed'"
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ modelName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="categoryMismatch">
{{
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
category: categoryMismatch
})
}}
</template>
<template v-else-if="isDownloadActive">
{{ t('rightSidePanel.missingModels.importing') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
t('rightSidePanel.missingModels.importFailed')
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('cancel')"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
categoryMismatch?: string | null
}>()
const emit = defineEmits<{
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -1,156 +0,0 @@
<template>
<div
:class="
cn(
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
!canImportModels && 'cursor-pointer'
)
"
v-bind="upgradePromptAttrs"
@click="!canImportModels && showUploadDialog()"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
</label>
<input
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:readonly="!canImportModels"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
:class="
cn(
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
!canImportModels && 'pointer-events-none opacity-60'
)
"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
/>
<Button
v-if="urlInputs[modelKey]"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
class="ml-1 shrink-0"
@click.stop="handleUrlInput(modelKey, '')"
>
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5 pt-0.5">
<span class="text-foreground min-w-0 truncate text-xs font-bold">
{{ urlMetadata[modelKey]?.filename }}
</span>
<span
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
</span>
</div>
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
/>
<span class="text-xs/tight text-warning-background">
{{
t('rightSidePanel.missingModels.typeMismatch', {
detectedType: typeMismatch
})
}}
</span>
</div>
<div class="pt-0.5">
<Button
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImport(modelKey, directory)"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
typeMismatch
? t('rightSidePanel.missingModels.importAnyway')
: t('rightSidePanel.missingModels.import')
}}
</Button>
</div>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="urlFetching[modelKey]"
aria-live="polite"
class="flex items-center justify-center py-2"
>
<i
aria-hidden="true"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="sr-only">{{ t('g.loading') }}</span>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
<span class="text-xs text-destructive-background-hover">
{{ urlErrors[modelKey] }}
</span>
</div>
</TransitionCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
const { modelKey, directory, typeMismatch } = defineProps<{
modelKey: string
directory: string | null
typeMismatch: string | null
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const canImportModels = computed(() => flags.privateModelsEnabled)
const { showUploadDialog } = useModelUpload()
const store = useMissingModelStore()
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
storeToRefs(store)
const { handleUrlInput, handleImport } = useMissingModelInteractions()
const upgradePromptAttrs = computed(() =>
canImportModels.value
? {}
: {
role: 'button',
tabindex: 0,
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
showUploadDialog()
}
}
}
)
</script>

View File

@@ -1,516 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
const mockResolveNodeDisplayName = vi.fn()
const mockValidateSourceUrl = vi.fn()
const mockGetAssetMetadata = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: (...args: unknown[]) =>
mockGetNodeByExecutionId(...args)
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: (...args: unknown[]) =>
mockResolveNodeDisplayName(...args)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: vi.fn(),
updateModelsForTag: vi.fn()
})
}))
vi.mock('@/stores/assetDownloadStore', () => ({
useAssetDownloadStore: () => ({
get downloadList() {
return mockDownloadList()
},
trackDownload: vi.fn()
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getAllNodeProviders: mockGetAllNodeProviders
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args)
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com']
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}
}))
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
}))
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
} from './useMissingModelInteractions'
function makeCandidate(
overrides: Partial<MissingModelCandidate> = {}
): MissingModelCandidate {
return {
name: 'model.safetensors',
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
isMissing: true,
...overrides
}
}
describe('useMissingModelInteractions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
'supported::checkpoints::model.safetensors'
)
})
it('returns key with unsupported prefix when asset is not supported', () => {
expect(getModelStateKey('model.safetensors', 'loras', false)).toBe(
'unsupported::loras::model.safetensors'
)
})
it('handles null directory', () => {
expect(getModelStateKey('model.safetensors', null, true)).toBe(
'supported::::model.safetensors'
)
})
})
describe('getNodeDisplayLabel', () => {
it('returns fallback when graph is null', () => {
;(app as { rootGraph: unknown }).rootGraph = null
expect(getNodeDisplayLabel('1', 'Node #1')).toBe('Node #1')
})
it('calls resolveNodeDisplayName when graph is available', () => {
const mockGraph = {}
const mockNode = { id: 1 }
;(app as { rootGraph: unknown }).rootGraph = mockGraph
mockGetNodeByExecutionId.mockReturnValue(mockNode)
mockResolveNodeDisplayName.mockReturnValue('My Checkpoint')
const result = getNodeDisplayLabel('1', 'Node #1')
expect(mockGetNodeByExecutionId).toHaveBeenCalledWith(mockGraph, '1')
expect(result).toBe('My Checkpoint')
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = useMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when download is running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importTaskIds['key1'] = 'task-123'
mockDownloadList.mockReturnValue([
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when importCategoryMismatch exists', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns true when selection is ready with no active download', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
describe('cancelLibrarySelect', () => {
it('clears selectedLibraryModel and importCategoryMismatch', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
})
})
describe('confirmLibrarySelect', () => {
it('updates widget values on referencing nodes and removes missing model', () => {
const mockGraph = {}
;(app as { rootGraph: unknown }).rootGraph = mockGraph
const widget1 = { name: 'ckpt_name', value: 'old_model.safetensors' }
const widget2 = { name: 'ckpt_name', value: 'old_model.safetensors' }
const node1 = { widgets: [widget1] }
const node2 = { widgets: [widget2] }
mockGetNodeByExecutionId.mockImplementation(
(_graph: unknown, id: string) => {
if (id === '10') return node1
if (id === '20') return node2
return null
}
)
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
])
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
[
{ nodeId: '10', widgetName: 'ckpt_name' },
{ nodeId: '20', widgetName: 'ckpt_name' }
],
null
)
expect(widget1.value).toBe('new_model.safetensors')
expect(widget2.value).toBe('new_model.safetensors')
expect(removeSpy).toHaveBeenCalledWith(
'old_model.safetensors',
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
})
it('does nothing when graph is null', () => {
;(app as { rootGraph: unknown }).rootGraph = null
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
})
it('refreshes model cache when directory is provided', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
mockGetAllNodeProviders.mockReturnValue([
{ nodeDef: { name: 'CheckpointLoaderSimple' } }
])
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
})
})
describe('handleUrlInput', () => {
it('clears previous state on new input', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'old' } as never
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
expect(store.urlMetadata['key1']).toBeUndefined()
expect(store.urlErrors['key1']).toBeUndefined()
expect(store.urlFetching['key1']).toBe(false)
})
it('does not set debounce timer for empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
})
it('sets debounce timer for non-empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
'key1',
expect.any(Function),
800
)
})
it('clears previous debounce timer', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
})
})
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when metadata has no tags', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when detected type matches directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['checkpoints']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns detected type when it differs from directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['loras']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
it('returns null when tags contain no recognized model type', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['other', 'random']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
})

View File

@@ -1,393 +0,0 @@
import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const importSources = [civitaiImportSource, huggingfaceImportSource]
const MODEL_TYPE_TAGS = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const URL_DEBOUNCE_MS = 800
export function getModelStateKey(
modelName: string,
directory: string | null,
isAssetSupported: boolean
): string {
const prefix = isAssetSupported ? 'supported' : 'unsupported'
return `${prefix}::${directory ?? ''}::${modelName}`
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
function toggleModelExpand(key: string) {
store.modelExpandState[key] = !isModelExpanded(key)
}
function isModelExpanded(key: string): boolean {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
}
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
function confirmLibrarySelect(
key: string,
modelName: string,
referencingNodes: MissingModelViewModel['referencingNodes'],
directory: string | null
) {
const value = store.selectedLibraryModel[key]
if (!value) return
const graph = app.rootGraph
if (!graph) return
if (directory) {
const providers = modelToNodeStore.getAllNodeProviders(directory)
void Promise.allSettled(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
).then((results) => {
for (const r of results) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model] Failed to refresh model cache:',
r.reason
)
}
}
})
}
for (const ref of referencingNodes) {
const node = getNodeByExecutionId(graph, String(ref.nodeId))
if (node) {
const widget = node.widgets?.find((w) => w.name === ref.widgetName)
if (widget) {
widget.value = value
widget.callback?.(value)
}
node.graph?.setDirtyCanvas(true, true)
}
}
delete store.selectedLibraryModel[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
function handleUrlInput(key: string, value: string) {
store.urlInputs[key] = value
delete store.urlMetadata[key]
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
store.urlFetching[key] = false
store.clearDebounceTimer(key)
const trimmed = value.trim()
if (!trimmed) return
store.setDebounceTimer(
key,
() => {
void fetchUrlMetadata(key, trimmed)
},
URL_DEBOUNCE_MS
)
}
async function fetchUrlMetadata(key: string, url: string) {
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) {
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
return
}
const token = Symbol()
_requestTokens[key] = token
store.urlFetching[key] = true
delete store.urlErrors[key]
try {
const metadata = await assetService.getAssetMetadata(url)
if (_requestTokens[key] !== token) return
if (metadata.filename) {
try {
const decoded = decodeURIComponent(metadata.filename)
const basename = decoded.split(/[/\\]/).pop() ?? decoded
if (!basename.includes('..')) {
metadata.filename = basename
}
} catch {
/* keep original */
}
}
store.urlMetadata[key] = metadata
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.metadataFetchFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlFetching[key] = false
}
}
}
function getTypeMismatch(
key: string,
groupDirectory: string | null
): string | null {
if (!groupDirectory) return null
const metadata = store.urlMetadata[key]
if (!metadata?.tags?.length) return null
const detectedType = metadata.tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (!detectedType) return null
if (detectedType !== groupDirectory) {
return detectedType
}
return null
}
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
return (
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
)
}
function handleAsyncPending(
key: string,
taskId: string,
modelType: string | undefined,
filename: string
) {
store.importTaskIds[key] = taskId
if (modelType) {
assetDownloadStore.trackDownload(taskId, modelType, filename)
}
}
function handleAsyncCompleted(modelType: string | undefined) {
if (modelType) {
assetsStore.invalidateModelsForCategory(modelType)
void assetsStore.updateModelsForTag(modelType)
}
}
function handleSyncResult(
key: string,
tags: string[],
modelType: string | undefined
) {
const existingCategory = tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (existingCategory && modelType && existingCategory !== modelType) {
store.importCategoryMismatch[key] = existingCategory
}
}
async function handleImport(key: string, groupDirectory: string | null) {
const metadata = store.urlMetadata[key]
if (!metadata) return
const url = store.urlInputs[key]?.trim()
if (!url) return
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) return
const token = Symbol()
_requestTokens[key] = token
store.urlImporting[key] = true
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
try {
const modelType = groupDirectory || undefined
const tags = modelType ? ['models', modelType] : ['models']
const filename = metadata.filename || metadata.name || 'model'
const result = await assetService.uploadAssetAsync({
source_url: url,
tags,
user_metadata: {
source: source.type,
source_url: url,
model_type: modelType
}
})
if (_requestTokens[key] !== token) return
if (result.type === 'async' && result.task.status !== 'completed') {
handleAsyncPending(key, result.task.task_id, modelType, filename)
} else if (result.type === 'async') {
handleAsyncCompleted(modelType)
} else if (result.type === 'sync') {
handleSyncResult(key, result.asset.tags ?? [], modelType)
}
store.selectedLibraryModel[key] = filename
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.importFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlImporting[key] = false
}
}
}
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleImport
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,386 +0,0 @@
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
MissingModelCandidate,
MissingModelViewModel,
EmbeddedModelWithSource
} from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type {
IAssetWidget,
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
export const MODEL_FILE_EXTENSIONS = new Set([
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.sft',
'.onnx',
'.gguf'
])
export function isModelFileName(name: string): boolean {
const lower = name.toLowerCase()
for (const ext of MODEL_FILE_EXTENSIONS) {
if (lower.endsWith(ext)) return true
}
return false
}
function resolveComboOptions(widget: IComboWidget): string[] {
const values = widget.options.values
if (!values) return []
if (typeof values === 'function') return values(widget)
if (Array.isArray(values)) return values
return Object.keys(values)
}
/**
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
*
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
*/
export function scanAllModelCandidates(
rootGraph: LGraph,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory?: (nodeType: string) => string | undefined
): MissingModelCandidate[] {
if (!rootGraph) return []
const allNodes = collectAllNodes(rootGraph)
const candidates: MissingModelCandidate[] = []
for (const node of allNodes) {
if (!node.widgets?.length) continue
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) continue
for (const widget of node.widgets) {
let candidate: MissingModelCandidate | null = null
if (isAssetWidget(widget)) {
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
} else if (isComboWidget(widget)) {
candidate = scanComboWidget(
node,
widget,
executionId,
isAssetSupported,
getDirectory
)
}
if (candidate) candidates.push(candidate)
}
}
return candidates
}
function scanAssetWidget(
node: { type: string },
widget: IAssetWidget,
executionId: string,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (!value.trim()) return null
if (!isModelFileName(value)) return null
return {
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
isAssetSupported: true,
name: value,
directory: getDirectory?.(node.type),
isMissing: undefined
}
}
function scanComboWidget(
node: { type: string },
widget: IComboWidget,
executionId: string,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (typeof value !== 'string' || !value.trim()) return null
if (!isModelFileName(value)) return null
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
const options = resolveComboOptions(widget)
const inOptions = options.includes(value)
return {
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
isAssetSupported: nodeIsAssetSupported,
name: value,
directory: getDirectory?.(node.type),
isMissing: nodeIsAssetSupported ? undefined : !inOptions
}
}
export async function enrichWithEmbeddedMetadata(
candidates: readonly MissingModelCandidate[],
graphData: ComfyWorkflowJSON,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
for (const c of enriched) {
const dirKey = `${c.name}::${c.directory ?? ''}`
const dirList = candidatesByKey.get(dirKey)
if (dirList) dirList.push(c)
else candidatesByKey.set(dirKey, [c])
const nameKey = c.name
const nameList = candidatesByKey.get(nameKey)
if (nameList) nameList.push(c)
else candidatesByKey.set(nameKey, [c])
}
const deduped: EmbeddedModelWithSource[] = []
const enrichedKeys = new Set<string>()
for (const model of embeddedModels) {
const dedupeKey = `${model.name}::${model.directory}`
if (enrichedKeys.has(dedupeKey)) continue
enrichedKeys.add(dedupeKey)
deduped.push(model)
}
const unmatched: EmbeddedModelWithSource[] = []
for (const model of deduped) {
const dirKey = `${model.name}::${model.directory}`
const exact = candidatesByKey.get(dirKey)
const fallback = candidatesByKey.get(model.name)
const existing = exact?.length ? exact : fallback
if (existing) {
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
}
} else {
unmatched.push(model)
}
}
const settled = await Promise.allSettled(
unmatched.map(async (model) => {
const installed = await checkModelInstalled(model.name, model.directory)
if (installed) return null
const nodeIsAssetSupported = isAssetSupported
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
: false
return {
nodeId: model.sourceNodeId,
nodeType: model.sourceNodeType,
widgetName: model.sourceWidgetName,
isAssetSupported: nodeIsAssetSupported,
name: model.name,
directory: model.directory,
url: model.url,
hash: model.hash,
hashType: model.hash_type,
isMissing: nodeIsAssetSupported ? undefined : true
} satisfies MissingModelCandidate
})
)
for (const r of settled) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model Pipeline] checkModelInstalled failed:',
r.reason
)
continue
}
if (r.value) enriched.push(r.value)
}
return enriched
}
function collectEmbeddedModelsWithSource(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: ComfyWorkflowJSON
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
for (const node of allNodes) {
const selected = getSelectedModelsMetadata(
node as Parameters<typeof getSelectedModelsMetadata>[0]
)
if (!selected?.length) continue
for (const model of selected) {
result.push({
...model,
sourceNodeId: node.id,
sourceNodeType: node.type,
sourceWidgetName: findWidgetNameForModel(node, model.name)
})
}
}
// Workflow-level model entries have no originating node; sourceNodeId
// remains undefined and empty-string node type/widget are handled by
// groupCandidatesByName (no nodeId → no referencing node entry).
if (graphData.models?.length) {
for (const model of graphData.models) {
result.push({
...model,
sourceNodeType: '',
sourceWidgetName: ''
})
}
}
return result
}
function findWidgetNameForModel(
node: ReturnType<typeof flattenWorkflowNodes>[number],
modelName: string
): string {
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
const wv = node.widgets_values as Record<string, unknown>
for (const [key, val] of Object.entries(wv)) {
if (val === modelName) return key
}
return ''
}
interface AssetVerifier {
updateModelsForNodeType: (nodeType: string) => Promise<void>
getAssets: (nodeType: string) => AssetItem[] | undefined
}
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier
): Promise<void> {
if (signal?.aborted) return
const pendingNodeTypes = new Set<string>()
for (const c of candidates) {
if (c.isAssetSupported && c.isMissing === undefined) {
pendingNodeTypes.add(c.nodeType)
}
}
if (pendingNodeTypes.size === 0) return
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
const failedNodeTypes = new Set<string>()
await Promise.allSettled(
[...pendingNodeTypes].map(async (nodeType) => {
if (signal?.aborted) return
try {
await store.updateModelsForNodeType(nodeType)
} catch (err) {
failedNodeTypes.add(nodeType)
console.warn(
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
err
)
}
})
)
if (signal?.aborted) return
for (const c of candidates) {
if (!c.isAssetSupported || c.isMissing !== undefined) continue
if (failedNodeTypes.has(c.nodeType)) continue
const assets = store.getAssets(c.nodeType) ?? []
c.isMissing = !isAssetInstalled(c, assets)
}
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
function isAssetInstalled(
candidate: MissingModelCandidate,
assets: AssetItem[]
): boolean {
if (candidate.hash && candidate.hashType) {
const candidateHash = `${candidate.hashType}:${candidate.hash}`
if (assets.some((a) => a.asset_hash === candidateHash)) return true
}
const normalizedName = normalizePath(candidate.name)
return assets.some((a) => {
const f = normalizePath(getAssetFilename(a))
return f === normalizedName || f.endsWith('/' + normalizedName)
})
}
export function groupCandidatesByName(
candidates: MissingModelCandidate[]
): MissingModelViewModel[] {
const map = new Map<string, MissingModelViewModel>()
for (const c of candidates) {
const existing = map.get(c.name)
if (existing) {
if (c.nodeId) {
existing.referencingNodes.push({
nodeId: c.nodeId,
widgetName: c.widgetName
})
}
} else {
map.set(c.name, {
name: c.name,
representative: c,
referencingNodes: c.nodeId
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
: []
})
}
}
return Array.from(map.values())
}

View File

@@ -1,189 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
import { useMissingModelStore } from './missingModelStore'
function makeModelCandidate(
name: string,
opts: {
nodeId?: string | number
nodeType?: string
widgetName?: string
isAssetSupported?: boolean
} = {}
): MissingModelCandidate {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true
}
}
describe('missingModelStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingModels', () => {
it('sets missingModelCandidates with provided models', () => {
const store = useMissingModelStore()
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
expect(store.missingModelCandidates).not.toBeNull()
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.hasMissingModels).toBe(true)
})
it('clears missingModelCandidates when given empty array', () => {
const store = useMissingModelStore()
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
expect(store.missingModelCandidates).not.toBeNull()
store.setMissingModels([])
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
})
it('includes model count in missingModelCount', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors'),
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
])
expect(store.missingModelCount).toBe(2)
})
})
describe('hasMissingModelOnNode', () => {
it('returns true when node has missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('5')).toBe(true)
})
it('returns false when node has no missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('99')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.hasMissingModelOnNode('1')).toBe(false)
})
})
describe('removeMissingModelByNameOnNodes', () => {
it('removes only the named model from specified nodes', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name'
}),
makeModelCandidate('model_b.safetensors', {
nodeId: '1',
widgetName: 'vae_name'
}),
makeModelCandidate('model_a.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name'
})
])
store.removeMissingModelByNameOnNodes(
'model_a.safetensors',
new Set(['1'])
)
expect(store.missingModelCandidates).toHaveLength(2)
expect(store.missingModelCandidates![0].name).toBe('model_b.safetensors')
expect(store.missingModelCandidates![1].name).toBe('model_a.safetensors')
expect(String(store.missingModelCandidates![1].nodeId)).toBe('2')
})
it('sets missingModelCandidates to null when all removed', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.removeMissingModelByNameOnNodes(
'model_a.safetensors',
new Set(['1'])
)
expect(store.missingModelCandidates).toBeNull()
})
})
describe('clearMissingModels', () => {
it('clears missingModelCandidates and interaction state', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.urlInputs['test-key'] = 'https://example.com'
store.selectedLibraryModel['test-key'] = 'some-model'
expect(store.missingModelCandidates).not.toBeNull()
store.clearMissingModels()
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
expect(store.urlInputs).toEqual({})
expect(store.selectedLibraryModel).toEqual({})
})
})
describe('isWidgetMissingModel', () => {
it('returns true when specific widget has missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '5',
widgetName: 'ckpt_name'
})
])
expect(store.isWidgetMissingModel('5', 'ckpt_name')).toBe(true)
})
it('returns false for different widget on same node', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '5',
widgetName: 'ckpt_name'
})
])
expect(store.isWidgetMissingModel('5', 'lora_name')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.isWidgetMissingModel('1', 'ckpt_name')).toBe(false)
})
})
})

View File

@@ -1,201 +0,0 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
* Missing model error state and interaction state.
* Separated from executionErrorStore to keep domain boundaries clean.
* The executionErrorStore composes from this store for aggregate error flags.
*/
export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
const hasMissingModels = computed(
() => !!missingModelCandidates.value?.length
)
const missingModelCount = computed(
() => missingModelCandidates.value?.length ?? 0
)
const missingModelNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!missingModelCandidates.value) return ids
for (const m of missingModelCandidates.value) {
if (m.nodeId != null) ids.add(String(m.nodeId))
}
return ids
})
const missingModelWidgetKeys = computed<Set<string>>(() => {
const keys = new Set<string>()
if (!missingModelCandidates.value) return keys
for (const m of missingModelCandidates.value) {
keys.add(`${String(m.nodeId)}::${m.widgetName}`)
}
return keys
})
/**
* Set of all execution ID prefixes derived from missing model node IDs,
* including the missing model nodes themselves.
*
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
() => {
const ids = new Set<NodeExecutionId>()
for (const nodeId of missingModelNodeIds.value) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
)
const activeMissingModelGraphIds = computed<Set<string>>(() => {
if (!app.rootGraph) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingModelAncestorExecutionIds.value
)
})
// Persists across component re-mounts so that download progress,
// URL inputs, etc. survive tab switches within the right-side panel.
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
let _verificationAbortController: AbortController | null = null
onScopeDispose(cancelDebounceTimers)
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
return _verificationAbortController
}
function setMissingModels(models: MissingModelCandidate[]) {
missingModelCandidates.value = models.length ? models : null
}
function removeMissingModelByNameOnNodes(
modelName: string,
nodeIds: Set<string>
) {
if (!missingModelCandidates.value) return
missingModelCandidates.value = missingModelCandidates.value.filter(
(m) =>
m.name !== modelName ||
m.nodeId == null ||
!nodeIds.has(String(m.nodeId))
)
if (!missingModelCandidates.value.length)
missingModelCandidates.value = null
}
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
return missingModelNodeIds.value.has(nodeLocatorId)
}
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
}
function isContainerWithMissingModel(node: LGraphNode): boolean {
return activeMissingModelGraphIds.value.has(String(node.id))
}
function cancelDebounceTimers() {
for (const key of Object.keys(_urlDebounceTimers)) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setDebounceTimer(
key: string,
callback: () => void,
delayMs: number
) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
}
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
}
function clearDebounceTimer(key: string) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function clearMissingModels() {
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
importTaskIds.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
urlErrors.value = {}
urlImporting.value = {}
}
return {
missingModelCandidates,
hasMissingModels,
missingModelCount,
missingModelNodeIds,
activeMissingModelGraphIds,
setMissingModels,
removeMissingModelByNameOnNodes,
clearMissingModels,
createVerificationAbortController,
hasMissingModelOnNode,
isWidgetMissingModel,
isContainerWithMissingModel,
modelExpandState,
selectedLibraryModel,
importTaskIds,
importCategoryMismatch,
urlInputs,
urlMetadata,
urlFetching,
urlErrors,
urlImporting,
setDebounceTimer,
clearDebounceTimer
}
})

View File

@@ -1,53 +0,0 @@
import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
* The same model name may appear multiple times across different nodes.
*/
export interface MissingModelCandidate {
/** Undefined for workflow-level models not tied to a specific node. */
nodeId?: NodeId
nodeType: string
widgetName: string
isAssetSupported: boolean
name: string
directory?: string
url?: string
hash?: string
hashType?: string
/**
* - `true` — confirmed missing
* - `false` — confirmed installed
* - `undefined` — pending async verification (asset-supported nodes only)
*/
isMissing: boolean | undefined
}
export interface EmbeddedModelWithSource extends ModelFile {
/** Undefined for workflow-level models not tied to a specific node. */
sourceNodeId?: NodeId
sourceNodeType: string
sourceWidgetName: string
}
/** View model grouping multiple candidate references under a single model name. */
export interface MissingModelViewModel {
name: string
representative: MissingModelCandidate
referencingNodes: Array<{
nodeId: NodeId
widgetName: string
}>
}
/** A category group of missing models sharing the same directory. */
export interface MissingModelGroup {
directory: string | null
models: MissingModelViewModel[]
isAssetSupported: boolean
}

View File

@@ -150,41 +150,6 @@ describe('fetchJobs', () => {
expect(result).toEqual([])
})
it('parses batch containing text-only preview outputs', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([
createMockJob('image-job', 'completed', {
preview_output: {
filename: 'output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob('text-job', 'completed', {
preview_output: {
content: 'some generated text',
nodeId: '5',
mediaType: 'text'
}
}),
createMockJob('no-preview-job', 'completed')
])
)
})
const result = await fetchHistory(mockFetch)
expect(result).toHaveLength(3)
expect(result[0].id).toBe('image-job')
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
})
describe('fetchQueue', () => {

View File

@@ -18,16 +18,14 @@ const zJobStatus = z.enum([
'cancelled'
])
const zPreviewOutput = z
.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional(),
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
.passthrough()
const zPreviewOutput = z.object({
filename: z.string(),
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
/**
* Execution error from Jobs API.

View File

@@ -31,7 +31,6 @@ export type RemoteConfig = {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -1201,10 +1201,10 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.Queue.QPOV2',
category: ['Comfy', 'Queue', 'Layout'],
name: 'Docked job history/queue panel',
name: 'Use the unified job queue in the Assets side panel',
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.',
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: false,
experimental: true
},

View File

@@ -3,7 +3,6 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
@@ -28,8 +27,7 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowImportMetadata
} from './types'
/**
@@ -159,14 +157,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}

View File

@@ -14,7 +14,6 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
@@ -41,8 +40,7 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowImportMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -361,14 +359,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}

View File

@@ -80,30 +80,27 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
)
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
api_host: 'https://ph.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: false
})
})
it('enables debug mode when posthog_debug is true in config', async () => {
it('uses custom api_host from config when provided', async () => {
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token',
posthog_debug: true
posthog_api_host: 'https://custom.host.com'
} as typeof window.__CONFIG__
new PostHogTelemetryProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ debug: true })
expect.objectContaining({ api_host: 'https://custom.host.com' })
)
})

View File

@@ -7,7 +7,6 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
@@ -35,8 +34,7 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getExecutionContext } from '../../utils/getExecutionContext'
@@ -103,15 +101,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.posthog = posthogModule.default
this.posthog!.init(apiKey, {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
window.__CONFIG__?.posthog_api_host || 'https://ph.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug:
window.__CONFIG__?.posthog_debug ??
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
})
this.isInitialized = true
this.flushEventQueue()
@@ -349,14 +344,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}

View File

@@ -149,15 +149,6 @@ export interface EnterLinearMetadata {
source?: string
}
export interface WorkflowSavedMetadata {
is_app: boolean
is_new: boolean
}
export interface DefaultViewSetMetadata {
default_view: 'app' | 'graph'
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
@@ -386,8 +377,6 @@ export interface TelemetryProvider {
// Workflow management events
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
@@ -501,8 +490,6 @@ export const TelemetryEvents = {
// Workflow Creation
WORKFLOW_CREATED: 'app:workflow_created',
WORKFLOW_SAVED: 'app:workflow_saved',
DEFAULT_VIEW_SET: 'app:default_view_set',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -553,6 +540,4 @@ export type TelemetryEventProperties =
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata

View File

@@ -149,8 +149,6 @@ export const useWorkflowService = () => {
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
return true
}
@@ -191,7 +189,6 @@ export const useWorkflowService = () => {
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}
@@ -547,16 +544,15 @@ export const useWorkflowService = () => {
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
missingNodesDialog.show({ missingNodeTypes })
}
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
}
// Missing models are NOT surfaced to the Errors tab here.
// On Cloud, the dedicated pipeline in app.ts handles detection and
// surfacing via surfaceMissingModels(). OSS uses only this dialog.
if (missingModels) {
if (settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')) {
missingModelsDialog.show(missingModels)
}
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
) {
missingModelsDialog.show(missingModels)
}
}

View File

@@ -1,360 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
const settingMocks = vi.hoisted(() => ({
persistRef: null as { value: boolean } | null
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { ref } = await import('vue')
settingMocks.persistRef = ref(true)
return {
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Workflow.Persist')
return settingMocks.persistRef!.value
return undefined
}),
set: vi.fn()
}))
}
})
const mockToastAdd = vi.fn()
vi.mock('primevue', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock(
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
() => ({
useSharedWorkflowUrlLoader: () => ({
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
})
})
)
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof I18n>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const openWorkflowMock = vi.fn()
const loadBlankWorkflowMock = vi.fn()
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
openWorkflow: openWorkflowMock,
loadBlankWorkflow: loadBlankWorkflowMock
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
})
})
)
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
}),
useRouter: () => ({
replace: vi.fn()
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserLogout: vi.fn()
})
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(() => null)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('../migration/migrateV1toV2', () => ({
migrateV1toV2: vi.fn()
}))
type GraphChangedHandler = (() => void) | null
const mocks = vi.hoisted(() => {
const state = {
graphChangedHandler: null as GraphChangedHandler,
currentGraph: {} as Record<string, unknown>
}
const serializeMock = vi.fn(() => state.currentGraph)
const loadGraphDataMock = vi.fn()
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
}
}),
removeEventListener: vi.fn()
}
return { state, serializeMock, loadGraphDataMock, apiMock }
})
vi.mock('@/scripts/app', () => ({
app: {
graph: {
serialize: () => mocks.serializeMock()
},
rootGraph: {
serialize: () => mocks.serializeMock()
},
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
canvas: {}
}
}))
vi.mock('@/scripts/api', () => ({
api: mocks.apiMock
}))
describe('useWorkflowPersistenceV2', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
settingMocks.persistRef!.value = true
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
mocks.loadGraphDataMock.mockReset()
mocks.apiMock.clientId = 'test-client'
mocks.apiMock.initialClientId = 'test-client'
mocks.apiMock.addEventListener.mockImplementation(
(event: string, handler: () => void) => {
if (event === 'graphChanged') {
mocks.state.graphChangedHandler = handler
}
}
)
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
function writeTabState(paths: string[], activeIndex: number) {
const pointer = {
workspaceId: 'personal',
paths,
activeIndex
}
sessionStorage.setItem(
`Comfy.Workflow.OpenPaths:test-client`,
JSON.stringify(pointer)
)
}
function writeActivePath(path: string) {
const pointer = {
workspaceId: 'personal',
path
}
sessionStorage.setItem(
`Comfy.Workflow.ActivePath:test-client`,
JSON.stringify(pointer)
)
}
describe('loadPreviousWorkflowFromStorage', () => {
it('loads saved workflow when draft is missing for session path', async () => {
const workflowStore = useWorkflowStore()
const savedWorkflow = workflowStore.createTemporary('SavedWorkflow.json')
// Set session path to the saved workflow but do NOT create a draft
writeActivePath(savedWorkflow.path)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should call workflowService.openWorkflow with the saved workflow
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
})
it('prefers draft over saved workflow when draft exists', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = workflowStore.createTemporary('DraftWorkflow.json')
const draftData = JSON.stringify({ nodes: [], title: 'draft' })
draftStore.saveDraft(workflow.path, draftData, {
name: 'DraftWorkflow.json',
isTemporary: true
})
writeActivePath(workflow.path)
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should load draft via loadGraphData, not via workflowService.openWorkflow
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('falls back to latest draft only when no session path exists', async () => {
const draftStore = useWorkflowDraftStoreV2()
// No session path set, but a draft exists
const draftData = JSON.stringify({ nodes: [], title: 'latest' })
draftStore.saveDraft('workflows/Other.json', draftData, {
name: 'Other.json',
isTemporary: true
})
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should load via fallbackToLatestDraft
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('restoreWorkflowTabsState', () => {
it('activates the correct workflow at storedActiveIndex', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
// Create two temporary workflows with drafts
const workflowA = workflowStore.createTemporary('WorkflowA.json')
const workflowB = workflowStore.createTemporary('WorkflowB.json')
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
name: 'WorkflowA.json',
isTemporary: true
})
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
name: 'WorkflowB.json',
isTemporary: true
})
// storedActiveIndex = 1 → WorkflowB should be activated
writeTabState([workflowA.path, workflowB.path], 1)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
})
it('activates first tab when storedActiveIndex is 0', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflowA = workflowStore.createTemporary('WorkflowA.json')
const workflowB = workflowStore.createTemporary('WorkflowB.json')
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
name: 'WorkflowA.json',
isTemporary: true
})
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
name: 'WorkflowB.json',
isTemporary: true
})
writeTabState([workflowA.path, workflowB.path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
})
it('does not call openWorkflow when no restorable state', async () => {
// No tab state written to sessionStorage
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('restores temporary workflows and adds them to tabs', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
// Save a draft for a workflow that doesn't exist in the store yet
const path = 'workflows/Unsaved.json'
draftStore.saveDraft(path, JSON.stringify({ title: 'Unsaved' }), {
name: 'Unsaved.json',
isTemporary: true
})
writeTabState([path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const restored = workflowStore.getWorkflowByPath(path)
expect(restored).toBeTruthy()
expect(restored?.isTemporary).toBe(true)
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
})
it('skips activation when persistence is disabled', async () => {
settingMocks.persistRef!.value = false
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -132,28 +132,19 @@ export function useWorkflowPersistenceV2() {
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
const loadPreviousWorkflowFromStorage = async () => {
// 1. Try session pointer (for tab restoration)
const sessionPath = tabState.getActivePath()
// 1. Try draft for session path
if (
sessionPath &&
(await draftStore.loadPersistedWorkflow({
workflowName: null,
preferredPath: sessionPath
}))
)
) {
return true
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
if (sessionPath) {
const saved = workflowStore.getWorkflowByPath(sessionPath)
if (saved) {
await useWorkflowService().openWorkflow(saved)
return true
}
}
// 3. Fall back to most recent draft
// 2. Fall back to most recent draft
return await draftStore.loadPersistedWorkflow({
workflowName: null,
fallbackToLatestDraft: true
@@ -251,7 +242,7 @@ export function useWorkflowPersistenceV2() {
}
})
const restoreWorkflowTabsState = async () => {
const restoreWorkflowTabsState = () => {
if (!workflowPersistenceEnabled.value) {
tabStateRestored = true
return
@@ -263,11 +254,10 @@ export function useWorkflowPersistenceV2() {
const storedWorkflows = storedTabState?.paths ?? []
const storedActiveIndex = storedTabState?.activeIndex ?? -1
tabStateRestored = true
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
if (!isRestorable) {
tabStateRestored = true
return
}
if (!isRestorable) return
storedWorkflows.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
@@ -290,17 +280,6 @@ export function useWorkflowPersistenceV2() {
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
tabStateRestored = true
// Activate the correct workflow at storedActiveIndex
const activePath = storedWorkflows[storedActiveIndex]
const workflow = activePath
? workflowStore.getWorkflowByPath(activePath)
: null
if (workflow) {
await useWorkflowService().openWorkflow(workflow)
}
}
return {

View File

@@ -3,13 +3,9 @@ import { describe, expect, it } from 'vitest'
import {
buildSubgraphExecutionPaths,
flattenWorkflowNodes,
validateComfyWorkflow
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyNode,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__'
@@ -278,48 +274,3 @@ describe('buildSubgraphExecutionPaths', () => {
).not.toThrow()
})
})
describe('flattenWorkflowNodes', () => {
it('returns root nodes when no subgraphs exist', () => {
const result = flattenWorkflowNodes({
nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')]
} as ComfyWorkflowJSON)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([1, 2])
})
it('returns empty array when nodes is undefined', () => {
const result = flattenWorkflowNodes({} as ComfyWorkflowJSON)
expect(result).toEqual([])
})
it('includes subgraph nodes with prefixed IDs', () => {
const result = flattenWorkflowNodes({
nodes: [node(5, 'def-A')],
definitions: {
subgraphs: [
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
]
}
} as unknown as ComfyWorkflowJSON)
expect(result).toHaveLength(3) // 1 root + 2 subgraph
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20'])
})
it('prefixes nested subgraph nodes with full execution path', () => {
const result = flattenWorkflowNodes({
nodes: [node(5, 'def-A')],
definitions: {
subgraphs: [
subgraphDef('def-A', [node(10, 'def-B')]),
subgraphDef('def-B', [node(3, 'Leaf')])
]
}
} as unknown as ComfyWorkflowJSON)
// root:5, def-A inner: 5:10, def-B inner: 5:10:3
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3'])
})
})

View File

@@ -592,56 +592,3 @@ export function buildSubgraphExecutionPaths(
build(rootNodes, '')
return pathMap
}
/**
* Recursively collect all subgraph definitions from root and nested levels.
*/
function collectAllSubgraphDefs(rootDefs: unknown[]): SubgraphDefinition[] {
const result: SubgraphDefinition[] = []
const seen = new Set<string>()
function collect(defs: unknown[]) {
for (const def of defs) {
if (!isSubgraphDefinition(def)) continue
if (seen.has(def.id)) continue
seen.add(def.id)
result.push(def)
if (def.definitions?.subgraphs?.length) {
collect(def.definitions.subgraphs)
}
}
}
collect(rootDefs)
return result
}
/**
* Flatten all workflow nodes (root + subgraphs) into a single array.
* Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" → "11:3").
*/
export function flattenWorkflowNodes(
graphData: ComfyWorkflowJSON
): Readonly<ComfyNode>[] {
const rootNodes = graphData.nodes ?? []
const allDefs = collectAllSubgraphDefs(graphData.definitions?.subgraphs ?? [])
const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs)
const allNodes: ComfyNode[] = [...rootNodes]
const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s]))
for (const [defId, paths] of pathMap.entries()) {
const def = subgraphDefMap.get(defId)
if (!def?.nodes) continue
for (const prefix of paths) {
for (const node of def.nodes) {
allNodes.push({
...node,
id: `${prefix}:${node.id}`
})
}
}
}
return allNodes
}

View File

@@ -1,131 +0,0 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import DropZone from './DropZone.vue'
type StoryArgs = ComponentPropsAndSlots<typeof DropZone>
const defaultLabel = 'Click to browse or drag an image'
const defaultIconClass = 'icon-[lucide--image]'
function createFileInput(onFile: (file: File) => void) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.addEventListener('change', () => {
const file = input.files?.[0]
if (file) onFile(file)
})
return input
}
function fileToObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
function extractDroppedImageFile(e: DragEvent): File | undefined {
return Array.from(e.dataTransfer?.files ?? []).find((f) =>
f.type.startsWith('image/')
)
}
const renderStory = (args: StoryArgs) => ({
components: { DropZone },
setup() {
const imageUrl = ref<string | undefined>(undefined)
const hovered = ref(false)
function handleFile(file: File) {
imageUrl.value = fileToObjectUrl(file)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer?.items) return false
return Array.from(e.dataTransfer.items).some(
(item) => item.kind === 'file' && item.type.startsWith('image/')
)
}
const onDragDrop = (e: DragEvent) => {
const file = extractDroppedImageFile(e)
if (file) handleFile(file)
return !!file
}
const onClick = () => {
createFileInput(handleFile).click()
}
const dropIndicator = ref({
...args.dropIndicator,
onClick
})
return { args, onDragOver, onDragDrop, dropIndicator, imageUrl, hovered }
},
template: `
<div
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<DropZone
v-bind="args"
:on-drag-over="onDragOver"
:on-drag-drop="onDragDrop"
:force-hovered="hovered"
:drop-indicator="{
...dropIndicator,
imageUrl: imageUrl ?? dropIndicator.imageUrl
}"
/>
</div>
`
})
const meta: Meta<StoryArgs> = {
title: 'Components/FileUpload',
component: DropZone,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Linear mode drag-and-drop target with a file upload indicator. Click to browse or drag an image file to upload.'
}
}
},
argTypes: {
onDragOver: { table: { disable: true } },
onDragDrop: { table: { disable: true } },
dropIndicator: { control: false },
forceHovered: { table: { disable: true } }
},
args: {
dropIndicator: {
label: defaultLabel,
iconClass: defaultIconClass
}
},
decorators: [
(story) => ({
components: { story },
template: `
<div class="w-[440px] rounded-xl bg-component-node-background p-4">
<story />
</div>
`
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: renderStory
}

View File

@@ -1,15 +1,10 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
onDragOver,
onDragDrop,
dropIndicator,
forceHovered = false
} = defineProps<{
const props = defineProps<{
onDragOver?: (e: DragEvent) => boolean
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
dropIndicator?: {
@@ -18,7 +13,6 @@ const {
label?: string
onClick?: (e: MouseEvent) => void
}
forceHovered?: boolean
}>()
const dropZoneRef = ref<HTMLElement | null>(null)
@@ -29,83 +23,53 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
// Stop propagation to prevent global handlers from creating a new node
event?.stopPropagation()
if (onDragDrop && event) {
onDragDrop(event)
if (props.onDragDrop && event) {
props.onDragDrop(event)
}
canAcceptDrop.value = false
},
onOver: (_, event) => {
if (onDragOver && event) {
canAcceptDrop.value = onDragOver(event)
if (props.onDragOver && event) {
canAcceptDrop.value = props.onDragOver(event)
}
},
onLeave: () => {
canAcceptDrop.value = false
}
})
const isHovered = computed(
() => forceHovered || (canAcceptDrop.value && isOverDropZone.value)
)
const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
</script>
<template>
<div
v-if="onDragOver && onDragDrop"
ref="dropZoneRef"
data-slot="drop-zone"
:class="
cn(
'rounded-lg transition-colors',
isHovered && 'bg-component-node-widget-background-hovered'
'rounded-lg ring-primary-500 ring-inset',
canAcceptDrop && isOverDropZone && 'bg-primary-500/10 ring-4'
)
"
>
<slot />
<component
:is="indicatorTag"
<div
v-if="dropIndicator"
:type="dropIndicator?.onClick ? 'button' : undefined"
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
'm-3 flex h-25 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-subtle py-2',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@click.prevent="dropIndicator?.onClick?.($event)"
>
<div
:class="
cn(
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator?.imageUrl &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<img
v-if="dropIndicator?.imageUrl"
class="max-h-23 rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
v-if="dropIndicator.iconClass"
:class="
cn(
'size-4 text-component-node-foreground-secondary',
dropIndicator.iconClass
)
"
/>
</template>
</div>
</component>
<img
v-if="dropIndicator?.imageUrl"
class="h-23"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
</template>
</div>
</div>
<slot v-else />
</template>

View File

@@ -64,7 +64,6 @@ useEventListener(
)
const mappedSelections = computed(() => {
void graphNodes.value
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { ref, useTemplateRef, watch } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
@@ -19,10 +19,6 @@ watch([containerRef, () => modelUrl], async () => {
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
})
onUnmounted(() => {
viewer.value.cleanup()
})
//TODO: refactor to add control buttons
</script>
<template>

View File

@@ -365,7 +365,6 @@ describe('linearOutputStore', () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
@@ -388,14 +387,12 @@ describe('linearOutputStore', () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// Direct transition: job-1 → job-2 (no null in between)
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-2'
await nextTick()
@@ -634,7 +631,7 @@ describe('linearOutputStore', () => {
)
})
it('preserves in-progress items when leaving app mode', async () => {
it('resets state when leaving app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
@@ -646,65 +643,9 @@ describe('linearOutputStore', () => {
isAppModeRef.value = false
await nextTick()
expect(store.inProgressItems.length).toBeGreaterThan(0)
expect(store.selectedId).toBe('slot:some-id')
})
it('completes stale tracked job when re-entering app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// Switch away — job finishes while we're gone
isAppModeRef.value = false
await nextTick()
activeJobIdRef.value = null
await nextTick()
// Switch back — store should reconcile the stale tracked job
isAppModeRef.value = true
await nextTick()
expect(store.pendingResolve.has('job-1')).toBe(true)
})
it('recovers latent preview when re-entering app mode', async () => {
vi.useFakeTimers()
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
// First node executes, consuming the skeleton
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
expect(store.inProgressItems[0].state).toBe('image')
// Switch away — latent preview arrives for next node while gone
isAppModeRef.value = false
await nextTick()
previewsRef.value = {
'job-1': { url: 'blob:preview-while-away', nodeId: 'node-2' }
}
await nextTick()
// Switch back — should recover the latent preview
isAppModeRef.value = true
await nextTick()
vi.advanceTimersByTime(16)
const latentItems = store.inProgressItems.filter(
(i) => i.state === 'latent'
)
expect(latentItems).toHaveLength(1)
expect(latentItems[0].latentPreviewUrl).toBe('blob:preview-while-away')
vi.useRealTimers()
expect(store.inProgressItems).toHaveLength(0)
expect(store.selectedId).toBeNull()
expect(store.pendingResolve.size).toBe(0)
})
it('does not show in-progress items from another workflow', () => {
@@ -844,472 +785,4 @@ describe('linearOutputStore', () => {
expect(store.inProgressItems).toHaveLength(0)
})
describe('workflow switching during generation', () => {
async function setup() {
vi.useFakeTimers()
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
return { store, nextTick }
}
afterEach(() => {
vi.useRealTimers()
})
it('preserves images and latent previews across tab switch', async () => {
const { store, nextTick } = await setup()
// Workflow A: start job, produce 1 image + 1 latent
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
store.onLatentPreview('job-a', 'blob:node2-latent', '2')
vi.advanceTimersByTime(16)
const imagesBefore = store.inProgressItems.filter(
(i) => i.state === 'image'
)
const latentsBefore = store.inProgressItems.filter(
(i) => i.state === 'latent'
)
expect(imagesBefore).toHaveLength(1)
expect(latentsBefore).toHaveLength(1)
// Switch to workflow B (graph mode)
activeWorkflowPathRef.value = 'workflows/graph-b.json'
isAppModeRef.value = false
await nextTick()
// Items still in store (not reset)
expect(store.inProgressItems.filter((i) => i.state === 'image')).toEqual(
imagesBefore
)
expect(store.inProgressItems.filter((i) => i.state === 'latent')).toEqual(
latentsBefore
)
// Switch back to workflow A
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = true
await nextTick()
vi.advanceTimersByTime(16)
// Items visible via activeWorkflowInProgressItems
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
expect(
store.activeWorkflowInProgressItems.some((i) => i.state === 'image')
).toBe(true)
})
it('captures outputs produced while viewing another tab', async () => {
const { store, nextTick } = await setup()
// Workflow A: start job, produce 1 image
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(1)
// Switch away
activeWorkflowPathRef.value = 'workflows/graph-b.json'
isAppModeRef.value = false
await nextTick()
// While away: node 2 executes (event missed — listener removed)
// Node 3 starts sending latent previews (watcher guarded)
previewsRef.value = {
'job-a': { url: 'blob:node3-latent', nodeId: '3' }
}
await nextTick()
// Switch back
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = true
await nextTick()
vi.advanceTimersByTime(16)
// Original image preserved + latent preview recovered
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(1)
expect(
store.inProgressItems.filter((i) => i.state === 'latent')
).toHaveLength(1)
expect(store.inProgressItems[0].latentPreviewUrl).toBe(
'blob:node3-latent'
)
})
it('scopes items to correct workflow after switching back', async () => {
const { store, nextTick } = await setup()
// Workflow A: start job
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch to workflow B
activeWorkflowPathRef.value = 'workflows/app-b.json'
// Workflow A items should NOT appear for workflow B
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// Switch back to workflow A
activeWorkflowPathRef.value = 'workflows/app-a.json'
// Workflow A items should reappear
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-a')
expect(store.activeWorkflowInProgressItems[0].state).toBe('image')
})
it('completes job A while away and starts tracking job B on return', async () => {
const { store, nextTick } = await setup()
// Workflow A: start and partially generate
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch to workflow B (graph mode)
activeWorkflowPathRef.value = 'workflows/graph-b.json'
isAppModeRef.value = false
await nextTick()
// While away: job A finishes, job B starts
setJobWorkflowPath('job-b', 'workflows/app-a.json')
activeJobIdRef.value = 'job-b'
await nextTick()
// Switch back to workflow A
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = true
await nextTick()
// Job A should have been completed (pending resolve)
expect(store.pendingResolve.has('job-a')).toBe(true)
// Job B should have been started
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
})
it('handles job finishing while away with no new job', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch away
isAppModeRef.value = false
await nextTick()
// Job finishes, no new job
activeJobIdRef.value = null
await nextTick()
// Switch back
isAppModeRef.value = true
await nextTick()
// Job A completed, images pending resolve
expect(store.pendingResolve.has('job-a')).toBe(true)
// No new skeleton should have been created (no active job)
expect(
store.inProgressItems.filter((i) => i.state === 'skeleton')
).toHaveLength(0)
})
it('does not leak workflow A items into workflow B view', async () => {
const { store, nextTick } = await setup()
// Workflow A: two images
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
store.onNodeExecuted(
'job-a',
makeExecutedDetail(
'job-a',
[{ filename: 'b.png', subfolder: '', type: 'output' }],
'2'
)
)
// Items exist in the global list
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(2)
// Switch to workflow B (also app mode)
activeWorkflowPathRef.value = 'workflows/app-b.json'
// Workflow B should see nothing from job-a
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// Global list still has them
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(2)
})
it('cleans up stale tracked job when leaving app mode after job finishes', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Job finishes while still in app mode
store.onJobComplete('job-a')
expect(store.pendingResolve.has('job-a')).toBe(true)
// Now switch away — pendingResolve items stay (still-running case
// doesn't apply, but items are kept for history absorption)
activeJobIdRef.value = null
isAppModeRef.value = false
await nextTick()
// Items preserved (pendingResolve is not cleared on exit)
expect(store.inProgressItems.some((i) => i.jobId === 'job-a')).toBe(true)
})
it('evicts prior pendingResolve entries when a new job completes', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
// Job 1: produce image, complete
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onJobComplete('job-1')
expect(store.pendingResolve.has('job-1')).toBe(true)
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(true)
// Job 2: produce image, complete — should evict job-1
store.onJobStart('job-2')
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
store.onJobComplete('job-2')
expect(store.pendingResolve.has('job-1')).toBe(false)
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(false)
// Job 2 is now pending resolve
expect(store.pendingResolve.has('job-2')).toBe(true)
vi.useRealTimers()
})
it('cleans up finished tracked job on exit when job ended while in app mode', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Job ends, new job starts — all while in app mode
setJobWorkflowPath('job-b', 'workflows/app-a.json')
activeJobIdRef.value = 'job-b'
await nextTick()
// job-a completed via activeJobId watcher, now pending resolve
expect(store.pendingResolve.has('job-a')).toBe(true)
// Switch away — tracked job is now job-b which is still active, so
// the else-branch does NOT complete it (it's still running)
isAppModeRef.value = false
await nextTick()
// job-b items preserved (still running)
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
})
it('does not leak items across many job cycles', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
for (let i = 1; i <= 5; i++) {
const jobId = `job-${i}`
setJobWorkflowPath(jobId, 'workflows/test-workflow.json')
store.onJobStart(jobId)
store.onNodeExecuted(jobId, makeExecutedDetail(jobId))
store.onJobComplete(jobId)
}
// Only the last job should have items (pending resolve).
// All prior jobs were evicted by subsequent onJobComplete calls.
expect(store.pendingResolve.size).toBe(1)
expect(store.pendingResolve.has('job-5')).toBe(true)
expect(store.inProgressItems.every((i) => i.jobId === 'job-5')).toBe(true)
vi.useRealTimers()
})
it('does not adopt another workflow job when switching back', async () => {
const { store, nextTick } = await setup()
// Tab A: queue "cat"
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-cat', 'workflows/app-a.json')
activeJobIdRef.value = 'job-cat'
await nextTick()
store.onNodeExecuted(
'job-cat',
makeExecutedDetail('job-cat', undefined, '1')
)
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-cat')
// Switch to tab B (different workflow, also app mode): queue "dog"
activeWorkflowPathRef.value = 'workflows/app-b.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
setJobWorkflowPath('job-dog', 'workflows/app-b.json')
activeJobIdRef.value = 'job-dog'
await nextTick()
// Tab B should see dog, not cat
expect(
store.activeWorkflowInProgressItems.every((i) => i.jobId === 'job-dog')
).toBe(true)
// Switch back to tab A
activeWorkflowPathRef.value = 'workflows/app-a.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
// Dog's executed events arrive while viewing tab A (listener is
// active and activeJobId is still job-dog).
const event = new CustomEvent('executed', {
detail: makeExecutedDetail(
'job-dog',
[{ filename: 'dog.png', subfolder: '', type: 'output' }],
'1'
)
})
apiTarget.dispatchEvent(event)
// Dog's latent preview also arrives
previewsRef.value = {
'job-dog': { url: 'blob:dog-latent', nodeId: '2' }
}
await nextTick()
vi.advanceTimersByTime(16)
// Tab A must NOT show dog — only cat
const tabAItems = store.activeWorkflowInProgressItems
expect(tabAItems.every((i) => i.jobId === 'job-cat')).toBe(true)
expect(tabAItems.some((i) => i.jobId === 'job-dog')).toBe(false)
// Selection must not have been yanked to a dog item
expect(store.selectedId?.includes('job-dog') ?? false).toBe(false)
// Dog should still exist globally (scoped to tab B)
expect(store.inProgressItems.some((i) => i.jobId === 'job-dog')).toBe(
true
)
})
it('does not create skeleton for next job from another workflow', async () => {
const { store, nextTick } = await setup()
// Run dog on dog tab
activeWorkflowPathRef.value = 'workflows/dog.json'
setJobWorkflowPath('job-dog', 'workflows/dog.json')
activeJobIdRef.value = 'job-dog'
await nextTick()
// Swap to cat tab, queue cat (dog still running)
activeWorkflowPathRef.value = 'workflows/cat.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
setJobWorkflowPath('job-cat', 'workflows/cat.json')
// Swap back to dog tab
activeWorkflowPathRef.value = 'workflows/dog.json'
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
// Dog finishes, cat starts (activeJobId transitions on dog tab)
activeJobIdRef.value = 'job-cat'
await nextTick()
// Dog tab must NOT show cat's skeleton
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// No skeleton for cat should have been created at all
expect(
store.inProgressItems.some(
(i) => i.jobId === 'job-cat' && i.state === 'skeleton'
)
).toBe(false)
})
it('processes new executed events after switching back', async () => {
const { store, nextTick } = await setup()
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-a', 'workflows/app-a.json')
activeJobIdRef.value = 'job-a'
await nextTick()
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
// Switch away and back
isAppModeRef.value = false
await nextTick()
isAppModeRef.value = true
await nextTick()
// Fire an executed event via the API — listener should be re-attached
const event = new CustomEvent('executed', {
detail: makeExecutedDetail(
'job-a',
[{ filename: 'c.png', subfolder: '', type: 'output' }],
'3'
)
})
apiTarget.dispatchEvent(event)
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(2)
})
})
})

View File

@@ -167,14 +167,6 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
}
function onJobComplete(jobId: string) {
// On any job complete, remove all pending resolve items.
if (pendingResolve.value.size > 0) {
for (const oldJobId of pendingResolve.value) {
removeJobItems(oldJobId)
}
pendingResolve.value = new Set()
}
if (raf) {
cancelAnimationFrame(raf)
raf = null
@@ -234,16 +226,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
isFollowing.value = true
}
function isJobForActiveWorkflow(jobId: string): boolean {
return (
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
workflowStore.activeWorkflow?.path
)
}
function autoSelect(slotId: string, jobId: string) {
// Only auto-select if the job belongs to the active workflow
if (!isJobForActiveWorkflow(jobId)) return
const path = workflowStore.activeWorkflow?.path
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
return
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
@@ -262,6 +249,20 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
onNodeExecuted(jobId, detail)
}
function reset() {
if (raf) {
cancelAnimationFrame(raf)
raf = null
}
executedNodeIds.clear()
inProgressItems.value = []
selectedId.value = null
isFollowing.value = true
trackedJobId.value = null
currentSkeletonId.value = null
pendingResolve.value = new Set()
}
watch(
() => executionStore.activeJobId,
(jobId, oldJobId) => {
@@ -269,10 +270,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
if (oldJobId && oldJobId !== jobId) {
onJobComplete(oldJobId)
}
// Start tracking only if the job belongs to this workflow.
// Jobs from other workflows are picked up by reconcileOnEnter
// when the user switches to that workflow's tab.
if (jobId && isJobForActiveWorkflow(jobId)) {
if (jobId) {
onJobStart(jobId)
}
}
@@ -290,66 +288,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
{ deep: true }
)
function reconcileOnEnter() {
// Complete any tracked job that finished while we were away.
// The activeJobId watcher couldn't fire onJobComplete because
// isAppMode was false at the time.
if (
trackedJobId.value &&
trackedJobId.value !== executionStore.activeJobId
) {
onJobComplete(trackedJobId.value)
}
// Start tracking the current job only if it belongs to this
// workflow — otherwise we'd adopt another tab's job.
if (
executionStore.activeJobId &&
trackedJobId.value !== executionStore.activeJobId &&
isJobForActiveWorkflow(executionStore.activeJobId)
) {
onJobStart(executionStore.activeJobId)
}
// Clear stale selection from another workflow's job.
if (
selectedId.value?.startsWith('slot:') &&
trackedJobId.value &&
!isJobForActiveWorkflow(trackedJobId.value)
) {
selectedId.value = null
isFollowing.value = true
}
// Re-apply the latest latent preview that may have arrived while
// away, but only for a job belonging to the active workflow.
const jobId = trackedJobId.value
if (jobId && isJobForActiveWorkflow(jobId)) {
const preview = jobPreviewStore.nodePreviewsByPromptId[jobId]
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
}
}
function cleanupOnLeave() {
// If the tracked job already finished (no longer the active job),
// complete it now to clean up skeletons/latents. If it's still
// running, preserve all items for tab switching.
if (
trackedJobId.value &&
trackedJobId.value !== executionStore.activeJobId
) {
onJobComplete(trackedJobId.value)
}
}
watch(
isAppMode,
(active, wasActive) => {
if (active) {
api.addEventListener('executed', handleExecuted)
reconcileOnEnter()
} else if (wasActive) {
api.removeEventListener('executed', handleExecuted)
cleanupOnLeave()
reset()
}
},
{ immediate: true }

View File

@@ -187,46 +187,6 @@ describe('useMinimapViewport', () => {
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should maintain strict reference equality for viewportTransform when canvas state is unchanged', () => {
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const initialTransform = viewport.viewportTransform.value
viewport.updateViewport()
const transformAfterIdle = viewport.viewportTransform.value
expect(transformAfterIdle).toBe(initialTransform)
mockCanvas.ds.offset = [-150, -50]
viewport.updateViewport()
const transformAfterPan = viewport.viewportTransform.value
expect(transformAfterPan).not.toBe(initialTransform)
expect(transformAfterPan.x).not.toBe(initialTransform.x)
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
const graphRef = ref(mockGraph) as Ref<LGraph | null>

View File

@@ -90,16 +90,12 @@ export function useMinimapViewport(
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
const x = (worldX - bounds.value.minX) * scale.value + centerOffsetX
const y = (worldY - bounds.value.minY) * scale.value + centerOffsetY
const w = viewportWidth * scale.value
const h = viewportHeight * scale.value
const curr = viewportTransform.value
if (curr.x === x && curr.y === y && curr.width === w && curr.height === h)
return
viewportTransform.value = { x, y, width: w, height: h }
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
}
const updateBounds = () => {

View File

@@ -1,61 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ImagePreview from './ImagePreview.vue'
const SAMPLE_URLS = [
'https://picsum.photos/seed/preview1/800/600',
'https://picsum.photos/seed/preview2/800/600',
'https://picsum.photos/seed/preview3/800/600'
]
const meta: Meta<typeof ImagePreview> = {
title: 'Components/Display/ImagePreview',
component: ImagePreview,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
}
}
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="h-80 w-96 rounded-lg bg-component-node-background"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
imageUrls: [SAMPLE_URLS[0]]
}
}
export const MultipleImages: Story = {
args: {
imageUrls: SAMPLE_URLS
}
}
export const ErrorState: Story = {
args: {
imageUrls: ['https://invalid.example.com/no-image.png']
}
}
export const ManyImages: Story = {
args: {
imageUrls: Array.from(
{ length: 8 },
(_, i) => `https://picsum.photos/seed/many${i}/800/600`
)
}
}

View File

@@ -101,7 +101,7 @@ describe('ImagePreview', () => {
it('shows navigation dots for multiple images', () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(2)
})
@@ -110,7 +110,7 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(0)
})
@@ -249,7 +249,7 @@ describe('ImagePreview', () => {
)
// Click second navigation dot
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
@@ -259,22 +259,22 @@ describe('ImagePreview', () => {
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('marks active navigation dot with aria-current', async () => {
it('applies correct classes to navigation dots based on current image', async () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
// First dot should be active
expect(navigationDots[0].attributes('aria-current')).toBe('true')
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
// First dot should be active (has bg-white class)
expect(navigationDots[0].classes()).toContain('bg-base-foreground')
expect(navigationDots[1].classes()).toContain('bg-base-foreground/50')
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
// Second dot should now be active
expect(navigationDots[0].attributes('aria-current')).toBeUndefined()
expect(navigationDots[1].attributes('aria-current')).toBe('true')
expect(navigationDots[0].classes()).toContain('bg-base-foreground/50')
expect(navigationDots[1].classes()).toContain('bg-base-foreground')
})
it('loads image without errors', async () => {
@@ -301,7 +301,7 @@ describe('ImagePreview', () => {
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
// Switch to second image
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
@@ -326,7 +326,7 @@ describe('ImagePreview', () => {
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Click second navigation dot to cycle
const dots = wrapper.findAll('[aria-label*="View image"]')
const dots = wrapper.findAll('.w-2.h-2.rounded-full')
await dots[1].trigger('click')
await nextTick()

View File

@@ -7,7 +7,7 @@
<!-- Image Wrapper -->
<div
ref="imageWrapperEl"
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
class="relative flex min-h-0 w-full flex-1 overflow-hidden rounded-[5px] bg-transparent"
tabindex="0"
role="img"
:aria-label="$t('g.imagePreview')"
@@ -33,19 +33,14 @@
</div>
<!-- Loading State -->
<div v-if="showLoader && !imageError" class="size-full">
<Skeleton class="size-full rounded-sm" />
<Skeleton border-radius="5px" width="100%" height="100%" />
</div>
<!-- Main Image -->
<img
v-if="!imageError"
:src="currentImageUrl"
:alt="imageAltText"
:class="
cn(
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
(isHovered || isFocused) && 'opacity-60'
)
"
class="pointer-events-none absolute inset-0 block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>
@@ -53,7 +48,7 @@
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-1"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<!-- Mask/Edit Button -->
<button
@@ -83,7 +78,7 @@
:aria-label="$t('g.removeImage')"
@click="handleRemove"
>
<i class="icon-[lucide--circle-x] size-4" />
<i class="icon-[lucide--x] size-4" />
</button>
</div>
</div>
@@ -124,16 +119,15 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -147,10 +141,9 @@ const props = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
// Component state
const currentIndex = ref(0)
@@ -236,10 +229,11 @@ const handleDownload = () => {
try {
downloadFile(currentImageUrl.value)
} catch (error) {
toastStore.add({
useToast().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
summary: 'Error',
detail: t('g.failedToDownloadImage'),
group: 'image-preview'
})
}
}
@@ -286,13 +280,13 @@ const handleFocusOut = (event: FocusEvent) => {
}
}
function getNavigationDotClass(index: number) {
return cn(
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',
index === currentIndex.value
? 'bg-base-foreground'
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
)
]
}
const handleKeyDown = (event: KeyboardEvent) => {

View File

@@ -289,7 +289,6 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -340,7 +339,6 @@ const isSelected = computed(() => {
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
@@ -351,11 +349,9 @@ const hasAnyError = computed((): boolean => {
nodeData.hasErrors ||
error ||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
)
})

View File

@@ -115,7 +115,6 @@ import {
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -135,7 +134,6 @@ const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -204,7 +202,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const nodeIdStr = String(nodeId)
const { widgets } = nodeData
const result: ProcessedWidget[] = []
@@ -287,11 +284,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
(nodeErrors?.errors?.some(
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ??
false) ||
missingModelStore.isWidgetMissingModel(nodeIdStr, widget.name),
) ?? false,
hidden: widget.options?.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,

View File

@@ -1,159 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import DisplayCarousel from './DisplayCarousel.vue'
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
const SAMPLE_IMAGES = [
'https://picsum.photos/seed/comfy1/600/400',
'https://picsum.photos/seed/comfy2/600/400',
'https://picsum.photos/seed/comfy3/600/400',
'https://picsum.photos/seed/comfy4/600/400',
'https://picsum.photos/seed/comfy5/600/400'
]
const SAMPLE_IMAGE_OBJECTS: GalleryImage[] = [
{
itemImageSrc: 'https://picsum.photos/seed/obj1/600/400',
thumbnailImageSrc: 'https://picsum.photos/seed/obj1/120/80',
alt: 'Mountain landscape'
},
{
itemImageSrc: 'https://picsum.photos/seed/obj2/600/400',
thumbnailImageSrc: 'https://picsum.photos/seed/obj2/120/80',
alt: 'Ocean view'
},
{
itemImageSrc: 'https://picsum.photos/seed/obj3/600/400',
thumbnailImageSrc: 'https://picsum.photos/seed/obj3/120/80',
alt: 'Forest path'
}
]
const meta: Meta<typeof DisplayCarousel> = {
title: 'Components/Display/DisplayCarousel',
component: DisplayCarousel,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Image gallery with Single (carousel) and Grid display modes. Hover to reveal a toggle button that switches between modes. Grid mode shows images in a responsive grid; clicking an image switches back to single mode focused on that image.'
}
}
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-80 rounded-xl bg-component-node-background p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { DisplayCarousel },
setup() {
const value = ref<GalleryValue>([...SAMPLE_IMAGES])
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
name: 'gallery',
type: 'array',
value: []
}
return { value, widget }
},
template: '<DisplayCarousel :widget="widget" v-model="value" />'
})
}
export const SingleImage: Story = {
render: () => ({
components: { DisplayCarousel },
setup() {
const value = ref<GalleryValue>([SAMPLE_IMAGES[0]])
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
name: 'gallery',
type: 'array',
value: []
}
return { value, widget }
},
template: '<DisplayCarousel :widget="widget" v-model="value" />'
})
}
export const WithImageObjects: Story = {
render: () => ({
components: { DisplayCarousel },
setup() {
const value = ref<GalleryValue>([...SAMPLE_IMAGE_OBJECTS])
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
name: 'gallery',
type: 'array',
value: []
}
return { value, widget }
},
template: '<DisplayCarousel :widget="widget" v-model="value" />'
})
}
export const GridFewImages: Story = {
render: () => ({
components: { DisplayCarousel },
setup() {
const value = ref<GalleryValue>([...SAMPLE_IMAGES.slice(0, 4)])
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
name: 'gallery',
type: 'array',
value: []
}
return { value, widget, displayMode: ref('grid') }
},
template: '<DisplayCarousel :widget="widget" v-model="value" />'
})
}
export const GridManyImages: Story = {
render: () => ({
components: { DisplayCarousel },
setup() {
const value = ref<GalleryValue>(
Array.from(
{ length: 25 },
(_, i) => `https://picsum.photos/seed/grid${i}/200/200`
)
)
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
name: 'gallery',
type: 'array',
value: []
}
return { value, widget }
},
template: '<DisplayCarousel :widget="widget" v-model="value" />'
})
}
export const Empty: Story = {
render: () => ({
components: { DisplayCarousel },
setup() {
const value = ref<GalleryValue>([])
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
name: 'gallery',
type: 'array',
value: []
}
return { value, widget }
},
template: '<DisplayCarousel :widget="widget" v-model="value" />'
})
}

View File

@@ -1,455 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import DisplayCarousel from './DisplayCarousel.vue'
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
import { createMockWidget } from './widgetTestUtils'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
galleryImage: 'Gallery image',
galleryThumbnail: 'Gallery thumbnail',
previousImage: 'Previous image',
nextImage: 'Next image',
switchToGridView: 'Switch to grid view',
switchToSingleView: 'Switch to single view',
viewImageOfTotal: 'View image {index} of {total}',
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image'
}
}
}
})
const TEST_IMAGES_SMALL: readonly string[] = Object.freeze([
'https://example.com/image0.jpg',
'https://example.com/image1.jpg',
'https://example.com/image2.jpg'
])
const TEST_IMAGES_SINGLE: readonly string[] = Object.freeze([
'https://example.com/single.jpg'
])
const TEST_IMAGE_OBJECTS: readonly GalleryImage[] = Object.freeze([
{
itemImageSrc: 'https://example.com/image0.jpg',
thumbnailImageSrc: 'https://example.com/thumb0.jpg',
alt: 'Test image 0'
},
{
itemImageSrc: 'https://example.com/image1.jpg',
thumbnailImageSrc: 'https://example.com/thumb1.jpg',
alt: 'Test image 1'
}
])
function createGalleriaWidget(
value: GalleryValue = [],
options: Record<string, unknown> = {}
) {
return createMockWidget<GalleryValue>({
value,
name: 'test_galleria',
type: 'array',
options
})
}
function mountComponent(
widget: SimplifiedWidget<GalleryValue>,
modelValue: GalleryValue
) {
return mount(DisplayCarousel, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n]
},
props: {
widget,
modelValue
}
})
}
function createImageStrings(count: number): string[] {
return Array.from(
{ length: count },
(_, i) => `https://example.com/image${i}.jpg`
)
}
function createGalleriaWrapper(
images: GalleryValue,
options: Record<string, unknown> = {}
) {
const widget = createGalleriaWidget(images, options)
return mountComponent(widget, images)
}
function findThumbnails(wrapper: ReturnType<typeof mount>) {
return wrapper.findAll('div').filter((div) => {
return div.find('img').exists() && div.classes().includes('border-2')
})
}
function findImageContainer(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[tabindex="0"]')
}
describe('DisplayCarousel Single Mode', () => {
describe('Component Rendering', () => {
it('renders main image', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe(TEST_IMAGES_SMALL[0])
})
it('displays empty gallery when no images provided', () => {
const wrapper = createGalleriaWrapper([])
expect(wrapper.find('img').exists()).toBe(false)
})
it('handles null value gracefully', () => {
const widget = createGalleriaWidget([])
const wrapper = mountComponent(widget, null as unknown as GalleryValue)
expect(wrapper.find('img').exists()).toBe(false)
})
it('handles undefined value gracefully', () => {
const widget = createGalleriaWidget([])
const wrapper = mountComponent(
widget,
undefined as unknown as GalleryValue
)
expect(wrapper.find('img').exists()).toBe(false)
})
})
describe('String Array Input', () => {
it('converts string array to image objects and displays first', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
})
it('handles single string image', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('https://example.com/single.jpg')
})
})
describe('Object Array Input', () => {
it('preserves image objects and displays first', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGE_OBJECTS])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
expect(img.attributes('alt')).toBe('Test image 0')
})
it('handles mixed object properties with src fallback', () => {
const images: GalleryImage[] = [
{ src: 'https://example.com/image1.jpg', alt: 'First' }
]
const wrapper = createGalleriaWrapper(images)
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('https://example.com/image1.jpg')
})
})
describe('Thumbnail Display', () => {
it('shows thumbnails when multiple images present', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const thumbnailButtons = findThumbnails(wrapper)
expect(thumbnailButtons).toHaveLength(3)
})
it('hides thumbnails for single image', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
const thumbnailButtons = findThumbnails(wrapper)
expect(thumbnailButtons).toHaveLength(0)
})
it('thumbnails are not interactive', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
const thumbnails = findThumbnails(wrapper)
expect(thumbnails[0].element.tagName).not.toBe('BUTTON')
})
})
describe('Navigation Buttons', () => {
it('shows navigation buttons when multiple images present', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(true)
})
it('hides navigation buttons for single image', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(false)
})
it('respects widget option to hide navigation buttons', () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
showItemNavigators: false
})
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
})
it('navigates to next image on next click', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
await wrapper.find('[aria-label="Next image"]').trigger('click')
await nextTick()
const mainImg = wrapper.findAll('img')[0]
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
})
it('navigates to previous image on prev click', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
// Go to second image first
await wrapper.find('[aria-label="Next image"]').trigger('click')
await nextTick()
// Go back
await wrapper.find('[aria-label="Previous image"]').trigger('click')
await nextTick()
const mainImg = wrapper.findAll('img')[0]
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
})
it('wraps from first to last image on previous click', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
await wrapper.find('[aria-label="Previous image"]').trigger('click')
await nextTick()
const mainImg = wrapper.findAll('img')[0]
expect(mainImg.attributes('src')).toBe('https://example.com/image2.jpg')
})
it('wraps from last to first image on next click', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
// Navigate to last image
await wrapper.find('[aria-label="Next image"]').trigger('click')
await wrapper.find('[aria-label="Next image"]').trigger('click')
await nextTick()
// Next from last should wrap to first
await wrapper.find('[aria-label="Next image"]').trigger('click')
await nextTick()
const mainImg = wrapper.findAll('img')[0]
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
})
})
})
describe('DisplayCarousel Accessibility', () => {
it('shows controls on focusin for keyboard users', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
true
)
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
true
)
})
it('hides controls on focusout when focus leaves component', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
// Focus leaves the image container entirely
await findImageContainer(wrapper).trigger('focusout', {
relatedTarget: null
})
await nextTick()
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
false
)
})
})
describe('DisplayCarousel Grid Mode', () => {
it('switches to grid mode via toggle button on hover', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
// Trigger focus on image container to reveal toggle button
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
const toggleBtn = wrapper.find('[aria-label="Switch to grid view"]')
expect(toggleBtn.exists()).toBe(true)
await toggleBtn.trigger('click')
await nextTick()
// Grid mode should show all images as grid items
const gridImages = wrapper.findAll('img')
expect(gridImages).toHaveLength(TEST_IMAGES_SMALL.length)
})
it('does not show grid toggle for single image', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
false
)
})
it('switches back to single mode via toggle button', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
// Switch to grid via focus on image container
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
await nextTick()
// Focus the grid container to reveal toggle
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
// Switch back to single
const singleToggle = wrapper.find('[aria-label="Switch to single view"]')
expect(singleToggle.exists()).toBe(true)
await singleToggle.trigger('click')
await nextTick()
// Should be back in single mode with main image
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
})
it('clicking grid image switches to single mode focused on that image', async () => {
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
// Switch to grid via focus on image container
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
await nextTick()
// Click second grid image
const gridButtons = wrapper
.findAll('button')
.filter((btn) => btn.find('img').exists())
await gridButtons[1].trigger('click')
await nextTick()
// Should be in single mode showing the second image
const mainImg = wrapper.findAll('img')[0]
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
})
it('reverts to single mode when images reduce to one', async () => {
const images = ref<GalleryValue>([...TEST_IMAGES_SMALL])
const widget = createGalleriaWidget([...TEST_IMAGES_SMALL])
const wrapper = mount(DisplayCarousel, {
global: { plugins: [createTestingPinia({ createSpy: vi.fn }), i18n] },
props: { widget, modelValue: images.value }
})
// Switch to grid via focus on image container
await findImageContainer(wrapper).trigger('focusin')
await nextTick()
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
await nextTick()
// Reduce to single image
await wrapper.setProps({ modelValue: [TEST_IMAGES_SMALL[0]] })
await nextTick()
// Should revert to single mode (no grid toggle visible)
expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe(
false
)
})
})
describe('DisplayCarousel Edge Cases', () => {
it('handles empty array gracefully', () => {
const wrapper = createGalleriaWrapper([])
expect(wrapper.find('img').exists()).toBe(false)
expect(findThumbnails(wrapper)).toHaveLength(0)
})
it('filters out malformed image objects without valid src', () => {
const malformedImages = [{}, { randomProp: 'value' }, null, undefined]
const wrapper = createGalleriaWrapper(malformedImages as string[])
// All filtered out: null/undefined removed, then objects without src filtered
expect(wrapper.find('img').exists()).toBe(false)
})
it('handles very large image arrays', () => {
const largeImageArray = createImageStrings(100)
const wrapper = createGalleriaWrapper(largeImageArray)
const thumbnailButtons = findThumbnails(wrapper)
expect(thumbnailButtons).toHaveLength(100)
})
it('handles mixed string and object arrays gracefully', () => {
const mixedArray = [
'https://example.com/string.jpg',
{ itemImageSrc: 'https://example.com/object.jpg' },
'https://example.com/another-string.jpg'
]
expect(() => createGalleriaWrapper(mixedArray as string[])).not.toThrow()
})
it('handles invalid URL strings without crashing', () => {
const invalidUrls = ['not-a-url', 'http://', 'ftp://invalid']
const wrapper = createGalleriaWrapper(invalidUrls)
expect(wrapper.find('img').exists()).toBe(true)
})
it('filters out empty string URLs', () => {
const wrapper = createGalleriaWrapper([''])
expect(wrapper.find('img').exists()).toBe(false)
})
})

View File

@@ -1,408 +0,0 @@
<template>
<div
class="flex max-w-full flex-col rounded-lg bg-component-node-widget-background"
>
<!-- Single Mode -->
<template v-if="displayMode === 'single'">
<div class="flex flex-col gap-1 p-4">
<!-- Main Image Container -->
<div
ref="imageContainerEl"
class="relative flex cursor-pointer items-center justify-center"
tabindex="0"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@focusin="isFocused = true"
@focusout="handleFocusOut"
>
<img
v-if="activeItem"
:src="getItemSrc(activeItem)"
:alt="getItemAlt(activeItem, activeIndex)"
:class="
cn(
'h-auto w-full rounded-sm object-contain transition-opacity',
showControls && 'opacity-50'
)
"
@load="handleImageLoad"
/>
<!-- Toggle to Grid (hover, top-left) -->
<button
v-if="showControls && galleryImages.length > 1"
:class="toggleButtonClass"
class="absolute top-2 left-2"
:aria-label="t('g.switchToGridView')"
@click="switchToGrid"
>
<i class="icon-[lucide--layout-grid] size-4" />
</button>
<!-- Action Buttons (hover, top-right) -->
<div
v-if="showControls && activeItem"
class="absolute top-2 right-2 flex gap-1"
>
<button
:class="actionButtonClass"
:aria-label="t('g.editOrMaskImage')"
@click="handleEditMask"
>
<i-comfy:mask class="size-4" />
</button>
<button
:class="actionButtonClass"
:aria-label="t('g.downloadImage')"
@click="handleDownload"
>
<i class="icon-[lucide--arrow-down-to-line] size-4" />
</button>
<button
:class="actionButtonClass"
:aria-label="t('g.removeImage')"
@click="handleRemove"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
</div>
<!-- Image Dimensions -->
<p
:class="
cn(
'text-center text-xs text-component-node-foreground-secondary',
!imageDimensions && 'invisible'
)
"
>
{{ imageDimensions || '\u00A0' }}
</p>
<!-- Thumbnail Strip with Navigation -->
<div
v-if="showMultipleImages || showNavButtons"
class="flex items-center justify-between"
>
<!-- Previous Button -->
<button
v-if="showNavButtons"
:class="navButtonClass"
:aria-label="t('g.previousImage')"
@click="goToPrevious"
>
<i class="icon-[lucide--chevron-left] size-3.5" />
</button>
<!-- Thumbnails -->
<div
v-if="showMultipleImages"
class="flex min-w-0 flex-1 items-center gap-1 overflow-x-hidden scroll-smooth py-1"
>
<div
v-for="(item, index) in galleryImages"
:key="getItemSrc(item)"
:ref="(el) => setThumbnailRef(el as HTMLElement | null, index)"
:class="
cn(
'shrink-0 overflow-hidden rounded-lg p-1 transition-colors',
index === activeIndex
? 'border-2 border-base-foreground'
: 'border-2 border-transparent'
)
"
:aria-label="getItemAlt(item, index)"
>
<img
:src="getItemThumbnail(item)"
:alt="getItemAlt(item, index)"
class="size-10 rounded-sm object-cover"
/>
</div>
</div>
<!-- Next Button -->
<button
v-if="showNavButtons"
:class="navButtonClass"
:aria-label="t('g.nextImage')"
@click="goToNext"
>
<i class="icon-[lucide--chevron-right] size-3.5" />
</button>
</div>
</div>
</template>
<!-- Grid Mode -->
<template v-else>
<div class="p-4">
<div
ref="gridContainerEl"
class="relative h-72 overflow-x-hidden overflow-y-auto rounded-sm bg-component-node-background"
tabindex="0"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@focusin="isFocused = true"
@focusout="handleFocusOut"
>
<!-- Toggle to Single (hover, top-left) -->
<button
v-if="showControls"
:class="toggleButtonClass"
class="absolute top-2 left-2 z-10"
:aria-label="t('g.switchToSingleView')"
@click="switchToSingle"
>
<i class="icon-[lucide--square] size-4" />
</button>
<div class="flex flex-wrap content-start gap-1">
<button
v-for="(item, index) in galleryImages"
:key="getItemSrc(item)"
class="size-14 shrink-0 cursor-pointer overflow-hidden border-0 p-0"
:aria-label="getItemAlt(item, index)"
@mouseenter="hoveredGridIndex = index"
@mouseleave="hoveredGridIndex = -1"
@click="selectFromGrid(index)"
>
<img
:src="getItemThumbnail(item)"
:alt="getItemAlt(item, index)"
:class="
cn(
'size-full object-cover transition-opacity',
hoveredGridIndex === index && 'opacity-50'
)
"
/>
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
export interface GalleryImage {
itemImageSrc?: string
thumbnailImageSrc?: string
src?: string
alt?: string
}
export type GalleryValue = string[] | GalleryImage[]
type DisplayMode = 'single' | 'grid'
interface GalleryOptions {
showItemNavigators?: boolean
}
const value = defineModel<GalleryValue>({ required: true })
const { widget, nodeId } = defineProps<{
widget: SimplifiedWidget<GalleryValue>
nodeId?: string
}>()
const { t } = useI18n()
const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const activeIndex = ref(0)
const displayMode = ref<DisplayMode>('single')
const isHovered = ref(false)
const isFocused = ref(false)
const hoveredGridIndex = ref(-1)
const imageDimensions = ref<string | null>(null)
const thumbnailRefs = ref<(HTMLElement | null)[]>([])
const imageContainerEl = ref<HTMLDivElement>()
const gridContainerEl = ref<HTMLDivElement>()
const showControls = computed(() => isHovered.value || isFocused.value)
const options = computed<GalleryOptions>(() => widget.options ?? {})
const galleryImages = computed<GalleryImage[]>(() => {
if (!value.value || !Array.isArray(value.value)) return []
return value.value.flatMap((item) => {
if (item === null || item === undefined) return []
const image =
typeof item === 'string'
? { itemImageSrc: item, thumbnailImageSrc: item }
: item
return image.itemImageSrc || image.src ? [image] : []
})
})
const activeItem = computed(() => galleryImages.value[activeIndex.value])
const showMultipleImages = computed(() => galleryImages.value.length > 1)
const showNavButtons = computed(
() => options.value.showItemNavigators !== false && showMultipleImages.value
)
const actionButtonClass =
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
const toggleButtonClass = actionButtonClass
const navButtonClass =
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-secondary-background text-component-node-foreground-secondary transition-colors'
watch(galleryImages, (images) => {
thumbnailRefs.value = thumbnailRefs.value.slice(0, images.length)
imageDimensions.value = null
if (images.length === 0) {
activeIndex.value = 0
return
}
if (activeIndex.value >= images.length) {
activeIndex.value = images.length - 1
}
if (images.length <= 1) {
displayMode.value = 'single'
}
})
function getItemSrc(item: GalleryImage): string {
return item.itemImageSrc || item.src || ''
}
function getItemThumbnail(item: GalleryImage): string {
return item.thumbnailImageSrc || item.itemImageSrc || item.src || ''
}
function getItemAlt(item: GalleryImage, index: number): string {
return (
item.alt ||
t('g.viewImageOfTotal', {
index: index + 1,
total: galleryImages.value.length
})
)
}
function handleFocusOut(event: FocusEvent) {
const container =
displayMode.value === 'single'
? imageContainerEl.value
: gridContainerEl.value
if (!container?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}
function handleImageLoad(event: Event) {
if (!(event.target instanceof HTMLImageElement)) return
const { naturalWidth, naturalHeight } = event.target
if (naturalWidth && naturalHeight) {
imageDimensions.value = `${naturalWidth} x ${naturalHeight}`
}
}
function setThumbnailRef(el: HTMLElement | null, index: number) {
thumbnailRefs.value[index] = el
}
function scrollToActive() {
void nextTick(() => {
const el = thumbnailRefs.value[activeIndex.value]
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
})
}
})
}
function goToPrevious() {
activeIndex.value =
activeIndex.value > 0
? activeIndex.value - 1
: galleryImages.value.length - 1
imageDimensions.value = null
scrollToActive()
}
function goToNext() {
activeIndex.value =
activeIndex.value < galleryImages.value.length - 1
? activeIndex.value + 1
: 0
imageDimensions.value = null
scrollToActive()
}
function switchToGrid() {
isHovered.value = false
displayMode.value = 'grid'
}
function switchToSingle() {
isHovered.value = false
displayMode.value = 'single'
}
function selectFromGrid(index: number) {
activeIndex.value = index
imageDimensions.value = null
isHovered.value = false
displayMode.value = 'single'
scrollToActive()
}
function handleEditMask() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
function handleDownload() {
const src = activeItem.value ? getItemSrc(activeItem.value) : ''
if (!src) return
try {
downloadFile(src)
} catch {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
}
function handleRemove() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
nodeOutputStore.removeNodeOutputs(nodeId)
if (node) {
node.imgs = undefined
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
imageWidget.value = ''
}
}
}
</script>

View File

@@ -15,34 +15,7 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import { createMockWidget } from './widgetTestUtils'
const mockCheckState = vi.hoisted(() => vi.fn())
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
'@/platform/workflow/management/stores/workflowStore'
)
return {
...actual,
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
}
}
})
}
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
@@ -483,69 +456,3 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void
handleFilesUpdate: (files: File[]) => Promise<void>
}
const mountForUndo = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<UndoTrackingInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
}) as unknown as VueWrapper<UndoTrackingInstance>
}
beforeEach(() => {
mockCheckState.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png', 'photo_abc.jpg'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
wrapper.vm.updateSelectedItems(new Set(['input-1']))
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('calls checkState after file upload completes', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
} as Response)
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
})

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