Compare commits
46 Commits
fix/load-a
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751da03084 | ||
|
|
e6cfb868c0 | ||
|
|
37c6ddfcd9 | ||
|
|
55c42ee484 | ||
|
|
b04db536a1 | ||
|
|
975d6a360d | ||
|
|
7e137d880b | ||
|
|
8db6fb7733 | ||
|
|
7c2c59b9fb | ||
|
|
2f7f3c4e56 | ||
|
|
4c00d39ade | ||
|
|
f1fc5fa9b3 | ||
|
|
c111fb7758 | ||
|
|
65655ba35f | ||
|
|
852d77159e | ||
|
|
b61029b9da | ||
|
|
0a62ea0b2c | ||
|
|
cccc0944a0 | ||
|
|
aadec87bff | ||
|
|
f5088d6cfb | ||
|
|
fd7ce3a852 | ||
|
|
08a2f8ae15 | ||
|
|
7b9f24f515 | ||
|
|
faed80e99a | ||
|
|
b129d64c5d | ||
|
|
4c9b83a224 | ||
|
|
e973efb44a | ||
|
|
9ecb100d11 | ||
|
|
dc3e455993 | ||
|
|
76006fca52 | ||
|
|
d2792cfac6 | ||
|
|
a786825093 | ||
|
|
b0f3b69bda | ||
|
|
d11a0f6c5e | ||
|
|
f97c38e6ee | ||
|
|
e89a0f96cd | ||
|
|
12989e8b63 | ||
|
|
c084605e4d | ||
|
|
b368a865cf | ||
|
|
1d7a5b9e0b | ||
|
|
fbcd36d355 | ||
|
|
e594164b71 | ||
|
|
245f840e7c | ||
|
|
240b54419b | ||
|
|
d9020b7fbe | ||
|
|
c4272ef1da |
150
.claude/skills/backport-management/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
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)
|
||||
```
|
||||
68
.claude/skills/backport-management/reference/analysis.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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
|
||||
42
.claude/skills/backport-management/reference/discovery.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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 |
|
||||
150
.claude/skills/backport-management/reference/execution.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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.
|
||||
96
.claude/skills/backport-management/reference/logging.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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/`.
|
||||
@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
25
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -138,6 +140,29 @@ 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]
|
||||
|
||||
14
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -46,18 +46,30 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
ACTION: ${{ github.event.action }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
|
||||
run: |
|
||||
if [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
REF="${PR_HEAD_SHA}"
|
||||
BRANCH="${PR_HEAD_REF}"
|
||||
|
||||
# Derive variant from all PR labels (default to cpu for frontend-only previews)
|
||||
VARIANT="cpu"
|
||||
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
|
||||
else
|
||||
REF="${GITHUB_SHA}"
|
||||
BRANCH="${GITHUB_REF_NAME}"
|
||||
PR_NUMBER=""
|
||||
VARIANT=""
|
||||
fi
|
||||
payload="$(jq -nc \
|
||||
--arg ref "${REF}" \
|
||||
--arg branch "${BRANCH}" \
|
||||
'{ref: $ref, branch: $branch}')"
|
||||
--arg pr_number "${PR_NUMBER}" \
|
||||
--arg variant "${VARIANT}" \
|
||||
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
|
||||
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch to cloud repo
|
||||
|
||||
39
.github/workflows/cloud-dispatch-cleanup.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
|
||||
# frontend PR with a preview label is closed or has its preview label
|
||||
# removed. The cloud repo handles the actual environment teardown.
|
||||
#
|
||||
# This is fire-and-forget — it does NOT wait for the cloud workflow to
|
||||
# complete. Status is visible in the cloud repo's Actions tab.
|
||||
|
||||
name: Cloud Frontend Preview Cleanup Dispatch
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Only dispatch when:
|
||||
# - PR closed AND had a preview label
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))) ||
|
||||
(github.event.action == 'unlabeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch to cloud repo
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
|
||||
repository: Comfy-Org/cloud
|
||||
event-type: frontend-preview-cleanup
|
||||
client-payload: >-
|
||||
{"pr_number": "${{ github.event.pull_request.number }}"}
|
||||
1
.gitignore
vendored
@@ -26,6 +26,7 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
document.documentElement.classList.remove('dark-theme')
|
||||
document.body.classList.remove('dark-theme')
|
||||
}
|
||||
document.body.classList.add('[&_*]:!font-inter')
|
||||
document.body.classList.add('font-inter')
|
||||
|
||||
return Story(context.args, context)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
91
docs/architecture/change-tracker.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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
@@ -35,6 +35,7 @@ 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
|
||||
|
||||
56
index.html
@@ -20,6 +20,10 @@ const config: KnipConfig = {
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/shared-frontend-utils': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
@@ -32,7 +36,9 @@ const config: KnipConfig = {
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
'@primevue/icons',
|
||||
// Used by lucideStrokePlugin.js (CSS @plugin)
|
||||
'@iconify/utils'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated manager types
|
||||
@@ -47,7 +53,9 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
51
public/splash.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Pre-Vue splash loader — colors set by inline script */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
19
src/App.vue
@@ -2,20 +2,13 @@
|
||||
<router-view />
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -31,6 +24,16 @@ app.extensionManager = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
|
||||
watch(
|
||||
isLoading,
|
||||
(loading, prevLoading) => {
|
||||
if (prevLoading && !loading) {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
const { target } = event
|
||||
switch (true) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -42,6 +43,9 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function useAppSetDefaultView() {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
|
||||
@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
160
src/components/common/ScrubableNumberInput.stories.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref, toRefs } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
|
||||
import ScrubableNumberInput from './ScrubableNumberInput.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof ScrubableNumberInput>
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Input/Number',
|
||||
component: ScrubableNumberInput,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
disabled: { control: 'boolean' },
|
||||
hideButtons: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
hideButtons: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-60"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { min, max, step, disabled, hideButtons } = toRefs(args)
|
||||
const value = ref(42)
|
||||
return { value, min, max, step, disabled, hideButtons }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min :max :step :disabled :hideButtons />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { disabled } = toRefs(args)
|
||||
const value = ref(50)
|
||||
return { value, disabled }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :disabled />'
|
||||
})
|
||||
}
|
||||
|
||||
export const AtMinimum: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(0)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const AtMaximum: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(100)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const FloatPrecision: Story = {
|
||||
args: { min: 0, max: 1, step: 0.01 },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { min, max, step } = toRefs(args)
|
||||
const value = ref(0.75)
|
||||
return { value, min, max, step }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min :max :step display-value="0.75" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const LargeNumber: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const value = ref(1809000312992)
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const HiddenButtons: Story = {
|
||||
args: { hideButtons: true },
|
||||
render: (args) => ({
|
||||
components: { ScrubableNumberInput },
|
||||
setup() {
|
||||
const { hideButtons } = toRefs(args)
|
||||
const value = ref(42)
|
||||
return { value, hideButtons }
|
||||
},
|
||||
template:
|
||||
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :hideButtons />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithControlButton: Story = {
|
||||
render: () => ({
|
||||
components: { ScrubableNumberInput, Button, Popover },
|
||||
setup() {
|
||||
const value = ref(1809000312992)
|
||||
return { value }
|
||||
},
|
||||
template: `
|
||||
<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
|
||||
>
|
||||
<i class="icon-[lucide--shuffle] w-full text-xs text-primary-background" />
|
||||
</Button>
|
||||
</template>
|
||||
<div class="p-4 text-sm">Control popover content</div>
|
||||
</Popover>
|
||||
</ScrubableNumberInput>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -33,19 +33,20 @@
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
@keydown.up.prevent="updateValueBy(step)"
|
||||
@keydown.down.prevent="updateValueBy(-step)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
||||
/>
|
||||
<div
|
||||
ref="swipeElement"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -65,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
min = -Number.MAX_VALUE,
|
||||
max = Number.MAX_VALUE,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const swipeElement = useTemplateRef('swipeElement')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
let dragDelta = 0
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
if (isSwiping.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
onSwipeEnd: () => (dragDelta = 0)
|
||||
})
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<Button variant="secondary" @click="copySystemInfo">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copySystemInfo') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
@@ -46,6 +52,8 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
@@ -55,6 +63,8 @@ const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const systemInfo = computed(() => ({
|
||||
...props.stats.system,
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
@@ -108,7 +118,7 @@ function isOutdated(column: ColumnDef): boolean {
|
||||
return !!installed && !!required && installed !== required
|
||||
}
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
function getDisplayValue(column: ColumnDef) {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
@@ -118,4 +128,33 @@ const getDisplayValue = (column: ColumnDef) => {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSystemInfoText(): string {
|
||||
const lines: string[] = ['## System Info']
|
||||
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevices.value) {
|
||||
lines.push('')
|
||||
lines.push('## Devices')
|
||||
for (const device of props.stats.devices) {
|
||||
lines.push(`- ${device.name} (${device.type})`)
|
||||
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
|
||||
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
|
||||
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
|
||||
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function copySystemInfo() {
|
||||
copyToClipboard(formatSystemInfoText())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@@ -48,6 +49,7 @@
|
||||
<!-- Folder -->
|
||||
<div
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@@ -98,6 +100,10 @@ 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'
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
size="tall"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
@@ -318,6 +318,20 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
|
||||
>
|
||||
<template v-if="isAppTemplate(template)">
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
{{ $t('builderToolbar.app', 'App') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--workflow]" />
|
||||
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
@@ -483,6 +497,8 @@ const {
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
|
||||
153
src/components/graph/CanvasModeSelector.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,21 @@
|
||||
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" />
|
||||
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -24,31 +30,54 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<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"
|
||||
<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')"
|
||||
@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" />
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.select') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
unlockCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
<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')"
|
||||
@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" />
|
||||
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.hand') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -56,7 +85,7 @@
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -69,6 +98,8 @@ 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()
|
||||
|
||||
@@ -106,6 +137,43 @@ 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'
|
||||
|
||||
@@ -479,50 +479,53 @@ useEventListener(
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
try {
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init()
|
||||
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
|
||||
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
if (settingsError.value) {
|
||||
if (settingsError.value instanceof UnauthorizedError) {
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
throw settingsError.value
|
||||
}
|
||||
throw settingsError.value
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
|
||||
// Register core settings immediately after settings are ready
|
||||
CORE_SETTINGS.forEach(settingStore.addSetting)
|
||||
|
||||
await Promise.all([
|
||||
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
|
||||
useNewUserService().initializeIfNewUser()
|
||||
])
|
||||
if (i18nError.value) {
|
||||
console.warn(
|
||||
'[GraphCanvas] Failed to load custom nodes i18n:',
|
||||
i18nError.value
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
await comfyApp.setup(canvasRef.value)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
@@ -535,7 +538,7 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
></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
|
||||
@@ -30,7 +32,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" />
|
||||
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -44,7 +46,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" />
|
||||
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +61,7 @@
|
||||
:class="minimapButtonClass"
|
||||
@click="onMinimapToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--map] size-4" />
|
||||
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -78,7 +80,7 @@
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="onLinkVisibilityToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--route-off] size-4" />
|
||||
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useCanvasMenuPositionSync } from '@/composables/graph/useCanvasMenuPositionSync'
|
||||
import {
|
||||
registerNodeOptionsInstance,
|
||||
useMoreOptionsMenu
|
||||
@@ -52,7 +53,6 @@ import type {
|
||||
MenuOption,
|
||||
SubMenuOption
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
|
||||
@@ -67,67 +67,11 @@ const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
const { setWorldPositionFromEvent } = useCanvasMenuPositionSync(
|
||||
contextMenu,
|
||||
isOpen
|
||||
)
|
||||
|
||||
// Close on touch outside to handle mobile devices where click might be swallowed
|
||||
useEventListener(
|
||||
@@ -207,25 +151,7 @@ const menuItems = computed<ExtendedMenuItem[]>(() =>
|
||||
// Show context menu
|
||||
function show(event: MouseEvent) {
|
||||
bump()
|
||||
|
||||
// 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]
|
||||
|
||||
setWorldPositionFromEvent(event)
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
@@ -26,15 +26,18 @@ function toggle() {
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0 sm:w-min sm:min-w-0"
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0',
|
||||
isExpanded ? 'sm:w-[max(400px,40vw)]' : 'sm:w-fit'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'max-w-full min-w-0 overflow-hidden transition-all duration-300',
|
||||
isExpanded
|
||||
? 'max-h-100 w-full sm:w-[max(400px,40vw)]'
|
||||
: 'max-h-0 w-0'
|
||||
isExpanded ? 'max-h-100 w-full' : 'max-h-0 w-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,73 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
// Combine our component props with PrimeVue MultiSelect props
|
||||
// Since we use v-bind="$attrs", all PrimeVue props are available
|
||||
interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
// Our custom props
|
||||
label?: string
|
||||
showSearchBox?: boolean
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
listMaxHeight?: string
|
||||
popoverMinWidth?: string
|
||||
popoverMaxWidth?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: SelectOption[]
|
||||
}
|
||||
|
||||
const meta: Meta<ExtendedProps> = {
|
||||
title: 'Components/Input/MultiSelect',
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
title: 'Components/Select/MultiSelect',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text'
|
||||
label: { control: 'text' },
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['lg', 'md']
|
||||
},
|
||||
options: {
|
||||
control: 'object'
|
||||
},
|
||||
showSearchBox: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle searchBar visibility'
|
||||
},
|
||||
showSelectedCount: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle selected count visibility'
|
||||
},
|
||||
showClearButton: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle clear button visibility'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: 'text'
|
||||
},
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of the dropdown list'
|
||||
},
|
||||
popoverMinWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width of the popover'
|
||||
},
|
||||
popoverMaxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width of the popover'
|
||||
}
|
||||
showSearchBox: { control: 'boolean' },
|
||||
showSelectedCount: { control: 'boolean' },
|
||||
showClearButton: { control: 'boolean' },
|
||||
searchPlaceholder: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
label: 'Select',
|
||||
options: [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
],
|
||||
label: 'Category',
|
||||
size: 'lg',
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
@@ -78,352 +38,125 @@ const meta: Meta<ExtendedProps> = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions: SelectOption[] = [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = args.options || [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
]
|
||||
return { selected, options, args }
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" :label="args.label" :size="args.size" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: (args) => ({
|
||||
export const MediumSize: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = args.options || [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options, args }
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
|
||||
}),
|
||||
args: {
|
||||
label: 'Select Languages',
|
||||
options: [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const MultipleSelectors: Story = {
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0], sampleOptions[1]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
args: { showSearchBox: true },
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
])
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" />'
|
||||
})
|
||||
}
|
||||
|
||||
const projectOptions = ref([
|
||||
{ name: 'Project A', value: 'proj-a' },
|
||||
{ name: 'Project B', value: 'proj-b' },
|
||||
{ name: 'Project C', value: 'proj-c' },
|
||||
{ name: 'Project D', value: 'proj-d' }
|
||||
])
|
||||
export const AllHeaderFeatures: Story = {
|
||||
args: {
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, sampleOptions, args }
|
||||
},
|
||||
template:
|
||||
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
|
||||
})
|
||||
}
|
||||
|
||||
const tagOptions = ref([
|
||||
{ name: 'Frontend', value: 'frontend' },
|
||||
{ name: 'Backend', value: 'backend' },
|
||||
{ name: 'Database', value: 'database' },
|
||||
{ name: 'DevOps', value: 'devops' },
|
||||
{ name: 'Testing', value: 'testing' }
|
||||
])
|
||||
|
||||
const selectedFrameworks = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const selectedTags = ref([])
|
||||
|
||||
return {
|
||||
frameworkOptions,
|
||||
projectOptions,
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags,
|
||||
args
|
||||
}
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const a = ref<SelectOption[]>([])
|
||||
const b = ref<SelectOption[]>([sampleOptions[0]])
|
||||
const c = ref<SelectOption[]>([sampleOptions[0]])
|
||||
return { sampleOptions, a, b, c }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MultiSelect v-model="a" :options="sampleOptions" label="Default" />
|
||||
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" />
|
||||
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-base-background rounded">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<MultiSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
|
||||
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" size="md" />
|
||||
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" size="md" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSelectedCount: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSelectedCount: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithClearButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AllHeaderFeatures: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
searchPlaceholder: 'Filter packages...'
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`
|
||||
}))
|
||||
return { selected1, selected2, selected3, manyOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected1"
|
||||
:options="manyOptions"
|
||||
label="Small Dropdown"
|
||||
list-max-height="10rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected2"
|
||||
:options="manyOptions"
|
||||
label="Default Dropdown"
|
||||
list-max-height="28rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||
<MultiSelect
|
||||
v-model="selected3"
|
||||
:options="manyOptions"
|
||||
label="Large Dropdown"
|
||||
list-max-height="32rem"
|
||||
show-selected-count
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMinWidth: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const options = [
|
||||
{ name: 'A', value: 'a' },
|
||||
{ name: 'B', value: 'b' },
|
||||
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||
]
|
||||
return { selected1, selected2, selected3, options }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<MultiSelect v-model="selected1" :options="options" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
|
||||
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
|
||||
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxWidth: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected1 = ref([])
|
||||
const selected2 = ref([])
|
||||
const selected3 = ref([])
|
||||
const longOptions = [
|
||||
{ name: 'Short', value: 'short' },
|
||||
{
|
||||
name: 'This is a very long option name that would normally expand the dropdown',
|
||||
value: 'long1'
|
||||
},
|
||||
{
|
||||
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||
value: 'long2'
|
||||
}
|
||||
]
|
||||
return { selected1, selected2, selected3, longOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
|
||||
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
|
||||
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
controls: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,23 @@
|
||||
:pt="{
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'relative inline-flex h-10 cursor-pointer select-none',
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0
|
||||
? 'border-node-component-border'
|
||||
: 'border-transparent',
|
||||
'focus-within:border-node-component-border',
|
||||
{ 'cursor-default opacity-60': props.disabled }
|
||||
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
|
||||
'focus-within:border-base-foreground',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class:
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
|
||||
class: cn(
|
||||
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
@@ -129,12 +132,12 @@
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm">
|
||||
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
@@ -197,6 +200,8 @@ defineOptions({
|
||||
interface Props {
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show search box in the panel header */
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
@@ -216,6 +221,7 @@ interface Props {
|
||||
}
|
||||
const {
|
||||
label,
|
||||
size = 'lg',
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
|
||||
219
src/components/input/SelectDropdown.stories.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Select/SelectDropdown',
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const modelOptions: SelectOption[] = [
|
||||
{ name: 'ACE-Step', value: 'ace-step' },
|
||||
{ name: 'Anima', value: 'anima' },
|
||||
{ name: 'BRIA', value: 'bria' },
|
||||
{ name: 'ByteDance', value: 'bytedance' },
|
||||
{ name: 'Capybara', value: 'capybara' },
|
||||
{ name: 'Chatter Box', value: 'chatter-box' },
|
||||
{ name: 'Chroma', value: 'chroma' },
|
||||
{ name: 'ChronoEdit', value: 'chronoedit' },
|
||||
{ name: 'DWPose', value: 'dwpose' },
|
||||
{ name: 'Depth Anything v2', value: 'depth-anything-v2' },
|
||||
{ name: 'ElevenLabs', value: 'elevenlabs' },
|
||||
{ name: 'Flux', value: 'flux' },
|
||||
{ name: 'HunyuanVideo', value: 'hunyuan-video' },
|
||||
{ name: 'Stable Diffusion', value: 'stable-diffusion' },
|
||||
{ name: 'SDXL', value: 'sdxl' }
|
||||
]
|
||||
|
||||
const useCaseOptions: SelectOption[] = [
|
||||
{ name: 'Text to Image', value: 'text-to-image' },
|
||||
{ name: 'Image to Image', value: 'image-to-image' },
|
||||
{ name: 'Inpainting', value: 'inpainting' },
|
||||
{ name: 'Upscaling', value: 'upscaling' },
|
||||
{ name: 'Video Generation', value: 'video-generation' },
|
||||
{ name: 'Audio Generation', value: 'audio-generation' },
|
||||
{ name: '3D Generation', value: '3d-generation' }
|
||||
]
|
||||
|
||||
const sortOptions: SelectOption[] = [
|
||||
{ name: 'Default', value: 'default' },
|
||||
{ name: 'Recommended', value: 'recommended' },
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
|
||||
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
|
||||
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
|
||||
]
|
||||
|
||||
export const ModelFilter: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([
|
||||
modelOptions[1],
|
||||
modelOptions[2],
|
||||
modelOptions[3]
|
||||
])
|
||||
return { selected, modelOptions }
|
||||
},
|
||||
template: `
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="modelOptions"
|
||||
:label="selected.length === 0 ? 'Models' : selected.length === 1 ? selected[0].name : selected.length + ' Models'"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
class="w-[250px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const UseCaseFilter: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref<SelectOption[]>([])
|
||||
return { selected, useCaseOptions }
|
||||
},
|
||||
template: `
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="useCaseOptions"
|
||||
:label="selected.length === 0 ? 'Use Case' : selected.length === 1 ? selected[0].name : selected.length + ' Use Cases'"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--target]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const SortDropdown: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | undefined>('default')
|
||||
return { selected, sortOptions }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect
|
||||
v-model="selected"
|
||||
:options="sortOptions"
|
||||
label="Sort by"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const TemplateFilterBar: Story = {
|
||||
render: () => ({
|
||||
components: { MultiSelect, SingleSelect },
|
||||
setup() {
|
||||
const selectedModels = ref<SelectOption[]>([
|
||||
modelOptions[1],
|
||||
modelOptions[2],
|
||||
modelOptions[3]
|
||||
])
|
||||
const selectedUseCases = ref<SelectOption[]>([])
|
||||
const sortBy = ref<string | undefined>('default')
|
||||
|
||||
const modelLabel = () => {
|
||||
if (selectedModels.value.length === 0) return 'Models'
|
||||
if (selectedModels.value.length === 1)
|
||||
return selectedModels.value[0].name
|
||||
return selectedModels.value.length + ' Models'
|
||||
}
|
||||
const useCaseLabel = () => {
|
||||
if (selectedUseCases.value.length === 0) return 'Use Case'
|
||||
if (selectedUseCases.value.length === 1)
|
||||
return selectedUseCases.value[0].name
|
||||
return selectedUseCases.value.length + ' Use Cases'
|
||||
}
|
||||
|
||||
return {
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
sortBy,
|
||||
modelOptions,
|
||||
useCaseOptions,
|
||||
sortOptions,
|
||||
modelLabel,
|
||||
useCaseLabel
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-wrap items-center justify-between gap-2" style="min-width: 700px;">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedModels"
|
||||
:options="modelOptions"
|
||||
:label="modelLabel()"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
class="w-[250px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<MultiSelect
|
||||
v-model="selectedUseCases"
|
||||
:options="useCaseOptions"
|
||||
:label="useCaseLabel()"
|
||||
show-search-box
|
||||
show-selected-count
|
||||
show-clear-button
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--target]" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
label="Sort by"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
@@ -3,29 +3,31 @@ import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
// SingleSelect already includes options prop, so no need to extend
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
title: 'Components/Select/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="pt-4"><story /></div>'
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
options: { control: 'object' },
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of the dropdown list'
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['lg', 'md']
|
||||
},
|
||||
popoverMinWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width of the popover'
|
||||
},
|
||||
popoverMaxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width of the popover'
|
||||
}
|
||||
invalid: { control: 'boolean' },
|
||||
loading: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
label: 'Sorting Type',
|
||||
label: 'Category',
|
||||
size: 'lg',
|
||||
invalid: false,
|
||||
loading: false,
|
||||
options: [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
@@ -37,7 +39,7 @@ const meta: Meta<typeof SingleSelect> = {
|
||||
}
|
||||
|
||||
export default meta
|
||||
export type Story = StoryObj<typeof meta>
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleOptions = [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
@@ -52,205 +54,118 @@ export const Default: Story = {
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = args.options || sampleOptions
|
||||
return { selected, options, args }
|
||||
return { selected, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label" />
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="args.options" :label="args.label" :size="args.size" :invalid="args.invalid" :loading="args.loading" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const MediumSize: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SingleSelect v-model="selected" :options="sampleOptions" label="Sorting Type">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] size-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
`
|
||||
})
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Preselected: Story = {
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('newest')
|
||||
const options = sampleOptions
|
||||
return { selected, options }
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template: `
|
||||
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
|
||||
`
|
||||
})
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
export const Invalid: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const options = sampleOptions
|
||||
const a = ref<string | null>(null)
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" invalid />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>('popular')
|
||||
return { selected, sampleOptions }
|
||||
},
|
||||
template:
|
||||
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" loading />'
|
||||
}),
|
||||
parameters: { controls: { disable: true } }
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const a = ref<string | null>('popular')
|
||||
const b = ref<string | null>('popular')
|
||||
const c = ref<string | null>('az')
|
||||
return { options, a, b, c }
|
||||
const c = ref<string | null>('popular')
|
||||
const d = ref<string | null>('popular')
|
||||
const e = ref<string | null>('popular')
|
||||
return { sampleOptions, a, b, c, d, e }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="a" :options="options" label="No Icon" />
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<SingleSelect v-model="a" :options="sampleOptions" label="Default" />
|
||||
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" disabled />
|
||||
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" invalid />
|
||||
<SingleSelect v-model="d" :options="sampleOptions" label="Loading" loading />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="b" :options="options" label="With Icon">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<SingleSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
|
||||
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" size="md" disabled />
|
||||
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" size="md" invalid />
|
||||
<SingleSelect v-model="e" :options="sampleOptions" label="Loading" size="md" loading />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxHeight: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`
|
||||
}))
|
||||
return { selected, manyOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMinWidth: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected1 = ref<string | null>(null)
|
||||
const selected2 = ref<string | null>(null)
|
||||
const selected3 = ref<string | null>(null)
|
||||
const options = [
|
||||
{ name: 'A', value: 'a' },
|
||||
{ name: 'B', value: 'b' },
|
||||
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||
]
|
||||
return { selected1, selected2, selected3, options }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<SingleSelect v-model="selected1" :options="options" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
|
||||
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
|
||||
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomMaxWidth: Story = {
|
||||
render: () => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected1 = ref<string | null>(null)
|
||||
const selected2 = ref<string | null>(null)
|
||||
const selected3 = ref<string | null>(null)
|
||||
const longOptions = [
|
||||
{ name: 'Short', value: 'short' },
|
||||
{
|
||||
name: 'This is a very long option name that would normally expand the dropdown',
|
||||
value: 'long1'
|
||||
},
|
||||
{
|
||||
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||
value: 'long2'
|
||||
}
|
||||
]
|
||||
return { selected1, selected2, selected3, longOptions }
|
||||
},
|
||||
template: `
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
|
||||
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
|
||||
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true },
|
||||
slot: { disable: true }
|
||||
controls: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,26 @@
|
||||
unstyled
|
||||
:pt="{
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: [
|
||||
// container
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
class: cn(
|
||||
'relative inline-flex cursor-pointer items-center select-none',
|
||||
size === 'md' ? 'h-8' : 'h-10',
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-node-component-border',
|
||||
// disabled
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
]
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus-within:border-node-component-border',
|
||||
props.disabled &&
|
||||
'cursor-default opacity-30 hover:bg-secondary-background'
|
||||
)
|
||||
}),
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
class: cn(
|
||||
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
|
||||
size === 'md' ? 'pl-3' : 'pl-4'
|
||||
)
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
@@ -77,6 +80,8 @@
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
:aria-busy="loading || undefined"
|
||||
:aria-invalid="invalid || undefined"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
@@ -84,8 +89,16 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="loading"
|
||||
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-base-foreground"
|
||||
@@ -98,9 +111,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<!-- Trigger caret (hidden when loading) -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="icon-[lucide--chevron-down] text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
@@ -133,6 +149,9 @@ defineOptions({
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
loading = false,
|
||||
listMaxHeight = '28rem',
|
||||
popoverMinWidth,
|
||||
popoverMaxWidth
|
||||
@@ -144,6 +163,12 @@ const {
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: SelectOption[]
|
||||
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show invalid (destructive) border */
|
||||
invalid?: boolean
|
||||
/** Show loading spinner instead of chevron */
|
||||
loading?: boolean
|
||||
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||
listMaxHeight?: string
|
||||
/** Minimum width of the popover (default: auto) */
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import LogoCFillLoader from './LogoCFillLoader.vue'
|
||||
|
||||
const meta: Meta<typeof LogoCFillLoader> = {
|
||||
title: 'Components/Loader/LogoCFillLoader',
|
||||
component: LogoCFillLoader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg', 'xl']
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['yellow', 'blue', 'white', 'black']
|
||||
},
|
||||
bordered: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disableAnimation: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: { size: 'xl' }
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: { bordered: false }
|
||||
}
|
||||
|
||||
export const Static: Story = {
|
||||
args: { disableAnimation: true }
|
||||
}
|
||||
|
||||
export const BrandColors: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-12">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Yellow</span>
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Blue</span>
|
||||
<LogoCFillLoader size="lg" color="blue" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">White</span>
|
||||
<LogoCFillLoader size="lg" color="white" />
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded" style="background: white">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-600">Black</span>
|
||||
<LogoCFillLoader size="lg" color="black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-8">
|
||||
<LogoCFillLoader size="sm" color="yellow" />
|
||||
<LogoCFillLoader size="md" color="yellow" />
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
<LogoCFillLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<span role="status" :class="cn('inline-flex', colorClass)">
|
||||
<svg
|
||||
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
|
||||
:height="heightMap[size]"
|
||||
:viewBox="`0 0 ${VB_W} ${VB_H}`"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<mask :id="maskId">
|
||||
<path :d="C_PATH" fill="white" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
v-if="bordered"
|
||||
:d="C_PATH"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<g :mask="`url(#${maskId})`">
|
||||
<rect
|
||||
:class="disableAnimation ? undefined : 'c-fill-rect'"
|
||||
:x="-BLEED"
|
||||
:y="-BLEED"
|
||||
:width="VB_W + BLEED * 2"
|
||||
:height="VB_H + BLEED * 2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId, computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'black',
|
||||
bordered = true,
|
||||
disableAnimation = false
|
||||
} = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'yellow' | 'blue' | 'white' | 'black'
|
||||
bordered?: boolean
|
||||
disableAnimation?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskId = `c-mask-${useId()}`
|
||||
|
||||
const VB_W = 185
|
||||
const VB_H = 201
|
||||
const BLEED = 1
|
||||
|
||||
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
|
||||
// while the COMFY wordmark is wide (879×284), so larger heights are needed
|
||||
// for visually comparable perceived size.
|
||||
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
|
||||
const colorMap = {
|
||||
yellow: 'text-brand-yellow',
|
||||
blue: 'text-brand-blue',
|
||||
white: 'text-white',
|
||||
black: 'text-black'
|
||||
} as const
|
||||
|
||||
const colorClass = computed(() => colorMap[color])
|
||||
|
||||
const C_PATH =
|
||||
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.c-fill-rect {
|
||||
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes c-fill-up {
|
||||
0% {
|
||||
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(v-bind(BLEED) * -1px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-fill-rect {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ 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'
|
||||
@@ -36,6 +37,7 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
@@ -43,6 +45,8 @@ const { t } = useI18n()
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
@@ -118,12 +122,21 @@ 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
|
||||
hasMissingNodeSelected.value ||
|
||||
hasMissingModelSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -314,7 +327,11 @@ function handleTitleCancel() {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
<i
|
||||
v-if="tab.icon"
|
||||
aria-hidden="true"
|
||||
:class="cn(tab.icon, 'size-4')"
|
||||
/>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto">
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -32,11 +32,7 @@
|
||||
:key="group.title"
|
||||
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="
|
||||
group.type === 'missing_node' || group.type === 'swap_nodes'
|
||||
? 'lg'
|
||||
: 'default'
|
||||
"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.title, $event)"
|
||||
>
|
||||
<template #label>
|
||||
@@ -130,6 +126,14 @@
|
||||
@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>
|
||||
@@ -187,12 +191,14 @@ 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()
|
||||
@@ -211,6 +217,15 @@ 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) !==
|
||||
@@ -226,6 +241,7 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
@@ -283,6 +299,10 @@ 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) {
|
||||
|
||||
@@ -23,3 +23,4 @@ export type ErrorGroup =
|
||||
}
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
| { type: 'missing_model'; title: string; priority: number }
|
||||
|
||||
@@ -47,6 +47,13 @@ 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'
|
||||
|
||||
@@ -520,4 +527,115 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -23,6 +24,11 @@ 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
|
||||
@@ -39,6 +45,9 @@ 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[]
|
||||
@@ -231,6 +240,7 @@ 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>>({})
|
||||
@@ -559,6 +569,60 @@ 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>()
|
||||
|
||||
@@ -566,7 +630,11 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -580,7 +648,11 @@ export function useErrorGroups(
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...executionGroups]
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -615,6 +687,7 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
}"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<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"
|
||||
/>
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
class="comfyui-logo col-span-full row-span-full size-4.5"
|
||||
mode="fill"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: false
|
||||
}))
|
||||
|
||||
const mockTeamWorkspaceStore = vi.hoisted(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' },
|
||||
isInPersonalWorkspace: { value: false }
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
@@ -32,16 +44,19 @@ vi.mock('pinia', () => ({
|
||||
// Mock the useFeatureFlags composable
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: { teamWorkspacesEnabled: false }
|
||||
flags: mockFeatureFlags
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useTeamWorkspaceStore
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: vi.fn(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' }
|
||||
}))
|
||||
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
@@ -64,6 +79,16 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the WorkspaceProfilePic component
|
||||
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkspaceProfilePicMock',
|
||||
render() {
|
||||
return h('div', 'WorkspaceProfilePic')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
@@ -78,9 +103,15 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
describe('CurrentUserButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = ''
|
||||
mockTeamWorkspaceStore.initState.value = 'idle'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
|
||||
const { stubButton = true } = options ?? {}
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -99,7 +130,7 @@ describe('CurrentUserButton', () => {
|
||||
hide: vi.fn()
|
||||
}
|
||||
},
|
||||
Button: true
|
||||
...(stubButton ? { Button: true } : {})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -137,4 +168,27 @@ describe('CurrentUserButton', () => {
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows UserAvatar in personal workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('Avatar')
|
||||
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
|
||||
})
|
||||
|
||||
it('shows WorkspaceProfilePic in team workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('WorkspaceProfilePic')
|
||||
expect(wrapper.html()).not.toContain('Avatar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,15 +98,21 @@ const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
const {
|
||||
workspaceName: teamWorkspaceName,
|
||||
initState,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(useTeamWorkspaceStore())
|
||||
|
||||
const showWorkspaceSkeleton = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
|
||||
)
|
||||
const showWorkspaceIcon = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
|
||||
() =>
|
||||
isCloud &&
|
||||
teamWorkspacesEnabled.value &&
|
||||
initState.value === 'ready' &&
|
||||
!isInPersonalWorkspace.value
|
||||
)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
213
src/components/ui/search-input/SearchAutocomplete.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
v-model="modelValue"
|
||||
v-model:open="isOpen"
|
||||
ignore-filter
|
||||
:disabled
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
searchInputVariants({ size }),
|
||||
disabled && 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
@click="focus"
|
||||
>
|
||||
<Button
|
||||
v-if="modelValue"
|
||||
:class="cn('absolute', sizeConfig.clearPos)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.clear')"
|
||||
@click.stop="clearSearch"
|
||||
>
|
||||
<i :class="cn('icon-[lucide--x]', sizeConfig.icon)" />
|
||||
</Button>
|
||||
<i
|
||||
v-else-if="loading"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute icon-[lucide--loader-circle] animate-spin',
|
||||
sizeConfig.iconPos,
|
||||
sizeConfig.icon
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute',
|
||||
sizeConfig.iconPos,
|
||||
sizeConfig.icon,
|
||||
icon
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
ref="inputRef"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'size-full border-none bg-transparent outline-none',
|
||||
sizeConfig.inputPl,
|
||||
sizeConfig.inputText
|
||||
)
|
||||
"
|
||||
:placeholder="placeholderText"
|
||||
:auto-focus="autofocus"
|
||||
@compositionstart="isComposing = true"
|
||||
@compositionend="isComposing = false"
|
||||
@keydown.enter="onEnterKey"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxContent
|
||||
v-if="suggestions.length > 0"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
|
||||
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ComboboxItem
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="suggestionKey(suggestion, index)"
|
||||
:value="suggestionValue(suggestion)"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
|
||||
'data-highlighted:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@select.prevent="onSelectSuggestion(suggestion)"
|
||||
>
|
||||
<slot name="suggestion" :suggestion>
|
||||
{{ suggestionLabel(suggestion) }}
|
||||
</slot>
|
||||
</ComboboxItem>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxRoot
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SearchInputVariants } from './searchInput.variants'
|
||||
import {
|
||||
searchInputSizeConfig,
|
||||
searchInputVariants
|
||||
} from './searchInput.variants'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
icon = 'icon-[lucide--search]',
|
||||
autofocus = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
suggestions = [],
|
||||
optionLabel,
|
||||
optionKey,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
autofocus?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
size?: SearchInputVariants['size']
|
||||
suggestions?: T[]
|
||||
optionLabel?: keyof T & string
|
||||
optionKey?: keyof T & string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: T]
|
||||
}>()
|
||||
|
||||
const sizeConfig = computed(() => searchInputSizeConfig[size])
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const isComposing = ref(false)
|
||||
|
||||
function focus() {
|
||||
inputRef.value?.$el?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
|
||||
)
|
||||
|
||||
function clearSearch() {
|
||||
modelValue.value = ''
|
||||
focus()
|
||||
}
|
||||
|
||||
function getItemProperty(item: T, key: keyof T & string): string {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return String(item[key])
|
||||
}
|
||||
return String(item)
|
||||
}
|
||||
|
||||
function suggestionLabel(item: T): string {
|
||||
if (optionLabel) return getItemProperty(item, optionLabel)
|
||||
return String(item)
|
||||
}
|
||||
|
||||
function suggestionKey(item: T, index: number): string {
|
||||
if (optionKey) return getItemProperty(item, optionKey)
|
||||
return `${suggestionLabel(item)}-${index}`
|
||||
}
|
||||
|
||||
function suggestionValue(item: T): string {
|
||||
return suggestionLabel(item)
|
||||
}
|
||||
|
||||
function onSelectSuggestion(item: T) {
|
||||
modelValue.value = suggestionLabel(item)
|
||||
isOpen.value = false
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
function onEnterKey(e: KeyboardEvent) {
|
||||
if (isComposing.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => suggestions,
|
||||
(items) => {
|
||||
isOpen.value = items.length > 0 && !!modelValue.value
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
:ignore-filter="true"
|
||||
:open="false"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
>
|
||||
<ComboboxRoot :open="false" ignore-filter :disabled :class="className">
|
||||
<ComboboxAnchor
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Select from './Select.vue'
|
||||
import SelectContent from './SelectContent.vue'
|
||||
import SelectGroup from './SelectGroup.vue'
|
||||
import SelectItem from './SelectItem.vue'
|
||||
import SelectLabel from './SelectLabel.vue'
|
||||
import SelectSeparator from './SelectSeparator.vue'
|
||||
import SelectTrigger from './SelectTrigger.vue'
|
||||
import SelectValue from './SelectValue.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'Selected value'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'When true, disables the select'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
} satisfies Meta<typeof Select>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref(args.modelValue || '')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
<SelectItem value="grape">Grape</SelectItem>
|
||||
<SelectItem value="orange">Orange</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Choose an option..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('apple')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" disabled>
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithGroups: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a model type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Checkpoints</SelectLabel>
|
||||
<SelectItem value="sd15">SD 1.5</SelectItem>
|
||||
<SelectItem value="sdxl">SDXL</SelectItem>
|
||||
<SelectItem value="flux">Flux</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>LoRAs</SelectLabel>
|
||||
<SelectItem value="lora-style">Style LoRA</SelectItem>
|
||||
<SelectItem value="lora-character">Character LoRA</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other</SelectLabel>
|
||||
<SelectItem value="vae">VAE</SelectItem>
|
||||
<SelectItem value="embedding">Embedding</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Scrollable: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: `item-${i + 1}`,
|
||||
label: `Option ${i + 1}`
|
||||
}))
|
||||
return { value, items, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-32">
|
||||
<SelectValue placeholder="Small" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">A</SelectItem>
|
||||
<SelectItem value="b">B</SelectItem>
|
||||
<SelectItem value="c">C</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Full width select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend" />
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectGroupProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectLabelProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'px-3 py-2 text-xs tracking-wide text-muted-foreground uppercase',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
v-bind="restProps"
|
||||
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
|
||||
/>
|
||||
</template>
|
||||
@@ -5,24 +5,41 @@ import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
const {
|
||||
class: className,
|
||||
size = 'lg',
|
||||
invalid = false,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
SelectTriggerProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
/** Trigger size: 'lg' (40px) or 'md' (32px) */
|
||||
size?: 'lg' | 'md'
|
||||
/** Show invalid (destructive) border */
|
||||
invalid?: boolean
|
||||
}
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="restProps"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full cursor-pointer items-center justify-between select-none',
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
'flex w-full cursor-pointer items-center justify-between select-none',
|
||||
size === 'md' ? 'h-8 px-3 py-1 text-xs' : 'h-10 px-4 py-2 text-sm',
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'border-[2.5px] border-solid',
|
||||
invalid
|
||||
? 'border-destructive-background'
|
||||
: 'border-transparent focus:border-node-component-border',
|
||||
'focus:outline-none',
|
||||
'data-placeholder:text-muted-foreground',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-secondary-background',
|
||||
'[&>span]:truncate',
|
||||
className
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:label="String(badge)"
|
||||
severity="contrast"
|
||||
variant="circle"
|
||||
class="ml-auto"
|
||||
class="ml-auto min-h-5 min-w-5 px-1 text-base-background"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
90
src/composables/graph/useCanvasMenuPositionSync.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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 }
|
||||
}
|
||||
@@ -324,7 +324,8 @@ function safeWidgetMapper(
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
slotName: name !== widget.name ? widget.name : undefined
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -357,7 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in standalone mode (for asset preview)
|
||||
* Initialize viewer in standalone mode (for asset preview).
|
||||
* Creates the Load3d instance once; subsequent calls reuse it.
|
||||
*/
|
||||
const initializeStandaloneViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
@@ -366,6 +367,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (!containerRef) return
|
||||
|
||||
try {
|
||||
if (load3d) {
|
||||
await loadStandaloneModel(modelUrl)
|
||||
return
|
||||
}
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
@@ -392,6 +398,23 @@ 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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {}]
|
||||
: [input, { tooltip: `${groupIndex}` }]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
@@ -106,6 +106,13 @@ 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', {}] } }
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"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",
|
||||
@@ -93,6 +97,7 @@
|
||||
"reportIssueTooltip": "Submit the error report to Comfy Org",
|
||||
"reportSent": "Report Submitted",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"copySystemInfo": "Copy System Info",
|
||||
"copyAll": "Copy All",
|
||||
"openNewIssue": "Open New Issue",
|
||||
"showReport": "Show Report",
|
||||
@@ -339,7 +344,8 @@
|
||||
"conflicting": "Conflicting",
|
||||
"inWorkflowSection": "IN WORKFLOW",
|
||||
"allInWorkflow": "All in: {workflowName}",
|
||||
"missingNodes": "Missing Nodes"
|
||||
"missingNodes": "Missing Nodes",
|
||||
"unresolvedNodes": "Unresolved Nodes"
|
||||
},
|
||||
"infoPanelEmpty": "Click an item to see the info",
|
||||
"applyChanges": "Apply Changes",
|
||||
@@ -404,6 +410,10 @@
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All",
|
||||
"unresolvedNodes": {
|
||||
"title": "Unresolved Missing Nodes",
|
||||
"message": "The following nodes are not installed and could not be found in the registry."
|
||||
},
|
||||
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
|
||||
"packsSelected": "packs selected",
|
||||
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
|
||||
@@ -1046,6 +1056,8 @@
|
||||
"logoProviderSeparator": " & "
|
||||
},
|
||||
"graphCanvasMenu": {
|
||||
"canvasMode": "Canvas Mode",
|
||||
"canvasToolbar": "Canvas Toolbar",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetView": "Reset View",
|
||||
@@ -1949,6 +1961,7 @@
|
||||
"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",
|
||||
@@ -3181,6 +3194,7 @@
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3379,6 +3393,33 @@
|
||||
"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": {
|
||||
|
||||
@@ -348,8 +348,8 @@
|
||||
"tooltip": "The maximum number of tasks that show in the queue history."
|
||||
},
|
||||
"Comfy_Queue_QPOV2": {
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"Comfy_QueueButton_BatchCountLimit": {
|
||||
"name": "Batch count limit",
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
|
||||
import CloudTemplate from './CloudTemplate.vue'
|
||||
|
||||
onMounted(() => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
193
src/platform/missingModel/components/MissingModelCard.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
78
src/platform/missingModel/components/MissingModelCard.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<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>
|
||||
@@ -0,0 +1,113 @@
|
||||
<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>
|
||||
224
src/platform/missingModel/components/MissingModelRow.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<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>
|
||||
108
src/platform/missingModel/components/MissingModelStatusCard.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
156
src/platform/missingModel/components/MissingModelUrlInput.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<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>
|
||||
@@ -0,0 +1,516 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,393 @@
|
||||
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
|
||||
}
|
||||
}
|
||||