Compare commits

...

13 Commits

Author SHA1 Message Date
snomiao
7c0bdf5af7 refactor: migrate branch status pages from Vercel to Cloudflare Pages
Replace Vercel deployment with Cloudflare Pages using the same wrangler
deploy pattern as Storybook and Playwright CI workflows.

- Delete vercel.json, trigger-vercel-rebuild.yml, fetch-branch-artifacts.sh
- Add deploy-branch-status.yml with wrangler pages deploy
- Simplify build-pages.sh (no artifact fetching needed)
- Revert unrelated CI workflow changes

URLs: https://<branch>.comfyui-frontend-reports.pages.dev

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:29:30 +00:00
snomiao
604d9e70f6 feat: add workflow to trigger Vercel rebuild after all CI completes
- Waits for both Storybook and E2E test workflows to finish
- Only triggers single Vercel rebuild when all artifacts are ready
- Adds PR comment with preview URL when rebuild is triggered
- Prevents multiple unnecessary rebuilds

Requires VERCEL_DEPLOY_HOOK_URL secret to be configured.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 17:35:45 +00:00
snomiao
01a2ca3bcf refactor: migrate to Vercel-native deployment for branch status pages
- Remove GitHub Pages workflow (release-pages.yml)
- Add Vercel configuration (vercel.json)
- Create artifact fetcher script (scripts/fetch-branch-artifacts.sh)
  - Fetches Storybook from Cloudflare Pages
  - Downloads E2E/Vitest reports from GitHub Actions
  - Uses gh CLI for API access
- Update build script with graceful fallbacks
  - Creates placeholder pages for pending CI
  - Supports both local and Vercel environments
  - Handles missing artifacts gracefully
- Add pages:build:branch-status npm script
- Update documentation with new deployment approach

Benefits:
- Deploys immediately on every push
- Fetches artifacts on-demand during build
- Shows loading states for pending CI
- Simpler, more reliable architecture
- No complex artifact passing between GitHub Actions and Vercel

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 08:07:33 +00:00
snomiao
2a466af154 feat: add GitHub Pages deployment for development tools and reports
- Add GitHub Pages workflow (release-pages.yml) to deploy Storybook, Knip reports, and Playwright test reports
- Create .pages directory structure for GitHub Pages content
- Add build scripts for generating pages artifacts (build-pages.sh, create-playwright-index.js)
- Update CI workflows to support pages deployment and artifact handling
- Configure Knip and TypeScript for new .pages structure
- Add HTML viewers for Knip and Playwright reports

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 07:33:42 +00:00
Jin Yi
553ea63357 [refactor] Migrate SettingDialog to BaseModalLayout design system (#8270) 2026-02-12 16:27:11 +09:00
Alexander Brown
995ebc4ba4 fix: default onboardingSurveyEnabled flag to false (#8829)
Fixes race condition where the onboarding survey could appear
intermittently before the actual feature flag value is fetched from the
server.

https://claude.ai/code/session_01EpCtunck6b89gpUFfWGKmZ

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8829-fix-default-onboardingSurveyEnabled-flag-to-false-3056d73d3650819b9f4cd0530efe8f39)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 23:13:12 -08:00
AustinMroz
d282353370 Add z-index to popover component (#8823)
This fixes the inability to see the value control popover in the
properties panel.
<img width="574" height="760" alt="image"
src="https://github.com/user-attachments/assets/c2cfa00f-f9b1-4c86-abda-830fd780d3f8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8823-Add-z-index-to-popover-component-3056d73d365081e6b2dbe15517e4c4e0)
by [Unito](https://www.unito.io)
2026-02-11 21:33:05 -08:00
Simula_r
85ae0a57c3 feat: invite member upsell for single-seat plans (#8801)
## Summary

- Show an upsell dialog when single-seat plan users try to invite
members, with a banner on the members panel directing them to upgrade.
- Misc fixes for member max seat display

## Changes

- **What**: `InviteMemberUpsellDialogContent.vue`,
`MembersPanelContent.vue`, `WorkspacePanelContent.vue`

## Screenshots

<img width="2730" height="1907" alt="image"
src="https://github.com/user-attachments/assets/e39a23be-8533-4ebb-a4ae-2797fc382bc2"
/>
<img width="2730" height="1907" alt="image"
src="https://github.com/user-attachments/assets/bec55867-1088-4d3a-b308-5d5cce64c8ae"
/>



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8801-feat-invite-member-upsell-for-single-seat-plans-3046d73d365081349b09fe1d4dc572e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-11 21:15:59 -08:00
Terry Jia
0d64d503ec fix(vueNodes): propagate height to DOM widget children (#8821)
## Summary
DOM widgets using CSS background-image (e.g. KJNodes FastPreview)
collapsed to 0px height in vueNodes mode because background-image
doesn't contribute to intrinsic element size. Make WidgetDOM a flex
column container so mounted extension elements fill the available grid
row space.

## Screenshots (if applicable)
before 



https://github.com/user-attachments/assets/85de0295-1f7c-4142-ac15-1e813c823e3f


after


https://github.com/user-attachments/assets/824ab662-14cb-412d-93dd-97c0f549f992

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8821-fix-vueNodes-propagate-height-to-DOM-widget-children-3056d73d36508134926dc67336fd0d70)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-11 23:05:54 -05:00
Alexander Brown
30ef6f2b8c Fix: Disabling textarea when linked. (#8818)
## Summary

Misaligned option setting when building the SimplifiedWidget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8818-Fix-Disabling-textarea-when-linked-3056d73d365081f581a9f1322aaf60bd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-11 18:45:52 -08:00
Alexander Brown
6012341fd1 Fix: Widgets Column sizing and ProgressText Widget (#8817)
## Summary

Fixes an issue where the label/control columns for widgets were
dependent on the contents of the progress text display widget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8817-Fix-Widgets-Column-sizing-and-ProgressText-Widget-3056d73d36508141a714fe342c386eef)
by [Unito](https://www.unito.io)
2026-02-11 18:45:35 -08:00
Brian Jemilo II
a80f6d7922 Batch Drag & Drop Images (#8282)
## Summary

<!-- One sentence describing what changed and why. -->
Added feature to drag and drop multiple images into the UI and connect
them with a Batch Images node with tests to add convenience for users.
Only works with a group of images, mixing files not supported.

## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
I've updated our usage of Litegraph.createNode, honestly, that method is
pretty bad, onNodeCreated option method doesn't even return the node
created. I think I will probably go check out their repo to do a PR over
there. Anyways, I made a createNode method to avoid race conditions when
creating nodes for the paste actions. Will allow us to better
programmatically create nodes that do not have workflows that also need
to be connected to other nodes.

<!-- If this PR fixes an issue, uncomment and update the line below -->

https://www.notion.so/comfy-org/Implement-Multi-image-drag-and-drop-to-canvas-2eb6d73d36508195ad8addfc4367db10

## Screenshots (if applicable)

https://github.com/user-attachments/assets/d4155807-56e2-4e39-8ab1-16eda90f6a53

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8282-Batch-Drag-Drop-Images-2f16d73d365081c1ab31ce9da47a7be5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-02-11 17:39:41 -08:00
Johnpaul Chiwetelu
0f5aca6726 fix: set audio widget value after file upload (#8814)
## Summary

Set `audioWidget.value` after uploading an audio file so the combo
dropdown reflects the newly uploaded file.

## Changes

- **What**: Added `audioWidget.value = path` in the `uploadFile`
function before the callback call, matching the pattern used by the
image upload widget.

## Review Focus

One-line fix. The image upload widget already does this correctly in
`useImageUploadWidget.ts:86`. Without this line, the file uploads but
the dropdown doesn't update to show the selection.

Fixes #8800

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8814-fix-set-audio-widget-value-after-file-upload-3056d73d365081a0af90d4e096eb4975)
by [Unito](https://www.unito.io)
2026-02-11 17:00:28 -08:00
100 changed files with 3156 additions and 1318 deletions

View File

@@ -1,12 +1,12 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: 'CI: Tests E2E'
name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
workflow_dispatch:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -15,56 +15,65 @@ concurrency:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Setup Test Environment, build frontend but do not start server yet
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
# Upload only built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v6
# Save the entire workspace as cache for later test jobs to restore
- name: Generate cache key
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
name: frontend-dist
path: dist/
retention-days: 1
path: .
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
# Sharded chromium tests
playwright-tests-chromium-sharded:
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v7
# download built frontend repo from setup job
- name: Wait for cache propagation
run: sleep 10
- name: Restore cached setup
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with:
name: frontend-dist
path: dist/
fail-on-cache-miss: true
path: .
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Install frontend deps
run: pnpm install --frozen-lockfile
# Run sharded tests (browsers pre-installed in container)
# Run sharded tests and upload sharded reports
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
@@ -72,7 +81,7 @@ jobs:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
- name: Upload blob report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-chromium-${{ matrix.shardIndex }}
@@ -84,70 +93,69 @@ jobs:
timeout-minutes: 15
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
strategy:
fail-fast: false
matrix:
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v7
# download built frontend repo from setup job
- name: Wait for cache propagation
run: sleep 10
- name: Restore cached setup
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with:
name: frontend-dist
path: dist/
fail-on-cache-miss: true
path: .
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Install frontend deps
run: pnpm install --frozen-lockfile
# Run tests (browsers pre-installed in container)
# Run tests and upload reports
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
- name: Generate HTML and JSON reports
if: always()
run: |
# Generate HTML report from blob
pnpm exec playwright merge-reports --reporter=html ./blob-report
# Generate JSON report separately with explicit output path
# Run tests with both HTML and JSON reporters
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./blob-report
pnpm exec playwright test --project=${{ matrix.browser }} \
--reporter=list \
--reporter=html \
--reporter=json
- name: Upload Playwright report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.browser }}
path: ./playwright-report/
retention-days: 30
# Merge sharded test reports (no container needed - only runs CLI)
# Merge sharded test reports
merge-reports:
needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Checkout repository
uses: actions/checkout@v5
# Setup Test Environment, we only need playwright to merge reports
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Download blob reports
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: ./all-blob-reports
pattern: blob-report-chromium-*
@@ -156,13 +164,13 @@ jobs:
- name: Merge into HTML Report
run: |
# Generate HTML report
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: playwright-report-chromium
path: ./playwright-report/
@@ -180,7 +188,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Get start time
id: start-time
@@ -207,10 +215,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download all playwright reports
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports
@@ -220,7 +228,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \

View File

@@ -1,9 +1,10 @@
# Description: Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
name: 'CI: Tests Storybook'
name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on:
workflow_dispatch: # Allow manual triggering
pull_request:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
@@ -14,8 +15,8 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -36,10 +37,21 @@ jobs:
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm build-storybook
@@ -58,7 +70,7 @@ jobs:
- name: Upload Storybook build
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook-static/
@@ -75,16 +87,27 @@ jobs:
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for Chromatic baseline
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook and run Chromatic
id: chromatic
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
@@ -114,18 +137,18 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
- name: Deploy Storybook and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -148,30 +171,30 @@ jobs:
pull-requests: write
steps:
- name: Update comment with Chromatic URLs
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
// Find the existing Storybook comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const storybookComment = comments.find(comment =>
const storybookComment = comments.find(comment =>
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
);
if (storybookComment && buildUrl && storybookUrl) {
// Append Chromatic info to existing comment
const updatedBody = storybookComment.body.replace(
/---\n(.*)$/s,
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,

View File

@@ -0,0 +1,122 @@
name: 'Deploy: Branch Status Pages'
on:
workflow_run:
workflows:
- 'CI: Tests Storybook'
- 'CI: Tests E2E'
types:
- completed
branches-ignore:
- main
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-branch-status-${{ github.event.workflow_run.head_branch || github.ref_name }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy to Cloudflare Pages
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(github.event.workflow_run.conclusion == 'success')
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build status pages
run: pnpm pages:build
- name: Install wrangler
run: npm install -g wrangler@^4.0.0
- name: Deploy to Cloudflare Pages
id: deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
BRANCH="${{ github.event.workflow_run.head_branch || github.ref_name }}"
CF_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Deploying branch status pages for: $BRANCH (cf: $CF_BRANCH)"
OUTPUT=$(wrangler pages deploy .pages-dist \
--project-name="comfyui-frontend-reports" \
--branch="$CF_BRANCH" 2>&1) || {
echo "Deployment failed: $OUTPUT"
exit 1
}
URL=$(echo "$OUTPUT" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
URL="${URL:-https://${CF_BRANCH}.comfyui-frontend-reports.pages.dev}"
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "Deployed to: $URL"
- name: Find PR number
id: pr
if: github.event_name == 'workflow_run'
uses: actions/github-script@v7
with:
script: |
const branch = '${{ steps.deploy.outputs.branch }}';
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${branch}`,
state: 'open'
});
return prs.length > 0 ? prs[0].number : null;
- name: Comment on PR
if: steps.pr.outputs.result && steps.pr.outputs.result != 'null'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- BRANCH_STATUS_DEPLOY -->';
const prNumber = ${{ steps.pr.outputs.result }};
const url = '${{ steps.deploy.outputs.url }}';
const body = `${marker}
**Branch Status Page** deployed: ${url}
Includes: Storybook links, Nx graph, Knip report, test report links`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
}

10
.gitignore vendored
View File

@@ -18,7 +18,6 @@ yarn.lock
.stylelintcache
node_modules
.pnpm-store
dist
dist-ssr
*.local
@@ -79,7 +78,7 @@ templates_repo/
vite.config.mts.timestamp-*.mjs
# Linux core dumps
/core
core
*storybook.log
storybook-static
@@ -94,7 +93,6 @@ storybook-static
vite.config.*.timestamp*
vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
# Generated reports in .pages (exclude generated, keep HTML templates)
/.pages/*/**/*
/.pages-dist/

54
.pages/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Branch Status Pages
This directory contains the source for branch status pages deployed to **Cloudflare Pages**.
## Architecture
```
Push to Branch
|
+-> GitHub Actions (runs tests, deploys artifacts)
| +-> Storybook -> comfy-storybook.pages.dev
| +-> E2E Tests -> comfyui-playwright-*.pages.dev
| +-> Branch Status -> comfyui-frontend-reports.pages.dev
|
+-> deploy-branch-status.yml
|
Runs: pnpm pages:build
|
Deploys to Cloudflare Pages via wrangler
```
## URLs
- **Production** (main branch): `https://comfyui-frontend-reports.pages.dev`
- **Preview** (PR branches): `https://<branch>.comfyui-frontend-reports.pages.dev`
## What's Included
1. **Storybook** - Links to Cloudflare Pages deployment
2. **Nx Dependency Graph** - Generated during build
3. **Playwright Reports** - Links to Cloudflare Pages deployments
4. **Vitest Reports** - Links to CI artifacts
5. **Coverage Report** - Links to CI artifacts
6. **Knip Report** - Generated fresh during build
## Local Development
```bash
pnpm pages:dev # Dev server
pnpm pages:build # Build
```
## Secrets
Required in GitHub Actions:
- `CLOUDFLARE_API_TOKEN`
- `CLOUDFLARE_ACCOUNT_ID`
## Files
- **index.html** - Landing page with links to all reports
- **vite.config.ts** - Vite config for building the static site
- **knip.html** - Knip markdown report viewer
- **playwright-reports.html** - E2E test report viewer

211
.pages/index.html Normal file
View File

@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComfyUI Frontend - Development Tools</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #52b2bb 100%);
}
.container {
max-width: 1200px;
width: 100%;
}
.header {
text-align: center;
color: white;
margin-bottom: 3rem;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 700;
}
.header p {
font-size: 1.125rem;
opacity: 0.9;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
p,h1,h2,h3,h4,h5,h6 {
transform: matrix(1, 0, -0.25, 1, 0, 0);
}
.card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: inherit;
display: block;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15);
}
.card-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #1a202c;
}
.card-description {
color: #718096;
line-height: 1.6;
}
.card-status {
display: inline-block;
margin-top: 1rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.status-available {
background: #c6f6d5;
color: #22543d;
}
.status-loading {
background: #e9d8fd;
color: #44337a;
}
.status-unavailable {
background: #fed7d7;
color: #742a2a;
}
.footer {
text-align: center;
color: white;
margin-top: 3rem;
opacity: 0.8;
}
.footer a {
color: white;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎨 ComfyUI Frontend</h1>
<p>Development Tools & Documentation</p>
</div>
<div class="grid">
<a href="./storybook/index.html" class="card" data-status="storybook" data-fetch="./storybook/index.html">
<div class="card-icon">📚</div>
<h2 class="card-title">Storybook</h2>
<p class="card-description">Interactive component library and design system documentation</p>
<span class="card-status status-loading" data-status-indicator>Checking…</span>
</a>
<a href="./nx-graph/index.html" class="card" data-status="nx-graph" data-fetch="./nx-graph/index.html">
<div class="card-icon">🔗</div>
<h2 class="card-title">Nx Dependency Graph</h2>
<p class="card-description">Visual representation of project dependencies and build structure</p>
<span class="card-status status-loading" data-status-indicator>Checking…</span>
</a>
<a href="./coverage/index.html" class="card" data-status="coverage" data-fetch="./coverage/index.html">
<div class="card-icon">📊</div>
<h2 class="card-title">Test Coverage</h2>
<p class="card-description">Code coverage reports from Vitest unit tests</p>
<span class="card-status status-loading" data-status-indicator>Checking…</span>
</a>
<a href="./playwright-reports/index.html" class="card" data-status="playwright" data-fetch="./playwright-reports/index.html">
<div class="card-icon">🎭</div>
<h2 class="card-title">Playwright E2E</h2>
<p class="card-description">Browser end-to-end test reports generated by Playwright</p>
<span class="card-status status-loading" data-status-indicator>Checking…</span>
</a>
<a href="./vitest-reports/index.html" class="card" data-status="vitest-reports" data-fetch="./vitest-reports/index.html">
<div class="card-icon">🧪</div>
<h2 class="card-title">Vitest Results</h2>
<p class="card-description">Interactive test results and reports</p>
<span class="card-status status-loading" data-status-indicator>Checking…</span>
</a>
<a href="./knip.html" class="card" data-status="knip" data-fetch="./knip/report.md">
<div class="card-icon">🔍</div>
<h2 class="card-title">Knip Report</h2>
<p class="card-description">Unused code and dependency analysis</p>
<span class="card-status status-loading" data-status-indicator>Checking…</span>
</a>
</div>
<div class="footer">
<p>
Built from the <strong>main</strong> branch &bull;
<a href="https://github.com/Comfy-Org/ComfyUI_frontend" target="_blank">GitHub Repository</a> &bull;
<a href="https://docs.comfy.org" target="_blank">Official Documentation</a>
</p>
</div>
</div>
<script>
const cards = Array.from(document.querySelectorAll('.card[data-status]'))
const setStatus = (card, variant, text) => {
const indicator = card.querySelector('[data-status-indicator]')
if (!indicator) return
indicator.classList.remove('status-loading', 'status-available', 'status-unavailable')
indicator.classList.add(`status-${variant}`)
indicator.textContent = text
}
const absoluteUrl = (href) => new URL(href, window.location.href).toString()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
cards.forEach((card) => {
const href = card.dataset.fetch || card.getAttribute('href')
if (!href) {
setStatus(card, 'unavailable', 'No link configured')
return
}
fetch(absoluteUrl(href), { cache: 'no-store', signal: controller.signal })
.then((response) => {
if (response.ok) {
setStatus(card, 'available', 'Available')
} else {
setStatus(card, 'unavailable', `Unavailable (${response.status})`)
}
})
.catch((error) => {
const reason = error.name === 'AbortError' ? 'Timed out' : 'Unavailable'
setStatus(card, 'unavailable', reason)
})
})
window.addEventListener('beforeunload', () => {
clearTimeout(timeout)
controller.abort()
})
</script>
</body>
</html>

87
.pages/knip.html Normal file
View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Knip Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
padding: 2rem;
background: #1a1a1a;
color: #f0f0f0;
line-height: 1.6;
}
h1 {
color: #4a9eff;
border-bottom: 3px solid #4a9eff;
padding-bottom: 1rem;
}
.status {
display: inline-block;
padding: 0.5rem 1rem;
margin: 1rem 0;
border-radius: 6px;
font-weight: 500;
}
.error {
background: #d32f2f;
color: #ffebee;
}
.loading {
background: #f57c00;
color: #fff3e0;
}
#content {
background: #2a2a2a;
padding: 1rem;
border-radius: 8px;
border: 1px solid #404040;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<h1>🧹 Knip Code Quality Report</h1>
<div id="status" class="status loading">Loading report...</div>
<div id="content"></div>
<script>
async function loadReport() {
const statusEl = document.getElementById('status')
const contentEl = document.getElementById('content')
try {
const response = await fetch('./knip/report.md')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reportText = await response.text()
statusEl.style.display = 'none'
// Wait for marked to be available
if (typeof marked !== 'undefined') {
contentEl.innerHTML = marked.parse(reportText)
} else {
contentEl.innerHTML = `<pre>${reportText}</pre>`
}
} catch (error) {
statusEl.className = 'status error'
statusEl.textContent = `Failed to load report: ${error.message}`
contentEl.innerHTML = '<p>The Knip report could not be loaded. This might happen if:</p><ul><li>The report generation failed during build</li><li>No unused code was detected</li><li>Network connectivity issues</li></ul>'
}
}
// Wait for marked library to load before running
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadReport)
} else {
loadReport()
}
</script>
</body>
</html>

View File

@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright E2E Test Reports</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 3rem;
font-size: 2.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.status {
display: inline-block;
padding: 0.5rem 1rem;
margin: 1rem auto;
border-radius: 6px;
font-weight: 500;
text-align: center;
width: 100%;
max-width: 400px;
display: block;
}
.error {
background: #d32f2f;
color: #ffebee;
}
.loading {
background: #f57c00;
color: #fff3e0;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: inherit;
display: block;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f0f0f0;
}
.card h2 {
color: #333;
font-size: 1.5rem;
text-transform: capitalize;
}
.pass-rate {
background: #10b981;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: bold;
font-size: 1.1rem;
}
.pass-rate.has-failures {
background: #ef4444;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat .label {
font-size: 0.875rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat .value {
font-size: 1.75rem;
font-weight: bold;
}
.stat.passed .value {
color: #10b981;
}
.stat.failed .value {
color: #ef4444;
}
.stat.skipped .value {
color: #f59e0b;
}
.stat.flaky .value {
color: #8b5cf6;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.cards-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🎭 Playwright E2E Test Reports</h1>
<div id="status" class="status loading">Loading reports...</div>
<div id="content" class="cards-grid"></div>
</div>
<script>
async function getTestStats(browserName) {
try {
const reportJsonPath = `./playwright-reports/${browserName}/report.json`
const response = await fetch(reportJsonPath)
if (!response.ok) {
console.warn(`No report.json found for ${browserName}`)
return null
}
const reportData = await response.json()
let passed = 0
let failed = 0
let skipped = 0
let flaky = 0
// Parse Playwright JSON report format
if (reportData.suites) {
const countResults = (suites) => {
for (const suite of suites) {
if (suite.specs) {
for (const spec of suite.specs) {
if (!spec.tests || spec.tests.length === 0) continue
const test = spec.tests[0]
const results = test.results || []
// Check if test is flaky (has both pass and fail results)
const hasPass = results.some((r) => r.status === 'passed')
const hasFail = results.some((r) => r.status === 'failed')
if (hasPass && hasFail) {
flaky++
} else if (results.some((r) => r.status === 'passed')) {
passed++
} else if (results.some((r) => r.status === 'failed')) {
failed++
} else if (results.some((r) => r.status === 'skipped')) {
skipped++
}
}
}
if (suite.suites) {
countResults(suite.suites)
}
}
}
countResults(reportData.suites)
}
return { passed, failed, skipped, flaky }
} catch (error) {
console.error(`Error reading report for ${browserName}:`, error.message)
return null
}
}
function createCard(browserName, stats) {
if (!stats) return ''
const total = stats.passed + stats.failed + stats.skipped + stats.flaky
const passRate = total > 0 ? ((stats.passed / total) * 100).toFixed(1) : 0
return `
<a href="./playwright-reports/${browserName}/index.html" class="card">
<div class="card-header">
<h2>${browserName}</h2>
<span class="pass-rate ${stats.failed > 0 ? 'has-failures' : ''}">${passRate}%</span>
</div>
<div class="stats">
<div class="stat passed">
<span class="label">Passed</span>
<span class="value">${stats.passed}</span>
</div>
<div class="stat failed">
<span class="label">Failed</span>
<span class="value">${stats.failed}</span>
</div>
<div class="stat skipped">
<span class="label">Skipped</span>
<span class="value">${stats.skipped}</span>
</div>
<div class="stat flaky">
<span class="label">Flaky</span>
<span class="value">${stats.flaky}</span>
</div>
</div>
</a>
`
}
async function loadReports() {
const statusEl = document.getElementById('status')
const contentEl = document.getElementById('content')
try {
// Known browser configurations from the workflow
const browsers = ['chromium', 'chromium-2x', 'chromium-0.5x', 'mobile-chrome']
const cards = []
for (const browser of browsers) {
const stats = await getTestStats(browser)
if (stats) {
cards.push(createCard(browser, stats))
console.log(`✓ Found report for ${browser}:`, stats)
}
}
if (cards.length === 0) {
throw new Error('No valid browser reports found')
}
statusEl.style.display = 'none'
contentEl.innerHTML = cards.join('')
} catch (error) {
statusEl.className = 'status error'
statusEl.textContent = `Failed to load reports: ${error.message}`
contentEl.innerHTML = '<p style="color: white; text-align: center;">The Playwright reports could not be loaded. This might happen if:</p><ul style="color: white; text-align: center; list-style: none;"><li>The report generation failed during build</li><li>No test reports are available yet</li><li>Network connectivity issues</li></ul>'
}
}
// Load reports when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadReports)
} else {
loadReports()
}
</script>
</body>
</html>

51
.pages/vite.config.ts Normal file
View File

@@ -0,0 +1,51 @@
import fs from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
const rootDir = __dirname
const outDir = resolve(rootDir, '../.pages-dist')
const discoverHtmlEntries = () => {
const entries = new Map<string, string>()
const topLevel = resolve(rootDir, 'index.html')
if (fs.existsSync(topLevel)) entries.set('index', topLevel)
// Directories with pre-built assets (e.g. nx-graph) are copied directly
// in build-pages.sh to avoid CSS minification issues
const skipDirs = new Set(['nx-graph'])
for (const dirent of fs.readdirSync(rootDir, { withFileTypes: true })) {
if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue
if (skipDirs.has(dirent.name)) continue
const candidate = resolve(rootDir, dirent.name, 'index.html')
if (fs.existsSync(candidate)) entries.set(dirent.name, candidate)
}
return entries.size > 0 ? Object.fromEntries(entries) : undefined
}
export default defineConfig({
root: rootDir,
base: '/',
appType: 'mpa',
logLevel: 'info',
publicDir: false,
server: {
open: '/index.html',
fs: {
allow: [rootDir],
strict: false
}
},
preview: {
open: '/index.html'
},
build: {
emptyOutDir: false,
outDir,
copyPublicDir: false,
rollupOptions: {
input: discoverHtmlEntries()
}
}
})

View File

@@ -100,8 +100,7 @@ const config: StorybookConfig = {
rolldownOptions: {
treeshake: false,
output: {
keepNames: true,
strictExecutionOrder: true
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').fill(value)
}
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').click()
}
get searchBox() {
return this.root.getByPlaceholder(/Search/)
}
get categories() {
return this.root.locator('nav').getByRole('button')
}
category(name: string) {
return this.root.locator('nav').getByRole('button', { name })
}
get contentArea() {
return this.root.getByRole('main')
}
async goToAboutPanel() {
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
await this.page
.getByTestId(TestIds.dialogs.about)
.waitFor({ state: 'visible' })
const aboutButton = this.root.locator('nav').getByRole('button', {
name: 'About'
})
await aboutButton.click()
await this.page.waitForSelector('.about-container')
}
}

View File

@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
})

View File

@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsContent = comfyPage.page.locator('.settings-content')
await expect(settingsContent).toBeVisible()
const isUsableHeight = await settingsContent.evaluate(
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate(
(el) => el.clientHeight > 30
)
expect(isUsableHeight).toBeTruthy()
@@ -256,7 +260,9 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator('.settings-container')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
test('Should persist keybinding setting', async ({ comfyPage }) => {
// Open the settings dialog
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
// Open the keybinding tab
await comfyPage.page.getByLabel('Keybinding').click()
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })
.click()
await comfyPage.page.waitForSelector(
'[placeholder="Search Keybindings..."]'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -826,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
@@ -836,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
comfyPage.page.locator('[data-testid="settings-dialog"]')
).not.toBeVisible()
// Should still be in subgraph

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -22,7 +22,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
name: 'TestSettingsExtension',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'TestHiddenSetting' as TestSettingId,
name: 'Test Hidden Setting',
type: 'hidden',
@@ -30,7 +29,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Hidden']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestDeprecatedSetting' as TestSettingId,
name: 'Test Deprecated Setting',
type: 'text',
@@ -39,7 +37,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Deprecated']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestVisibleSetting' as TestSettingId,
name: 'Test Visible Setting',
type: 'text',
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
})
test('can open settings dialog and use search box', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await expect(searchBox).toBeVisible()
// Verify search box has the correct placeholder
await expect(searchBox).toHaveAttribute(
await expect(dialog.searchBox).toHaveAttribute(
'placeholder',
expect.stringContaining('Search')
)
})
test('search box is functional and accepts input', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Comfy')
// Verify the input was accepted
await expect(searchBox).toHaveValue('Comfy')
await dialog.searchBox.fill('Comfy')
await expect(dialog.searchBox).toHaveValue('Comfy')
})
test('search box clears properly', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('test')
await expect(searchBox).toHaveValue('test')
await dialog.searchBox.fill('test')
await expect(dialog.searchBox).toHaveValue('test')
// Clear the search box
await searchBox.clear()
await expect(searchBox).toHaveValue('')
await dialog.searchBox.clear()
await expect(dialog.searchBox).toHaveValue('')
})
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Check that the sidebar has categories
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
expect(await categories.count()).toBeGreaterThan(0)
// Check that at least one category is visible
await expect(categories.first()).toBeVisible()
expect(await dialog.categories.count()).toBeGreaterThan(0)
})
test('can select different categories in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Click on a specific category (Appearance) to verify category switching
const appearanceCategory = comfyPage.page.getByRole('option', {
name: 'Appearance'
})
await appearanceCategory.click()
const categoryCount = await dialog.categories.count()
// Verify the category is selected
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
})
if (categoryCount > 1) {
await dialog.categories.nth(1).click()
test('settings content area is visible', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the content area is visible
const contentArea = comfyPage.page.locator('.settings-content')
await expect(contentArea).toBeVisible()
// Check that tab panels are visible
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
await expect(tabPanels).toBeVisible()
await expect(dialog.categories.nth(1)).toHaveClass(
/bg-interface-menu-component-surface-selected/
)
}
})
test('search functionality affects UI state', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
// Type in search box
await searchBox.fill('graph')
// Verify that the search input is handled
await expect(searchBox).toHaveValue('graph')
await dialog.searchBox.fill('graph')
await expect(dialog.searchBox).toHaveValue('graph')
})
test('settings dialog can be closed', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Close with escape key
await comfyPage.page.keyboard.press('Escape')
// Verify dialog is closed
await expect(settingsDialog).not.toBeVisible()
await expect(dialog.root).not.toBeVisible()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Type rapidly in search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('a')
await searchBox.fill('ab')
await searchBox.fill('abc')
await searchBox.fill('abcd')
await dialog.searchBox.fill('a')
await dialog.searchBox.fill('ab')
await dialog.searchBox.fill('abc')
await dialog.searchBox.fill('abcd')
// Verify final value
await expect(searchBox).toHaveValue('abcd')
await expect(dialog.searchBox).toHaveValue('abcd')
})
test('search excludes hidden settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not hidden setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
})
test('search excludes deprecated settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not deprecated setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
})
test('search shows visible settings but excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should only show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
// Should not show hidden or deprecated settings
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
})
test('search by setting name excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
const searchBox = comfyPage.page.locator('.settings-search-box input')
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Hidden')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
// Search specifically for hidden setting by name
await searchBox.clear()
await searchBox.fill('Hidden')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Deprecated')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
// Should not show the hidden setting even when searching by name
await expect(settingsContent).not.toContainText('Test Hidden Setting')
// Search specifically for deprecated setting by name
await searchBox.clear()
await searchBox.fill('Deprecated')
// Should not show the deprecated setting even when searching by name
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
// Search for visible setting by name - should work
await searchBox.clear()
await searchBox.fill('Visible')
// Should show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Visible')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
})
})

View File

@@ -8,15 +8,10 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
@@ -27,19 +22,49 @@ const config: KnipConfig = {
ignoreBinaries: ['python3', 'gh'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Unused but kept for potential future use
'@sparkjsdev/spark',
'wwobjloader2',
'@iconify-json/lucide',
'yjs'
],
ignore: [
// Auto generated manager types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
'src/scripts/ui/components/splitButton.ts',
'.pages/vite.config.ts',
// Utility files with exports that may be used by extensions or future features
'src/constants/uvMirrors.ts',
'src/lib/litegraph/src/measure.ts',
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts',
'src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts',
'src/utils/electronMirrorCheck.ts',
'src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts',
'src/types/spatialIndex.ts',
'src/lib/litegraph/src/litegraph.ts',
'src/utils/vintageClipboard.ts',
'src/platform/auth/session/useSessionCookie.ts',
'src/platform/support/config.ts',
'src/renderer/utils/nodeTypeGuards.ts',
'src/schemas/nodeDefSchema.ts',
'src/scripts/defaultGraph.ts',
'src/scripts/ui.ts',
'src/scripts/widgets.ts',
'src/services/litegraphService.ts',
'src/storybook/mocks/useJobActions.ts',
'src/storybook/mocks/useJobList.ts',
'src/utils/executableGroupNodeDto.ts',
'src/utils/litegraphUtil.ts',
'src/lib/litegraph/src/LGraph.ts',
'src/schemas/apiSchema.ts',
'src/schemas/nodeDef/nodeDefSchemaV2.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -36,6 +36,8 @@
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"pages:build": "bash scripts/build-pages.sh && vite build --config ./.pages/vite.config.ts && cp -r .pages/nx-graph .pages-dist/nx-graph 2>/dev/null || true",
"pages:dev": "vite --config ./.pages/vite.config.ts",
"preinstall": "pnpm dlx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",

152
scripts/build-pages.sh Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bash
set -Eeo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "======================================================================"
echo "Building Branch Status Pages"
echo "======================================================================"
echo ""
# Helper function to create placeholder HTML
create_placeholder() {
local dir="$1"
local title="$2"
local message="$3"
mkdir -p "$dir"
cat > "$dir/index.html" <<EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 1rem;
backdrop-filter: blur(10px);
max-width: 600px;
}
h1 { margin-top: 0; }
</style>
</head>
<body>
<div class="container">
<h1>$title</h1>
<p>$message</p>
</div>
</body>
</html>
EOF
}
# ============================================================================
# Storybook (deployed separately to Cloudflare Pages)
# ============================================================================
echo "[build-pages] Setting up Storybook"
rm -rf ".pages/storybook"
if [ -d "./storybook-static" ] && [ "$(find ./storybook-static -name '*.html' 2>/dev/null | wc -l)" -gt 0 ]; then
echo " Using local Storybook build"
cp -r "./storybook-static" ".pages/storybook"
else
echo " Creating placeholder (Storybook deployed separately to comfy-storybook.pages.dev)"
create_placeholder ".pages/storybook" "Storybook" \
"Storybook is deployed separately. Check the PR comments for the Cloudflare Pages link."
fi
# ============================================================================
# Nx Dependency Graph
# ============================================================================
echo "[build-pages] Generating Nx dependency graph"
rm -rf ".pages/nx-graph" && mkdir -p ".pages/nx-graph"
if pnpm nx graph --file=".pages/nx-graph/index.html" 2>/dev/null; then
echo " Nx graph generated"
else
echo " Nx graph generation failed, creating placeholder"
create_placeholder ".pages/nx-graph" "Nx Dependency Graph" \
"Graph generation is not available in this environment."
fi
# ============================================================================
# Playwright E2E Test Reports (deployed separately to Cloudflare Pages)
# ============================================================================
echo "[build-pages] Setting up Playwright test reports"
rm -rf ".pages/playwright-reports" && mkdir -p ".pages/playwright-reports"
if [ -d "./playwright-report" ] && [ "$(find ./playwright-report -name '*.html' 2>/dev/null | wc -l)" -gt 0 ]; then
echo " Using local Playwright reports"
cp -r "./playwright-report/"* ".pages/playwright-reports/" 2>/dev/null || true
else
echo " Creating placeholder (Playwright reports deployed separately)"
create_placeholder ".pages/playwright-reports" "E2E Test Reports" \
"Playwright reports are deployed separately. Check the PR comments for Cloudflare Pages links."
fi
# ============================================================================
# Vitest Test Reports
# ============================================================================
echo "[build-pages] Setting up Vitest test reports"
rm -rf ".pages/vitest-reports" && mkdir -p ".pages/vitest-reports"
create_placeholder ".pages/vitest-reports" "Vitest Test Reports" \
"Unit test results are available in CI. Check the GitHub Actions workflow run."
# ============================================================================
# Coverage Report
# ============================================================================
echo "[build-pages] Setting up coverage report"
mkdir -p ".pages/coverage"
create_placeholder ".pages/coverage" "Coverage Report" \
"Code coverage is generated in CI. Check the GitHub Actions workflow run."
# ============================================================================
# Knip Report (Fast - generate fresh)
# ============================================================================
echo "[build-pages] Generating Knip report"
mkdir -p ".pages/knip"
rm -f ".pages/knip/report.md"
if pnpm knip --reporter markdown --no-progress --no-exit-code > ".pages/knip/report.md" 2>/dev/null && [ -s ".pages/knip/report.md" ]; then
echo " Knip report generated"
else
echo " Knip report failed, creating placeholder"
cat > ".pages/knip/report.md" <<'EOF'
# Knip Report
> Knip report generation failed
Knip analysis could not be completed in this build environment.
Please check the CI logs for details.
EOF
fi
# ============================================================================
# Summary
# ============================================================================
echo ""
echo "======================================================================"
echo "Build Complete"
echo "======================================================================"
echo ""
echo "Generated artifacts in ./.pages:"
echo ""
ls -lh ".pages" 2>/dev/null | tail -n +2 | awk '{print " " $9}'
echo ""

View File

@@ -0,0 +1,290 @@
#!/usr/bin/env node
/**
* Creates an index page for Playwright test reports with test statistics
* Reads JSON reports from each browser and creates a landing page with cards
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const reportsDir = path.join(__dirname, '..', '.pages', 'playwright-reports')
function getTestStats(reportPath) {
try {
const reportJsonPath = path.join(reportPath, 'report.json')
if (!fs.existsSync(reportJsonPath)) {
console.warn(`No report.json found at ${reportJsonPath}`)
return null
}
const reportData = JSON.parse(fs.readFileSync(reportJsonPath, 'utf-8'))
let passed = 0
let failed = 0
let skipped = 0
let flaky = 0
// Parse Playwright JSON report format
if (reportData.suites) {
const countResults = (suites) => {
for (const suite of suites) {
if (suite.specs) {
for (const spec of suite.specs) {
if (!spec.tests || spec.tests.length === 0) continue
const test = spec.tests[0]
const results = test.results || []
// Check if test is flaky (has both pass and fail results)
const hasPass = results.some((r) => r.status === 'passed')
const hasFail = results.some((r) => r.status === 'failed')
if (hasPass && hasFail) {
flaky++
} else if (results.some((r) => r.status === 'passed')) {
passed++
} else if (results.some((r) => r.status === 'failed')) {
failed++
} else if (results.some((r) => r.status === 'skipped')) {
skipped++
}
}
}
if (suite.suites) {
countResults(suite.suites)
}
}
}
countResults(reportData.suites)
}
return { passed, failed, skipped, flaky }
} catch (error) {
console.error(`Error reading report at ${reportPath}:`, error.message)
return null
}
}
function generateIndexHtml(browsers) {
const cards = browsers
.map((browser) => {
const { name, stats } = browser
if (!stats) return ''
const total = stats.passed + stats.failed + stats.skipped + stats.flaky
const passRate = total > 0 ? ((stats.passed / total) * 100).toFixed(1) : 0
return `
<a href="./${name}/index.html" class="card">
<div class="card-header">
<h2>${name}</h2>
<span class="pass-rate ${stats.failed > 0 ? 'has-failures' : ''}">${passRate}%</span>
</div>
<div class="stats">
<div class="stat passed">
<span class="label">Passed</span>
<span class="value">${stats.passed}</span>
</div>
<div class="stat failed">
<span class="label">Failed</span>
<span class="value">${stats.failed}</span>
</div>
<div class="stat skipped">
<span class="label">Skipped</span>
<span class="value">${stats.skipped}</span>
</div>
<div class="stat flaky">
<span class="label">Flaky</span>
<span class="value">${stats.flaky}</span>
</div>
</div>
</a>
`
})
.join('')
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright E2E Test Reports</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 3rem;
font-size: 2.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: inherit;
display: block;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f0f0f0;
}
.card h2 {
color: #333;
font-size: 1.5rem;
text-transform: capitalize;
}
.pass-rate {
background: #10b981;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: bold;
font-size: 1.1rem;
}
.pass-rate.has-failures {
background: #ef4444;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat .label {
font-size: 0.875rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat .value {
font-size: 1.75rem;
font-weight: bold;
}
.stat.passed .value {
color: #10b981;
}
.stat.failed .value {
color: #ef4444;
}
.stat.skipped .value {
color: #f59e0b;
}
.stat.flaky .value {
color: #8b5cf6;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.cards-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🎭 Playwright E2E Test Reports</h1>
<div class="cards-grid">
${cards}
</div>
</div>
</body>
</html>`
}
function main() {
if (!fs.existsSync(reportsDir)) {
console.log(
'No playwright reports directory found, skipping index creation'
)
return
}
const browsers = []
const browserDirs = fs.readdirSync(reportsDir, { withFileTypes: true })
for (const dirent of browserDirs) {
if (dirent.isDirectory()) {
const browserName = dirent.name
const browserPath = path.join(reportsDir, browserName)
const stats = getTestStats(browserPath)
if (stats) {
browsers.push({ name: browserName, stats })
console.log(`✓ Found report for ${browserName}:`, stats)
}
}
}
if (browsers.length === 0) {
console.warn('No valid browser reports found')
return
}
const html = generateIndexHtml(browsers)
const indexPath = path.join(reportsDir, 'index.html')
fs.writeFileSync(indexPath, html, 'utf-8')
console.log(`✓ Created index page at ${indexPath}`)
}
main()

View File

@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
const bottomPanelStore = useBottomPanelStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const { t } = useI18n()
const isShortcutsTabActive = computed(() => {
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
}
const openKeybindingSettings = async () => {
dialogService.showSettingsDialog('keybinding')
settingsDialog.show('keybinding')
}
const closeBottomPanel = () => {

View File

@@ -1,7 +1,7 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
size="md"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
@@ -854,19 +854,3 @@ onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
</script>
<style>
/* Ensure the workflow template selector dialog fits within provided dialog */
.workflow-template-selector-dialog.base-widget-layout {
width: 100% !important;
max-width: 1400px;
height: 100% !important;
aspect-ratio: auto !important;
}
@media (min-width: 1600px) {
.workflow-template-selector-dialog.base-widget-layout {
max-width: 1600px;
}
}
</style>

View File

@@ -4,12 +4,7 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"

View File

@@ -116,7 +116,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -134,10 +134,7 @@ const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.WarnBlueprintOverwrite'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
}
const doNotAskAgain = ref(false)

View File

@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
@@ -105,10 +105,7 @@ const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingModelsWarning'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})

View File

@@ -80,7 +80,7 @@ import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -103,10 +103,7 @@ const handleGotItClick = () => {
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingNodesWarning'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
}
const { missingNodePacks, isLoading, error } = useMissingNodes()

View File

@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
settingsDialog.show(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -161,7 +161,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {

View File

@@ -1,9 +1,5 @@
<template>
<PanelTemplate
value="About"
class="about-container"
data-testid="about-panel"
>
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('g.about') }}
</h2>
@@ -32,7 +28,7 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -43,8 +39,6 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import PanelTemplate from './PanelTemplate.vue'
const systemStatsStore = useSystemStatsStore()
const aboutPanelStore = useAboutPanelStore()
</script>

View File

@@ -1,13 +1,9 @@
<template>
<PanelTemplate value="Keybinding" class="keybinding-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<DataTable
v-model:selection="selectedCommandData"
@@ -135,7 +131,7 @@
<i class="pi pi-replay" />
{{ $t('g.resetAll') }}
</Button>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -159,7 +155,6 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
@@ -102,7 +102,7 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
@@ -110,7 +110,6 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'

View File

@@ -6,14 +6,15 @@
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
})
}}
</template>
@@ -27,7 +28,10 @@
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
@@ -45,14 +49,16 @@
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
isSingleSeatPlan
? 'grid-cols-1 py-0'
: activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -101,17 +107,19 @@
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
<template v-if="!isSingleSeatPlan">
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</template>
</div>
@@ -166,7 +174,7 @@
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
@@ -206,14 +214,14 @@
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<Button
@@ -237,8 +245,29 @@
</template>
</template>
<!-- Upsell Banner -->
<div
v-if="isSingleSeatPlan"
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
>
<p class="m-0 text-sm text-foreground">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="muted-textonly"
class="cursor-pointer underline text-sm"
@click="showSubscriptionDialog()"
>
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
<!-- Pending Invites -->
<template v-else>
<template v-if="activeView === 'pending'">
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
@@ -367,6 +398,27 @@ const {
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')

View File

@@ -1,21 +0,0 @@
<template>
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<div class="flex h-full w-full flex-col gap-2">
<slot name="header" />
<ScrollPanel class="h-0 grow pr-2">
<slot />
</ScrollPanel>
<slot name="footer" />
</div>
</TabPanel>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import TabPanel from 'primevue/tabpanel'
const props = defineProps<{
value: string
class?: string
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="User" class="user-settings-container h-full">
<div class="user-settings-container h-full">
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
@@ -95,13 +95,12 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -1,11 +0,0 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<header class="mb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
@@ -8,44 +8,38 @@
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
</header>
<TabsRoot v-model="activeTab">
<div class="flex w-full items-center">
<TabList unstyled class="flex w-full gap-2">
<Tab
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
count: members.length
})
}}
</Tab>
</TabList>
</TabsTrigger>
</TabsList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
@@ -55,20 +49,22 @@
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'opacity-50 cursor-not-allowed'
"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
<i class="pi pi-plus text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
class="ml-2"
variant="secondary"
size="lg"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
@@ -76,17 +72,21 @@
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
<button
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
]"
type="button"
:disabled="!!item.disabled"
:class="
cn(
'flex w-full items-center gap-2 px-3 py-2 bg-transparent border-none cursor-pointer',
item.class,
item.disabled && 'pointer-events-auto cursor-not-allowed'
)
"
@click="
item.command?.({
originalEvent: $event,
@@ -96,44 +96,47 @@
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</button>
</template>
</Menu>
</template>
</div>
<TabPanels unstyled>
<TabPanel value="plan">
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
@@ -144,19 +147,26 @@ const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
const activeTab = ref(defaultTab)
const menu = ref<InstanceType<typeof Menu> | null>(null)
@@ -187,11 +197,16 @@ const deleteTooltip = computed(() => {
})
const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
@@ -231,7 +246,6 @@ const menuItems = computed(() => {
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})

View File

@@ -1,19 +0,0 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onDismiss">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
}
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
}
</script>

View File

@@ -1,32 +0,0 @@
<template>
<div>
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
<i class="pi pi-cog" />
<span>{{ $t('g.settings') }}</span>
<Tag
v-if="isStaging"
value="staging"
severity="warn"
class="ml-2 text-xs"
/>
</h2>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { cn } from '@comfyorg/tailwind-utils'
const { flags } = useFeatureFlags()
</script>
<style scoped>
.pi-cog {
font-size: 1.25rem;
margin-right: 0.5rem;
}
.version-tag {
margin-left: 0.5rem;
}
</style>

View File

@@ -11,7 +11,7 @@ import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { cn } from '@/utils/tailwindUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -20,7 +20,7 @@ import LayoutField from './LayoutField.vue'
const { t } = useI18n()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
// NODES settings
const showAdvancedParameters = computed({
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
}
function openFullSettings() {
dialogService.showSettingsDialog()
settingsDialog.show()
}
</script>

View File

@@ -108,15 +108,14 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingPanelType } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -129,7 +128,7 @@ const commandStore = useCommandStore()
const menuItemStore = useMenuItemStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const managerState = useManagerState()
const settingStore = useSettingStore()
@@ -166,15 +165,8 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
}
}
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
const showSettings = (defaultPanel?: SettingPanelType) => {
settingsDialog.show(defaultPanel)
}
const showManageExtensions = async () => {

View File

@@ -0,0 +1,42 @@
<template>
<Toast group="invite-accepted" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2 justify-between w-full">
<div class="flex flex-col justify-start">
<div class="text-base">
{{ slotProps.message.summary }}
</div>
<div class="mt-1 text-sm text-foreground">
{{ slotProps.message.detail.text }} <br />
{{ slotProps.message.detail.workspaceName }}
</div>
</div>
<Button
size="md"
variant="inverted"
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
>
{{ t('workspace.viewWorkspace') }}
</Button>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
}
</script>

View File

@@ -31,6 +31,15 @@ vi.mock('pinia')
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
hide: vi.fn(),
showAbout: vi.fn()
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
@@ -64,7 +73,6 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSettingsDialog: mockShowSettingsDialog,
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))

View File

@@ -152,6 +152,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -165,6 +166,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
@@ -198,7 +200,7 @@ const canUpgrade = computed(() => {
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
settingsDialog.show('user')
emit('close')
}
@@ -209,9 +211,9 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('subscription')
settingsDialog.show('subscription')
} else {
dialogService.showSettingsDialog('credits')
settingsDialog.show('credits')
}
emit('close')

View File

@@ -220,6 +220,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
@@ -239,6 +240,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
useBillingContext()
@@ -284,12 +286,12 @@ const showSubscribeAction = computed(
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
settingsDialog.show('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
emit('close')
}
@@ -300,9 +302,9 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
} else {
dialogService.showSettingsDialog('credits')
settingsDialog.show('credits')
}
emit('close')

View File

@@ -36,7 +36,7 @@ defineProps<{
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative"
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
@keydown.esc.capture="handleEscape"
>
<div
@@ -141,14 +141,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { contentTitle, rightPanelTitle } = defineProps<{
const SIZE_CLASSES = {
sm: 'h-[80vh] w-[90vw] max-w-[960px]',
md: 'h-[80vh] w-[90vw] max-w-[1400px]',
lg: 'h-[80vh] w-[90vw] max-w-[1280px] aspect-[20/13] min-[1450px]:max-w-[1724px]',
full: 'h-full w-full max-w-[1400px] 2xl:max-w-[1600px]'
} as const
type ModalSize = keyof typeof SIZE_CLASSES
const {
contentTitle,
rightPanelTitle,
size = 'lg'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
@@ -215,17 +232,3 @@ function handleEscape(event: KeyboardEvent) {
}
}
</script>
<style scoped>
.base-widget-layout {
height: 80vh;
width: 90vw;
max-width: 1280px;
aspect-ratio: 20/13;
}
@media (min-width: 1450px) {
.base-widget-layout {
max-width: 1724px;
}
}
</style>

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
Plan,
PreviewSubscribeResponse,
@@ -73,4 +74,5 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,25 +1,50 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
...(original as Record<string, unknown>),
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsPersonal.value
},
get activeWorkspace() {
return mockIsPersonal.value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
@@ -52,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
useBillingPlans: () => ({
get plans() {
return mockPlans
},
currentPlanSlug: { value: null },
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
@@ -88,6 +111,9 @@ describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
})
it('returns legacy type for personal workspace', () => {
@@ -161,4 +187,51 @@ describe('useBillingContext', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(1)
expect(getMaxSeats('pro')).toBe(1)
expect(getMaxSeats('founder')).toBe(1)
})
it('falls back to hardcoded values when no API plans available', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(5)
expect(getMaxSeats('pro')).toBe(20)
expect(getMaxSeats('founder')).toBe(1)
})
it('prefers API max_seats when plans are loaded', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockPlans.value = [
{
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 2110000,
max_seats: 50,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 10000,
total_credits_cents: 2110000
}
}
]
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('pro')).toBe(50)
// Tiers without API plans still fall back to hardcoded values
expect(getMaxSeats('creator')).toBe(5)
})
})
})

View File

@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -115,6 +120,16 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
const apiTier = KEY_TO_TIER[tierKey]
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
)
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
@@ -223,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
getMaxSeats,
initialize,
fetchStatus,

View File

@@ -35,6 +35,7 @@ import {
} from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyCommand } from '@/stores/commandStore'
@@ -73,6 +74,7 @@ export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -582,7 +584,7 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.7',
category: 'view-controls' as const,
function: () => {
void dialogService.showSettingsDialog()
settingsDialog.show()
}
},
{
@@ -831,7 +833,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'About ComfyUI',
versionAdded: '1.6.4',
function: () => {
void dialogService.showSettingsDialog('about')
settingsDialog.showAbout()
}
},
{

View File

@@ -61,7 +61,7 @@ export function useFeatureFlags() {
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
)
},
get linearToggleEnabled() {

View File

@@ -7,8 +7,13 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isImageNode } from '@/utils/litegraphUtil'
import { pasteImageNode, usePaste } from './usePaste'
import { createNode, isImageNode } from '@/utils/litegraphUtil'
import {
cloneDataTransfer,
pasteImageNode,
pasteImageNodes,
usePaste
} from './usePaste'
function createMockNode() {
return {
@@ -86,6 +91,7 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
}))
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
@@ -99,34 +105,32 @@ describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
it('should create new LoadImage node when no image node provided', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items
)
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -136,13 +140,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
it('should handle multiple image files', async () => {
const mockNode = createMockNode()
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const dataTransfer = createDataTransfer([file1, file2])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -152,11 +156,11 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
it('should do nothing when no image files present', async () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -166,13 +170,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
it('should filter non-image items', async () => {
const mockNode = createMockNode()
const imageFile = createImageFile()
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
const dataTransfer = createDataTransfer([textFile, imageFile])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -183,21 +187,61 @@ describe('pasteImageNode', () => {
})
})
describe('pasteImageNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create multiple nodes for multiple files', async () => {
const mockNode1 = createMockNode()
const mockNode2 = createMockNode()
vi.mocked(createNode)
.mockResolvedValueOnce(mockNode1 as unknown as LGraphNode)
.mockResolvedValueOnce(mockNode2 as unknown as LGraphNode)
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const fileList = createDataTransfer([file1, file2]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
)
expect(createNode).toHaveBeenCalledTimes(2)
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
expect(result).toEqual([mockNode1, mockNode2])
})
it('should handle empty file list', async () => {
const fileList = createDataTransfer([]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
)
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
usePaste()
@@ -207,7 +251,7 @@ describe('usePaste', () => {
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
@@ -312,3 +356,62 @@ describe('usePaste', () => {
})
})
})
describe('cloneDataTransfer', () => {
it('should clone string data', () => {
const original = new DataTransfer()
original.setData('text/plain', 'test text')
original.setData('text/html', '<p>test html</p>')
const cloned = cloneDataTransfer(original)
expect(cloned.getData('text/plain')).toBe('test text')
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
})
it('should clone files', () => {
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const original = createDataTransfer([file1, file2])
const cloned = cloneDataTransfer(original)
// Files are added from both .files and .items, causing duplicates
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
expect(Array.from(cloned.files)).toContain(file1)
expect(Array.from(cloned.files)).toContain(file2)
})
it('should preserve dropEffect and effectAllowed', () => {
const original = new DataTransfer()
original.dropEffect = 'copy'
original.effectAllowed = 'copyMove'
const cloned = cloneDataTransfer(original)
expect(cloned.dropEffect).toBe('copy')
expect(cloned.effectAllowed).toBe('copyMove')
})
it('should handle empty DataTransfer', () => {
const original = new DataTransfer()
const cloned = cloneDataTransfer(original)
expect(cloned.types.length).toBe(0)
expect(cloned.files.length).toBe(0)
})
it('should clone both string data and files', () => {
const file = createImageFile()
const original = createDataTransfer([file])
original.setData('text/plain', 'test')
const cloned = cloneDataTransfer(original)
expect(cloned.getData('text/plain')).toBe('test')
// Files are added from both .files and .items
expect(cloned.files.length).toBeGreaterThanOrEqual(1)
expect(Array.from(cloned.files)).toContain(file)
})
})

View File

@@ -6,9 +6,41 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import {
createNode,
isAudioNode,
isImageNode,
isVideoNode
} from '@/utils/litegraphUtil'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
export function cloneDataTransfer(original: DataTransfer): DataTransfer {
const persistent = new DataTransfer()
// Copy string data
for (const type of original.types) {
const data = original.getData(type)
if (data) {
persistent.setData(type, data)
}
}
for (const item of original.items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
persistent.items.add(file)
}
}
}
// Preserve dropEffect and effectAllowed
persistent.dropEffect = original.dropEffect
persistent.effectAllowed = original.effectAllowed
return persistent
}
function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
@@ -48,27 +80,37 @@ function pasteItemsOnNode(
)
}
export function pasteImageNode(
export async function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const {
graph,
graph_mouse: [posX, posY]
} = canvas
): Promise<LGraphNode | null> {
// No image node selected: add a new one
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [posX, posY]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
imageNode = await createNode(canvas, 'LoadImage')
}
pasteItemsOnNode(items, imageNode, 'image')
return imageNode
}
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []
for (const file of fileList) {
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await pasteImageNode(canvas, transfer.items)
if (imageNode) {
nodes.push(imageNode)
}
}
return nodes
}
/**
@@ -93,6 +135,7 @@ export const usePaste = () => {
const { graph } = canvas
let data: DataTransfer | string | null = e.clipboardData
if (!data) throw new Error('No clipboard data on clipboard event')
data = cloneDataTransfer(data)
const { items } = data
@@ -114,7 +157,7 @@ export const usePaste = () => {
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {

View File

@@ -32,15 +32,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
props: {
onClose: hide,
initialCategory
},
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-hidden h-full !py-0' },
root: {
style:
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
}
}
}
})
}

View File

@@ -1,20 +0,0 @@
import { computed } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
/**
* Returns whether the current environment is staging.
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export const isStaging = computed(() => {
if (!isCloud) {
return BUILD_TIME_IS_STAGING
}
const projectId = remoteConfig.value.firebase_config?.projectId
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
})

View File

@@ -71,6 +71,7 @@ async function uploadFile(
api.apiURL(getResourceURL(...splitFilePath(path)))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}

View File

@@ -48,6 +48,17 @@ describe('LGraph', () => {
expect(result1).toEqual(result2)
})
it('should handle adding null node gracefully', () => {
const graph = new LGraph()
const initialNodeCount = graph.nodes.length
const result = graph.add(null)
expect(result).toBeUndefined()
expect(graph.nodes.length).toBe(initialNodeCount)
})
test('can be instantiated', ({ expect }) => {
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
const graph = new LGraph({ extra: 'TestGraph' })

View File

@@ -896,7 +896,7 @@ export class LGraph
* @deprecated Use options object instead
*/
add(
node: LGraphNode | LGraphGroup,
node: LGraphNode | LGraphGroup | null,
skipComputeOrder?: boolean
): LGraphNode | null | undefined
add(

View File

@@ -2176,7 +2176,7 @@
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"descriptionWorkspace": "Choose the best plan for your workspace",
@@ -2264,7 +2264,7 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"membersCount": "{count}/{maxSeats} Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2280,6 +2280,9 @@
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
@@ -2318,6 +2321,14 @@
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
@@ -2964,8 +2975,9 @@
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
"addedToWorkspace": "You have been added to:",
"inviteFailed": "Failed to Accept Invite",
"viewWorkspace": "View workspace"
},
"workspaceAuth": {
"errors": {

View File

@@ -86,15 +86,15 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const showSecretsHint = computed(() => flags.userSecretsEnabled)
function openSecretsSettings() {
dialogService.showSettingsDialog('secrets')
settingsDialog.show('secrets')
}
const props = defineProps<{

View File

@@ -1,7 +1,7 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { DialogComponentProps } from '@/stores/dialogStore'
interface ShowOptions {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
@@ -22,64 +22,51 @@ interface BrowseOptions {
onAssetSelected?: (asset: AssetItem) => void
}
const dialogComponentProps: DialogComponentProps = {
headless: true,
modal: true,
closable: true,
pt: {
root: {
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
},
header: {
class: '!p-0 hidden'
},
content: {
class: '!p-0 !m-0 h-full w-full'
}
}
} as const
const DIALOG_KEY = 'global-asset-browser'
export const useAssetBrowserDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
async function show(props: ShowOptions) {
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(props: ShowOptions) {
const handleAssetSelected = (asset: AssetItem) => {
props.onAssetSelected?.(asset)
dialogStore.closeDialog({ key: dialogKey })
hide()
}
dialogStore.showDialog({
key: dialogKey,
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: AssetBrowserModal,
props: {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
onSelect: handleAssetSelected,
onClose: () => dialogStore.closeDialog({ key: dialogKey })
},
dialogComponentProps
onClose: hide
}
})
}
async function browse(options: BrowseOptions): Promise<void> {
function browse(options: BrowseOptions) {
const handleAssetSelected = (asset: AssetItem) => {
options.onAssetSelected?.(asset)
dialogStore.closeDialog({ key: dialogKey })
hide()
}
dialogStore.showDialog({
key: dialogKey,
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: AssetBrowserModal,
props: {
showLeftPanel: true,
assetType: options.assetType,
title: options.title,
onSelect: handleAssetSelected,
onClose: () => dialogStore.closeDialog({ key: dialogKey })
},
dialogComponentProps
onClose: hide
}
})
}

View File

@@ -374,7 +374,8 @@ const {
plans: apiPlans,
currentPlanSlug,
fetchPlans,
subscription
subscription,
getMaxSeats
} = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
@@ -404,11 +405,6 @@ function getPriceFromApi(tier: PricingTierConfig): number | null {
return currentBillingCycle.value === 'yearly' ? price / 12 : price
}
function getMaxSeatsFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.max_seats : null
}
const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
)
@@ -493,8 +489,7 @@ const getAnnualTotal = (tier: PricingTierConfig): number => {
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
}
const getMaxMembers = (tier: PricingTierConfig): number =>
getMaxSeatsFromApi(tier) ?? tier.maxMembers
const getMaxMembers = (tier: PricingTierConfig): number => getMaxSeats(tier.key)
const getMonthlyCreditsPerMember = (tier: PricingTierConfig): number =>
tier.pricing.credits

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="PlanCredits" class="subscription-container h-full">
<div class="subscription-container h-full">
<div class="flex h-full flex-col gap-6">
<div class="flex items-center gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
@@ -63,11 +63,10 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'

View File

@@ -88,10 +88,13 @@
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base"
>{{ $t('subscription.perMonth') }} /
{{ $t('subscription.member') }}</span
>
<span class="text-base">
{{
isInPersonalWorkspace
? $t('subscription.usdPerMonth')
: $t('subscription.usdPerMonthPerMember')
}}
</span>
</div>
<div
v-if="isActiveSubscription"
@@ -176,7 +179,7 @@
<div class="flex flex-col">
<div class="flex flex-col gap-3 h-full">
<div
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-modal-panel-background justify-between h-full"
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-secondary-background justify-between h-full"
>
<Button
variant="muted-textonly"
@@ -359,7 +362,6 @@ import { useSubscriptionActions } from '@/platform/cloud/subscription/composable
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
@@ -388,7 +390,7 @@ const {
manageSubscription,
fetchStatus,
fetchBalance,
plans: apiPlans
getMaxSeats
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
@@ -511,23 +513,6 @@ const tierPrice = computed(() =>
const memberCount = computed(() => members.value.length)
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
function getApiPlanForTier(tierKey: TierKey, duration: 'monthly' | 'yearly') {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase()
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getMaxSeatsFromApi(tierKey: TierKey): number | null {
const plan = getApiPlanForTier(tierKey, 'monthly')
return plan ? plan.max_seats : null
}
function getMaxMembers(tierKey: TierKey): number {
return getMaxSeatsFromApi(tierKey) ?? getTierFeatures(tierKey).maxMembers
}
const refillsDate = computed(() => {
if (!subscription.value?.renewalDate) return ''
const date = new Date(subscription.value.renewalDate)
@@ -571,13 +556,18 @@ interface Benefit {
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
const benefits: Benefit[] = []
if (!isInPersonalWorkspace.value) {
benefits.push({
key: 'members',
type: 'icon',
label: t('subscription.membersLabel', { count: getMaxMembers(key) }),
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
icon: 'pi pi-user'
},
})
}
benefits.push(
{
key: 'maxDuration',
type: 'metric',
@@ -594,7 +584,7 @@ const tierBenefits = computed((): Benefit[] => {
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
)
if (getTierFeatures(key).customLoRAs) {
benefits.push({

View File

@@ -11,6 +11,13 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
FOUNDERS_EDITION: 'founder'
}
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
standard: 'STANDARD',
creator: 'CREATOR',
pro: 'PRO',
founder: 'FOUNDERS_EDITION'
}
export interface TierPricing {
monthly: number
yearly: number

View File

@@ -1,33 +1,29 @@
<template>
<PanelTemplate value="Extension" class="extension-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.extensions') })
"
/>
<Message
v-if="hasChanges"
severity="info"
pt:text="w-full"
class="max-h-96 overflow-y-auto"
>
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button variant="destructive" @click="applyChanges">
{{ $t('g.reloadToApplyChanges') }}
</Button>
</div>
</Message>
</template>
<div class="extension-panel flex flex-col gap-2">
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.extensions') })"
/>
<Message
v-if="hasChanges"
severity="info"
pt:text="w-full"
class="max-h-96 overflow-y-auto"
>
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button variant="destructive" @click="applyChanges">
{{ $t('g.reloadToApplyChanges') }}
</Button>
</div>
</Message>
<div class="mb-3 flex gap-2">
<SelectButton
v-model="filterType"
@@ -81,7 +77,7 @@
</template>
</Column>
</DataTable>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -97,7 +93,6 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore'

View File

@@ -1,46 +1,39 @@
<template>
<PanelTemplate value="Server-Config" class="server-config-panel">
<template #header>
<div class="flex flex-col gap-2">
<Message
v-if="modifiedConfigs.length > 0"
severity="info"
pt:text="w-full"
>
<p>
{{ $t('serverConfig.modifiedConfigs') }}
</p>
<ul>
<li v-for="config in modifiedConfigs" :key="config.id">
{{ config.name }}: {{ config.initialValue }} {{ config.value }}
</li>
</ul>
<div class="flex justify-end gap-2">
<Button variant="secondary" @click="revertChanges">
{{ $t('serverConfig.revertChanges') }}
</Button>
<Button variant="destructive" @click="restartApp">
{{ $t('serverConfig.restart') }}
</Button>
</div>
</Message>
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
<template #icon>
<i class="icon-[lucide--terminal] text-xl font-bold" />
</template>
<div class="flex items-center justify-between">
<p>{{ commandLineArgs }}</p>
<Button
size="icon"
variant="muted-textonly"
@click="copyCommandLineArgs"
>
<i class="pi pi-clipboard" />
</Button>
</div>
</Message>
<div class="server-config-panel flex flex-col gap-2">
<Message v-if="modifiedConfigs.length > 0" severity="info" pt:text="w-full">
<p>
{{ $t('serverConfig.modifiedConfigs') }}
</p>
<ul>
<li v-for="config in modifiedConfigs" :key="config.id">
{{ config.name }}: {{ config.initialValue }} {{ config.value }}
</li>
</ul>
<div class="flex justify-end gap-2">
<Button variant="secondary" @click="revertChanges">
{{ $t('serverConfig.revertChanges') }}
</Button>
<Button variant="destructive" @click="restartApp">
{{ $t('serverConfig.restart') }}
</Button>
</div>
</template>
</Message>
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
<template #icon>
<i class="icon-[lucide--terminal] text-xl font-bold" />
</template>
<div class="flex items-center justify-between">
<p>{{ commandLineArgs }}</p>
<Button
size="icon"
variant="muted-textonly"
:aria-label="$t('g.copyToClipboard')"
@click="copyCommandLineArgs"
>
<i class="pi pi-clipboard" />
</Button>
</div>
</Message>
<div
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
:key="label"
@@ -58,7 +51,7 @@
/>
</div>
</div>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -69,7 +62,6 @@ import { onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'

View File

@@ -0,0 +1,200 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
</template>
<template #leftPanel>
<div class="px-3">
<SearchBox
v-model:model-value="searchQuery"
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
@search="handleSearch"
/>
</div>
<nav
ref="navRef"
class="scrollbar-hide flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
>
<div
v-for="(group, index) in navGroups"
:key="index"
class="flex flex-col gap-2"
>
<NavTitle :title="group.title" />
<NavItem
v-for="item in group.items"
:key="item.id"
:data-nav-id="item.id"
:icon="item.icon"
:badge="item.badge"
:active="activeCategoryKey === item.id"
@click="onNavItemClick(item.id)"
>
{{ item.label }}
</NavItem>
</div>
</nav>
</template>
<template #header />
<template #content>
<template v-if="inSearch">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
<ColorPaletteMessage
v-if="activeSettingCategory.label === 'Appearance'"
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
<template v-else-if="activePanel">
<Suspense>
<component :is="activePanel.component" v-bind="activePanel.props" />
<template #fallback>
<div>
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
</div>
</template>
</Suspense>
</template>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { computed, nextTick, provide, ref, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
ISettingGroup,
SettingPanelType,
SettingParams
} from '@/platform/settings/types'
import { OnCloseKey } from '@/types/widgetTypes'
import { flattenTree } from '@/utils/treeUtil'
const { onClose, defaultPanel, scrollToSettingId } = defineProps<{
onClose: () => void
defaultPanel?: SettingPanelType
scrollToSettingId?: string
}>()
provide(OnCloseKey, onClose)
const {
defaultCategory,
settingCategories,
navGroups,
findCategoryByKey,
findPanelByKey
} = useSettingUI(defaultPanel, scrollToSettingId)
const {
searchQuery,
inSearch,
searchResultsCategories,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
const authActions = useFirebaseAuthActions()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
watch(searchResultsCategories, (categories) => {
if (!inSearch.value || categories.size === 0) return
const firstMatch = navGroups.value
.flatMap((g) => g.items)
.find((item) => {
const node = findCategoryByKey(item.id)
return node && categories.has(node.label)
})
activeCategoryKey.value = firstMatch?.id ?? null
})
const activeSettingCategory = computed<SettingTreeNode | null>(() => {
if (!activeCategoryKey.value) return null
return (
settingCategories.value.find((c) => c.key === activeCategoryKey.value) ??
null
)
})
const activePanel = computed(() => {
if (!activeCategoryKey.value) return null
return findPanelByKey(activeCategoryKey.value)
})
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
function sortedGroups(category: SettingTreeNode): ISettingGroup[] {
return [...(category.children ?? [])]
.sort((a, b) => {
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
})
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group).sort((a, b) => {
const sortOrderA = a.sortOrder ?? 0
const sortOrderB = b.sortOrder ?? 0
return sortOrderB - sortOrderA
})
}))
}
function handleSearch(query: string) {
handleSearchBase(query.trim())
if (query) {
activeCategoryKey.value = null
} else if (!activeCategoryKey.value) {
activeCategoryKey.value = defaultCategory.value?.key ?? null
}
}
function onNavItemClick(id: string) {
activeCategoryKey.value = id
}
const searchResults = computed<ISettingGroup[]>(() => {
const category = activeCategoryKey.value
? findCategoryByKey(activeCategoryKey.value)
: null
return getSearchResults(category)
})
watch(activeCategoryKey, (newKey, oldKey) => {
if (!newKey && !inSearch.value) {
activeCategoryKey.value = oldKey
}
if (newKey === 'credits') {
void authActions.fetchBalance()
}
if (newKey) {
void nextTick(() => {
navRef.value
?.querySelector(`[data-nav-id="${newKey}"]`)
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
})
}
})
</script>

View File

@@ -1,303 +0,0 @@
<template>
<div
data-testid="settings-dialog"
:class="
teamWorkspacesEnabled
? 'flex h-full w-full overflow-auto flex-col md:flex-row'
: 'settings-container'
"
>
<ScrollPanel
:class="
teamWorkspacesEnabled
? 'w-full md:w-64 md:min-w-64 md:max-w-64 shrink-0 p-2'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
"
>
<div :class="teamWorkspacesEnabled ? 'px-4' : ''">
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.settings') })
"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>
<Listbox
v-model="activeCategory"
:options="groupedMenuTreeNodes"
option-label="translatedLabel"
option-group-label="label"
option-group-children="children"
scroll-height="100%"
:option-disabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
:class="
teamWorkspacesEnabled
? 'w-full border-none bg-transparent'
: 'w-full border-none'
"
>
<!-- Workspace mode: custom group headers -->
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
{{ option.translatedLabel ?? option.label }}
</h3>
</template>
<!-- Legacy mode: divider between groups -->
<template v-else #optiongroup>
<Divider class="my-0" />
</template>
<!-- Custom option template with data-testid for stable test selectors -->
<template #option="{ option }">
<span
:data-testid="`settings-tab-${option.key}`"
class="settings-tab-option"
>
<WorkspaceSidebarItem
v-if="teamWorkspacesEnabled && option.key === 'workspace'"
/>
<template v-else>{{ option.translatedLabel }}</template>
</span>
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
<Divider layout="horizontal" class="flex md:hidden" />
<Tabs
:value="tabValue"
:lazy="true"
:class="
teamWorkspacesEnabled
? 'h-full flex-1 overflow-auto scrollbar-custom'
: 'settings-content h-full w-full'
"
>
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :setting-groups="searchResults" />
</PanelTemplate>
<PanelTemplate
v-for="category in settingCategories"
:key="category.key"
:value="category.label ?? ''"
>
<template #header>
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
</template>
<SettingsPanel :setting-groups="sortedGroups(category)" />
</PanelTemplate>
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" v-bind="panel.props" />
<template #fallback>
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
</template>
</Suspense>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, nextTick, onBeforeUnmount, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { flattenTree } from '@/utils/treeUtil'
const { defaultPanel, scrollToSettingId } = defineProps<{
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets'
scrollToSettingId?: string
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const {
activeCategory,
defaultCategory,
settingCategories,
groupedMenuTreeNodes,
panels
} = useSettingUI(defaultPanel, scrollToSettingId)
const {
searchQuery,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
const authActions = useFirebaseAuthActions()
// Get max sortOrder from settings in a group
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => {
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
})
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group).sort((a, b) => {
const sortOrderA = a.sortOrder ?? 0
const sortOrderB = b.sortOrder ?? 0
return sortOrderB - sortOrderA
})
}))
}
const handleSearch = (query: string) => {
handleSearchBase(query.trim())
activeCategory.value = query ? null : defaultCategory.value
}
// Get search results
const searchResults = computed<ISettingGroup[]>(() =>
getSearchResults(activeCategory.value)
)
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : (activeCategory.value?.label ?? '')
)
// Scroll to and highlight the target setting once the correct tab renders.
if (scrollToSettingId) {
const stopScrollWatch = watch(
tabValue,
() => {
void nextTick(() => {
const el = document.querySelector(
`[data-setting-id="${CSS.escape(scrollToSettingId)}"]`
)
if (!el) return
stopScrollWatch()
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('setting-highlight')
el.addEventListener(
'animationend',
() => el.classList.remove('setting-highlight'),
{ once: true }
)
})
},
{ immediate: true }
)
onBeforeUnmount(stopScrollWatch)
}
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
if (!tabValue.value) {
activeCategory.value = oldValue
}
if (activeCategory.value?.key === 'credits') {
void authActions.fetchBalance()
}
})
</script>
<style>
.settings-tab-panels {
padding-top: 0 !important;
}
@media (prefers-reduced-motion: no-preference) {
.setting-highlight {
animation: setting-highlight-pulse 1.5s ease-in-out;
}
}
@keyframes setting-highlight-pulse {
0%,
100% {
background-color: transparent;
}
30% {
background-color: color-mix(
in srgb,
var(--p-primary-color) 15%,
transparent
);
}
}
</style>
<style scoped>
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
.settings-container {
display: flex;
height: 70vh;
width: 60vw;
max-width: 64rem;
overflow: hidden;
}
.settings-content {
overflow-x: auto;
}
@media (max-width: 768px) {
.settings-container {
flex-direction: column;
height: auto;
width: 80vw;
}
.settings-sidebar {
width: 100%;
}
.settings-content {
height: 350px;
}
}
/* Hide the first group separator in legacy mode */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}
</style>

View File

@@ -12,10 +12,30 @@ import {
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import type { NavGroupData } from '@/types/navTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
const CATEGORY_ICONS: Record<string, string> = {
'3D': 'icon-[lucide--box]',
about: 'icon-[lucide--info]',
Appearance: 'icon-[lucide--palette]',
Comfy: 'icon-[lucide--settings]',
credits: 'icon-[lucide--coins]',
extension: 'icon-[lucide--puzzle]',
keybinding: 'icon-[lucide--keyboard]',
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Other: 'icon-[lucide--ellipsis]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--building-2]'
}
interface SettingPanelItem {
node: SettingTreeNode
component: Component
@@ -23,16 +43,7 @@ interface SettingPanelItem {
}
export function useSettingUI(
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets',
defaultPanel?: SettingPanelType,
scrollToSettingId?: string
) {
const { t } = useI18n()
@@ -165,7 +176,8 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
() =>
import('@/components/dialog/content/setting/WorkspacePanelContent.vue')
)
}
@@ -357,6 +369,36 @@ export function useSettingUI(
: legacyMenuTreeNodes.value
)
const navGroups = computed<NavGroupData[]>(() =>
groupedMenuTreeNodes.value.map((group) => ({
title:
(group as SettingTreeNode & { translatedLabel?: string })
.translatedLabel ?? group.label,
items: (group.children ?? []).map((child) => ({
id: child.key,
label:
(child as SettingTreeNode & { translatedLabel?: string })
.translatedLabel ?? child.label,
icon:
CATEGORY_ICONS[child.key] ??
CATEGORY_ICONS[child.label] ??
'icon-[lucide--plug]'
}))
}))
)
function findCategoryByKey(key: string): SettingTreeNode | null {
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === key)
if (found) return found
}
return null
}
function findPanelByKey(key: string): SettingPanelItem | null {
return panels.value.find((p) => p.node.key === key) ?? null
}
onMounted(() => {
activeCategory.value = defaultCategory.value
})
@@ -366,6 +408,10 @@ export function useSettingUI(
activeCategory,
defaultCategory,
groupedMenuTreeNodes,
settingCategories
settingCategories,
navGroups,
teamWorkspacesEnabled,
findCategoryByKey,
findPanelByKey
}
}

View File

@@ -0,0 +1,34 @@
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import SettingDialog from '@/platform/settings/components/SettingDialog.vue'
import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
export function useSettingsDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(panel?: SettingPanelType, settingId?: string) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: SettingDialog,
props: {
onClose: hide,
...(panel ? { defaultPanel: panel } : {}),
...(settingId ? { scrollToSettingId: settingId } : {})
}
})
}
function showAbout() {
show('about')
}
return { show, hide, showAbout }
}

View File

@@ -64,3 +64,14 @@ export interface ISettingGroup {
label: string
settings: SettingParams[]
}
export type SettingPanelType =
| 'about'
| 'credits'
| 'extension'
| 'keybinding'
| 'secrets'
| 'server-config'
| 'subscription'
| 'user'
| 'workspace'

View File

@@ -239,15 +239,6 @@ interface CreateTopupResponse {
amount_cents: number
}
interface TopupStatusResponse {
topup_id: string
status: TopupStatus
amount_cents: number
error_message?: string
created_at: string
completed_at?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
export interface BillingOpStatusResponse {
@@ -701,23 +692,6 @@ export const workspaceApi = {
}
},
/**
* Get top-up status
* GET /api/billing/topup/:id
*/
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<TopupStatusResponse>(
api.apiURL(`/billing/topup/${topupId}`),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing events
* GET /api/billing/events

View File

@@ -130,8 +130,13 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
detail: {
text: 'You have been added to Test Workspace',
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
},
group: 'invite-accepted',
closable: true
})
})

View File

@@ -81,12 +81,17 @@ export function useInviteUrlLoader() {
toast.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
life: 5000
detail: {
text: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
workspaceName: result.workspaceName,
workspaceId: result.workspaceId
},
group: 'invite-accepted',
closable: true
})
} catch (error) {
toast.add({

View File

@@ -1,4 +1,4 @@
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
@@ -137,13 +137,6 @@ function getUIConfig(
function useWorkspaceUIInternal() {
const store = useTeamWorkspaceStore()
// Tab management (shared UI state)
const activeTab = ref<string>('plan')
function setActiveTab(tab: string | number) {
activeTab.value = String(tab)
}
const workspaceType = computed<WorkspaceType>(
() => store.activeWorkspace?.type ?? 'personal'
)
@@ -161,10 +154,6 @@ function useWorkspaceUIInternal() {
)
return {
// Tab management
activeTab: computed(() => activeTab.value),
setActiveTab,
// Permissions and config
permissions,
uiConfig,

View File

@@ -6,7 +6,7 @@
v-else
:class="
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,max-content)_minmax(125px,auto)] gap-y-1 pr-3',
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'

View File

@@ -34,5 +34,11 @@ onMounted(() => {
whenever(() => !canvasStore.linearMode, mountWidgetElement)
</script>
<template>
<div ref="domEl" @pointerdown.stop @pointermove.stop @pointerup.stop />
<div
ref="domEl"
class="flex flex-col [&>*]:flex-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
/>
</template>

View File

@@ -59,5 +59,7 @@ const filteredProps = computed(() =>
const displayName = computed(() => widget.label || widget.name)
const id = useId()
const isReadOnly = computed(() => widget.options?.read_only ?? false)
const isReadOnly = computed(
() => widget.options?.read_only ?? widget.options?.disabled ?? false
)
</script>

View File

@@ -1,5 +1,3 @@
import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -7,6 +5,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
@@ -24,7 +23,6 @@ export function useTextPreviewWidget(
node: LGraphNode,
inputSpec: InputSpec
): IBaseWidget {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
@@ -37,9 +35,16 @@ export function useTextPreviewWidget(
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
getValue: () =>
useWidgetValueStore().getWidget(node.id, inputSpec.name)?.value ?? '',
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
const widgetState = useWidgetValueStore().getWidget(
node.id,
inputSpec.name
)
if (widgetState)
widgetState.value =
typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
read_only: true

200
src/scripts/app.test.ts Normal file
View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { ComfyApp } from './app'
import { createNode } from '@/utils/litegraphUtil'
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn(),
isAudioNode: vi.fn(),
executeWidgetsCallback: vi.fn(),
fixLinkInputSlots: vi.fn()
}))
vi.mock('@/composables/usePaste', () => ({
pasteImageNode: vi.fn(),
pasteImageNodes: vi.fn()
}))
vi.mock('@/scripts/metadata/parser', () => ({
getWorkflowDataFromFile: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
function createMockNode(options: { [K in keyof LGraphNode]?: any } = {}) {
return {
id: 1,
pos: [0, 0],
size: [200, 100],
type: 'LoadImage',
connect: vi.fn(),
getBounding: vi.fn(() => new Float64Array([0, 0, 200, 100])),
...options
} as LGraphNode
}
function createMockCanvas(): Partial<LGraphCanvas> {
const mockGraph: Partial<LGraph> = {
change: vi.fn()
}
return {
graph: mockGraph as LGraph,
selectItems: vi.fn()
}
}
function createTestFile(name: string, type: string): File {
return new File([''], name, { type })
}
describe('ComfyApp', () => {
let app: ComfyApp
let mockCanvas: LGraphCanvas
beforeEach(() => {
vi.clearAllMocks()
app = new ComfyApp()
mockCanvas = createMockCanvas() as LGraphCanvas
app.canvas = mockCanvas as LGraphCanvas
})
describe('handleFileList', () => {
it('should create image nodes for each file in the list', async () => {
const mockNode1 = createMockNode({ id: 1 })
const mockNode2 = createMockNode({ id: 2 })
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
const file1 = createTestFile('test1.png', 'image/png')
const file2 = createTestFile('test2.jpg', 'image/jpeg')
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const { files } = dataTransfer
await app.handleFileList(files)
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2,
mockBatchNode
])
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
})
it('should not proceed if batch node creation fails', async () => {
const mockNode1 = createMockNode({ id: 1 })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
vi.mocked(createNode).mockResolvedValue(null)
const file = createTestFile('test.png', 'image/png')
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
await app.handleFileList(dataTransfer.files)
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
expect(mockNode1.connect).not.toHaveBeenCalled()
})
it('should handle empty file list', async () => {
const dataTransfer = new DataTransfer()
await expect(app.handleFileList(dataTransfer.files)).rejects.toThrow()
})
it('should not process unsupported file types', async () => {
const invalidFile = createTestFile('test.pdf', 'application/pdf')
const dataTransfer = new DataTransfer()
dataTransfer.items.add(invalidFile)
await app.handleFileList(dataTransfer.files)
expect(pasteImageNodes).not.toHaveBeenCalled()
expect(createNode).not.toHaveBeenCalled()
})
})
describe('positionBatchNodes', () => {
it('should position batch node to the right of first node', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockBatchNode = createMockNode({ pos: [0, 0] })
app.positionBatchNodes([mockNode1], mockBatchNode)
expect(mockBatchNode.pos).toEqual([500, 230])
})
it('should stack multiple image nodes vertically', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
type: 'LoadImage',
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockBatchNode = createMockNode({ pos: [0, 0] })
app.positionBatchNodes([mockNode1, mockNode2, mockNode3], mockBatchNode)
expect(mockNode1.pos).toEqual([100, 200])
expect(mockNode2.pos).toEqual([100, 594])
expect(mockNode3.pos).toEqual([100, 963])
})
it('should call graph change once for all nodes', () => {
const mockNode1 = createMockNode({
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockBatchNode = createMockNode()
app.positionBatchNodes([mockNode1], mockBatchNode)
expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1)
})
})
describe('handleFile', () => {
it('should handle image files by creating LoadImage node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode()
vi.mocked(createNode).mockResolvedValue(mockNode)
const imageFile = createTestFile('test.png', 'image/png')
await app.handleFile(imageFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(pasteImageNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
})
})

View File

@@ -84,6 +84,7 @@ import {
} from '@/utils/graphTraversalUtil'
import {
executeWidgetsCallback,
createNode,
fixLinkInputSlots,
isImageNode
} from '@/utils/litegraphUtil'
@@ -108,7 +109,7 @@ import { type ComfyWidgetConstructor } from './widgets'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { extractFileFromDragEvent } from '@/utils/eventUtils'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { pasteImageNode } from '@/composables/usePaste'
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -553,7 +554,13 @@ export class ComfyApp {
const workspace = useWorkspaceStore()
try {
workspace.spinner = true
await this.handleFile(fileMaybe, 'file_drop')
if (fileMaybe instanceof File) {
await this.handleFile(fileMaybe, 'file_drop')
}
if (fileMaybe instanceof FileList) {
await this.handleFileList(fileMaybe)
}
} finally {
workspace.spinner = false
}
@@ -1488,7 +1495,8 @@ export class ComfyApp {
if (file.type.startsWith('image')) {
const transfer = new DataTransfer()
transfer.items.add(file)
pasteImageNode(this.canvas, transfer.items)
const imageNode = await createNode(this.canvas, 'LoadImage')
await pasteImageNode(this.canvas, transfer.items, imageNode)
return
}
@@ -1567,6 +1575,49 @@ export class ComfyApp {
this.showErrorOnFileLoad(file)
}
/**
* Loads multiple files, connects to a batch node, and selects them
* @param {FileList} fileList
*/
async handleFileList(fileList: FileList) {
if (fileList[0].type.startsWith('image')) {
const imageNodes = await pasteImageNodes(this.canvas, fileList)
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
if (!batchImagesNode) return
this.positionBatchNodes(imageNodes, batchImagesNode)
this.canvas.selectItems([...imageNodes, batchImagesNode])
Array.from(imageNodes).forEach((imageNode, index) => {
imageNode.connect(0, batchImagesNode, index)
})
}
}
/**
* Positions batched nodes in drag and drop
* @param nodes
* @param batchNode
*/
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
const [x, y, width] = nodes[0].getBounding()
batchNode.pos = [x + width + 100, y + 30]
// Retrieving Node Height is inconsistent
let height = 0
if (nodes[0].type === 'LoadImage') {
height = 344
}
nodes.forEach((node, index) => {
if (index > 0) {
node.pos = [x, y + height * index + 25 * (index + 1)]
}
})
this.canvas.graph?.change()
}
// @deprecated
isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!_.isObject(data) || Array.isArray(data)) {

View File

@@ -1,7 +1,7 @@
import { useSettingStore } from '@/platform/settings/settingStore'
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { isCloud } from '@/platform/distribution/types'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
@@ -464,7 +464,7 @@ export class ComfyUI {
$el('button.comfy-settings-btn', {
textContent: '⚙️',
onclick: () => {
useDialogService().showSettingsDialog()
useSettingsDialog().show()
}
}),
$el('button.comfy-close-menu-btn', {

View File

@@ -40,10 +40,6 @@ const lazyUpdatePasswordContent = () =>
import('@/components/dialog/content/UpdatePasswordContent.vue')
const lazyComfyOrgHeader = () =>
import('@/components/dialog/header/ComfyOrgHeader.vue')
const lazySettingDialogHeader = () =>
import('@/components/dialog/header/SettingDialogHeader.vue')
const lazySettingDialogContent = () =>
import('@/platform/settings/components/SettingDialogContent.vue')
const lazyImportFailedNodeContent = () =>
import('@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue')
const lazyImportFailedNodeHeader = () =>
@@ -128,64 +124,6 @@ export const useDialogService = () => {
})
}
async function showSettingsDialog(
panel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets',
settingId?: string
) {
const [
{ default: SettingDialogHeader },
{ default: SettingDialogContent }
] = await Promise.all([
lazySettingDialogHeader(),
lazySettingDialogContent()
])
const props =
panel || settingId
? {
props: {
defaultPanel: panel,
scrollToSettingId: settingId
}
}
: undefined
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
...props
})
}
async function showAboutDialog() {
const [
{ default: SettingDialogHeader },
{ default: SettingDialogContent }
] = await Promise.all([
lazySettingDialogHeader(),
lazySettingDialogContent()
])
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel: 'about'
}
})
}
function showExecutionErrorDialog(executionError: ExecutionErrorDialogInput) {
const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: {
@@ -476,7 +414,7 @@ export const useDialogService = () => {
const layoutDefaultProps: DialogComponentProps = {
headless: true,
modal: true,
closable: false,
closable: true,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
@@ -711,6 +649,22 @@ export const useDialogService = () => {
})
}
async function showInviteMemberUpsellDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberUpsellDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member-upsell',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
@@ -760,8 +714,6 @@ export const useDialogService = () => {
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
showSettingsDialog,
showAboutDialog,
showExecutionErrorDialog,
showApiNodesSignInDialog,
showSignInDialog,
@@ -782,6 +734,7 @@ export const useDialogService = () => {
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog
}

View File

@@ -880,7 +880,6 @@ export const useLitegraphService = () => {
const graph = useWorkflowStore().activeSubgraph ?? app.graph
// @ts-expect-error fixme ts strict error
graph.add(node)
// @ts-expect-error fixme ts strict error
return node

View File

@@ -33,9 +33,11 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSettingsDialog: vi.fn()
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: () => ({
show: vi.fn(),
hide: vi.fn(),
showAbout: vi.fn()
})
}))

View File

@@ -6,7 +6,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
const INITIAL_INTERVAL_MS = 1000
@@ -143,7 +143,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
const dialogStore = useDialogStore()
dialogStore.closeDialog({ key: 'subscription-required' })
dialogStore.closeDialog({ key: 'top-up-credits' })
void useDialogService().showSettingsDialog('workspace')
useSettingsDialog().show('workspace')
const toastStore = useToastStore()
const messageKey =

View File

@@ -119,7 +119,6 @@ describe('useFirebaseAuthStore', () => {
// Setup dialog service mock
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
showSettingsDialog: vi.fn(),
showErrorDialog: vi.fn()
})

View File

@@ -33,6 +33,45 @@ describe('eventUtils', () => {
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
})
it('should handle drops with multiple image files', async () => {
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
type: 'image/png'
})
const imageFile2 = new File([new Uint8Array()], 'image2.jpg', {
type: 'image/jpeg'
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(imageFile1)
dataTransfer.items.add(imageFile2)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBeDefined()
expect((actual as FileList).length).toBe(2)
expect((actual as FileList)[0]).toBe(imageFile1)
expect((actual as FileList)[1]).toBe(imageFile2)
})
it('should return undefined when dropping multiple non-image files', async () => {
const file1 = new File([new Uint8Array()], 'file1.txt', {
type: 'text/plain'
})
const file2 = new File([new Uint8Array()], 'file2.txt', {
type: 'text/plain'
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBe(undefined)
})
// Skip until we can setup MSW
it.skip('should handle drops with URLs', async () => {
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'

View File

@@ -1,14 +1,14 @@
export async function extractFileFromDragEvent(
event: DragEvent
): Promise<File | undefined> {
): Promise<File | FileList | undefined> {
if (!event.dataTransfer) return
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
if (
event.dataTransfer.files.length &&
event.dataTransfer.files[0].type !== 'image/bmp'
) {
return event.dataTransfer.files[0]
const { files } = event.dataTransfer
// Dragging from Chrome->Firefox there is a file, but it's a bmp, so ignore it
if (files.length === 1 && files[0].type !== 'image/bmp') {
return files[0]
} else if (files.length > 1 && Array.from(files).every(hasImageType)) {
return files
}
// Try loading the first URI in the transfer list
@@ -25,3 +25,7 @@ export async function extractFileFromDragEvent(
const blob = await response.blob()
return new File([blob], uri, { type: blob.type })
}
function hasImageType({ type }: File): boolean {
return type.startsWith('image')
}

View File

@@ -1,13 +1,97 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
compressWidgetInputSlots,
createNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
function createMockCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
const mockGraph = {
add: vi.fn((node) => node),
change: vi.fn()
} satisfies Partial<LGraph> as unknown as LGraph
const mockCanvas: Partial<LGraphCanvas> = {
graph_mouse: [100, 200],
graph: mockGraph,
...overrides
}
return mockCanvas as LGraphCanvas
}
describe('createNode', () => {
beforeEach(vi.clearAllMocks)
it('should create a node successfully', async () => {
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, 'LoadImage')
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(result).toBe(mockNode)
})
it('should return null when name is empty', async () => {
const mockCanvas = createMockCanvas()
const result = await createNode(mockCanvas, '')
expect(LiteGraph.createNode).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('should handle graph being null', async () => {
const mockNode = { pos: [0, 0] }
const mockCanvas = createMockCanvas({ graph: null })
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
const result = await createNode(mockCanvas, 'LoadImage')
expect(mockNode.pos).toEqual([0, 0])
expect(result).toBeNull()
})
it('should set position based on canvas graph_mouse', async () => {
const mockCanvas = createMockCanvas({ graph_mouse: [250, 350] })
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as LGraphNode)
await createNode(mockCanvas, 'LoadAudio')
expect(mockNode.pos).toEqual([250, 350])
})
})
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {

View File

@@ -1,9 +1,14 @@
import _ from 'es-toolkit/compat'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type {
ColorOption,
LGraph,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
LGraphGroup,
LGraphNode,
LiteGraph,
Reroute,
isColorable
} from '@/lib/litegraph/src/litegraph'
@@ -18,6 +23,8 @@ import type {
WidgetCallbackOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { t } from '@/i18n'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -25,6 +32,38 @@ type VideoNode = LGraphNode & {
imgs: HTMLVideoElement[] | undefined
}
/**
* Extract & Promisify Litegraph.createNode to allow for positioning
* @param canvas
* @param name
*/
export async function createNode(
canvas: LGraphCanvas,
name: string
): Promise<LGraphNode | null> {
if (!name) {
return null
}
const {
graph,
graph_mouse: [posX, posY]
} = canvas
const newNode = LiteGraph.createNode(name)
await new Promise((r) => setTimeout(r, 0))
if (newNode && graph) {
newNode.pos = [posX, posY]
const addedNode = graph.add(newNode) ?? null
if (addedNode) graph.change()
return addedNode
} else {
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))
return null
}
}
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
if (!node) return false
return (

View File

@@ -16,6 +16,7 @@
</div>
<GlobalToast />
<InviteAcceptedToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
<ManagerProgressToast />
@@ -44,6 +45,7 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import InviteAcceptedToast from '@/components/toast/InviteAcceptedToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'

View File

@@ -20,11 +20,6 @@ export function useManagerDialog() {
props: {
onClose: hide,
initialTab
},
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-hidden h-full !py-0' }
}
}
})
}

View File

@@ -28,18 +28,13 @@ vi.mock('@/composables/useFeatureFlags', () => {
}
})
vi.mock('@/services/dialogService', () => {
const showManagerPopup = vi.fn()
const showLegacyManagerPopup = vi.fn()
const showSettingsDialog = vi.fn()
return {
useDialogService: vi.fn(() => ({
showManagerPopup,
showLegacyManagerPopup,
showSettingsDialog
}))
}
})
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: vi.fn(),
hide: vi.fn(),
showAbout: vi.fn()
}))
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({

View File

@@ -4,7 +4,7 @@ import { computed, readonly } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useManagerDialog } from '@/workbench/extensions/manager/composables/useManagerDialog'
@@ -148,12 +148,12 @@ export function useManagerState() {
isLegacyOnly?: boolean
}): Promise<void> => {
const state = managerUIState.value
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const commandStore = useCommandStore()
switch (state) {
case ManagerUIState.DISABLED:
void dialogService.showSettingsDialog('extension')
settingsDialog.show('extension')
break
case ManagerUIState.LEGACY_UI: {
@@ -173,7 +173,7 @@ export function useManagerState() {
}
// Fallback to extensions panel if not showing toast
if (options?.showToastOnLegacyError === false) {
void dialogService.showSettingsDialog('extension')
settingsDialog.show('extension')
}
}
break

View File

@@ -35,6 +35,7 @@
"rootDir": "./"
},
"include": [
".pages/*.ts",
".storybook/**/*",
"eslint.config.ts",
"global.d.ts",