diff --git a/.claude/commands/setup_repo.md b/.claude/commands/setup_repo.md index d82e22ec6..71dee96a5 100644 --- a/.claude/commands/setup_repo.md +++ b/.claude/commands/setup_repo.md @@ -122,7 +122,7 @@ echo " pnpm build - Build for production" echo " pnpm test:unit - Run unit tests" echo " pnpm typecheck - Run TypeScript checks" echo " pnpm lint - Run ESLint" -echo " pnpm format - Format code with Prettier" +echo " pnpm format - Format code with oxfmt" echo "" echo "Next steps:" echo "1. Run 'pnpm dev' to start developing" diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index 3ce6d6aa9..c97f6255c 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -42,7 +42,7 @@ jobs: - name: Run Stylelint with auto-fix run: pnpm stylelint:fix - - name: Run Prettier with auto-format + - name: Run oxfmt with auto-format run: pnpm format - name: Check for changes @@ -60,7 +60,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . - git commit -m "[automated] Apply ESLint and Prettier fixes" + git commit -m "[automated] Apply ESLint and Oxfmt fixes" git push - name: Final validation @@ -80,7 +80,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting' + body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting' }) - name: Comment on PR about manual fix needed diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 86ce06eaa..4369f0a70 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -1,7 +1,7 @@ // This file is intentionally kept in CommonJS format (.cjs) // to resolve compatibility issues with dependencies that require CommonJS. // Do not convert this file to ESModule format unless all dependencies support it. -const { defineConfig } = require('@lobehub/i18n-cli'); +const { defineConfig } = require('@lobehub/i18n-cli') module.exports = defineConfig({ modelName: 'gpt-4.1', @@ -10,7 +10,19 @@ module.exports = defineConfig({ entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', - outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'], + outputLocales: [ + 'zh', + 'zh-TW', + 'ru', + 'ja', + 'ko', + 'fr', + 'es', + 'ar', + 'tr', + 'pt-BR', + 'fa' + ], reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. @@ -26,4 +38,4 @@ module.exports = defineConfig({ - Use Arabic-Indic numerals (۰-۹) for numbers where appropriate. - Maintain consistency with terminology used in Persian software and design applications. ` -}); +}) diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..5da4febe2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "tabWidth": 2, + "semi": false, + "trailingComma": "none", + "printWidth": 80, + "ignorePatterns": [ + "packages/registry-types/src/comfyRegistryTypes.ts", + "src/types/generatedManagerTypes.ts", + "**/*.md", + "**/*.json", + "**/*.css", + "**/*.yaml", + "**/*.yml", + "**/*.html", + "**/*.svg", + "**/*.xml" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4403edd8e..000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -packages/registry-types/src/comfyRegistryTypes.ts -src/types/generatedManagerTypes.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index aa43a43ac..000000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "singleQuote": true, - "tabWidth": 2, - "semi": false, - "trailingComma": "none", - "printWidth": 80, - "importOrder": ["^@core/(.*)$", "", "^@/(.*)$", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 54f28d400..9cbac42d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,25 +1,22 @@ { "recommendations": [ + "antfu.vite", "austenc.tailwind-docs", "bradlc.vscode-tailwindcss", "davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", + "donjayamanne.githistory", "eamodio.gitlens", - "esbenp.prettier-vscode", - "figma.figma-vscode-extension", "github.vscode-github-actions", "github.vscode-pull-request-github", "hbenl.vscode-test-explorer", + "kisstkondoros.vscode-codemetrics", "lokalise.i18n-ally", "ms-playwright.playwright", + "oxc.oxc-vscode", + "sonarsource.sonarlint-vscode", "vitest.explorer", "vue.volar", - "sonarsource.sonarlint-vscode", - "deque-systems.vscode-axe-linter", - "kisstkondoros.vscode-codemetrics", - "donjayamanne.githistory", - "wix.vscode-import-cost", - "prograhammer.tslint-vue", - "antfu.vite" + "wix.vscode-import-cost" ] } diff --git a/AGENTS.md b/AGENTS.md index da2953783..9938865a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob) - Build output: `dist/` - Configs - `vite.config.mts` - - `vitest.config.ts` - `playwright.config.ts` - `eslint.config.ts` - - `.prettierrc` + - `.oxfmtrc.json` + - `.oxlintrc.json` - etc. ## Monorepo Architecture @@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management - `pnpm test:unit`: Run Vitest unit tests - `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`) - `pnpm lint` / `pnpm lint:fix`: Lint (ESLint) -- `pnpm format` / `pnpm format:check`: Prettier +- `pnpm format` / `pnpm format:check`: oxfmt - `pnpm typecheck`: Vue TSC type checking - `pnpm storybook`: Start Storybook development server @@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management - Composition API only - Tailwind 4 styling - Avoid ` diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs deleted file mode 100644 index d158a355d..000000000 --- a/lint-staged.config.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import path from 'node:path' - -export default { - 'tests-ui/**': () => 'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1', - - './**/*.js': (stagedFiles) => formatAndEslint(stagedFiles), - - './**/*.{ts,tsx,vue,mts}': (stagedFiles) => [ - ...formatAndEslint(stagedFiles), - 'pnpm typecheck' - ] -} - -function formatAndEslint(fileNames) { - // Convert absolute paths to relative paths for better ESLint resolution - const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f)) - const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ') - return [ - `pnpm exec prettier --cache --write ${joinedPaths}`, - `pnpm exec oxlint --fix ${joinedPaths}`, - `pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}` - ] -} - - diff --git a/lint-staged.config.ts b/lint-staged.config.ts index 89deaa4b0..c239881f8 100644 --- a/lint-staged.config.ts +++ b/lint-staged.config.ts @@ -1,6 +1,9 @@ import path from 'node:path' export default { + 'tests-ui/**': () => + 'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1', + './**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles), './**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [ @@ -14,7 +17,7 @@ function formatAndEslint(fileNames: string[]) { const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f)) const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ') return [ - `pnpm exec prettier --cache --write ${joinedPaths}`, + `pnpm exec oxfmt --write ${joinedPaths}`, `pnpm exec oxlint --fix ${joinedPaths}`, `pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}` ] diff --git a/manifest.json b/manifest.json index ad419b437..8d22f6423 100644 --- a/manifest.json +++ b/manifest.json @@ -11,6 +11,6 @@ } ], "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#000000" + "background_color": "#172dd7", + "theme_color": "#f0ff41" } diff --git a/package.json b/package.json index 72eb659c3..b01b6dbe5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.38.7", + "version": "1.38.12", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -22,10 +22,8 @@ "dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve", "dev": "nx serve", "devtools:pycheck": "python3 -m compileall -q tools/devtools", - "format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", - "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", - "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different", - "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different", + "format:check": "oxfmt --check", + "format": "oxfmt --write", "json-schema": "tsx scripts/generate-json-schema.ts", "knip:no-cache": "knip", "knip": "knip --cache", @@ -63,14 +61,12 @@ "@nx/vite": "catalog:", "@pinia/testing": "catalog:", "@playwright/test": "catalog:", - "@prettier/plugin-oxc": "catalog:", "@sentry/vite-plugin": "catalog:", "@storybook/addon-docs": "catalog:", "@storybook/addon-mcp": "catalog:", "@storybook/vue3": "catalog:", "@storybook/vue3-vite": "catalog:", "@tailwindcss/vite": "catalog:", - "@trivago/prettier-plugin-sort-imports": "catalog:", "@types/fs-extra": "catalog:", "@types/jsdom": "catalog:", "@types/node": "catalog:", @@ -101,11 +97,11 @@ "markdown-table": "catalog:", "mixpanel-browser": "catalog:", "nx": "catalog:", + "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "picocolors": "catalog:", "postcss-html": "catalog:", - "prettier": "catalog:", "pretty-bytes": "catalog:", "rollup-plugin-visualizer": "catalog:", "storybook": "catalog:", @@ -173,6 +169,7 @@ "firebase": "catalog:", "fuse.js": "^7.0.0", "glob": "^11.0.3", + "jsonata": "catalog:", "jsondiffpatch": "^0.6.0", "loglevel": "^1.9.2", "marked": "^15.0.11", diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 1fdf23af1..647b3756c 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -584,8 +584,6 @@ body { height: 100vh; margin: 0; overflow: hidden; - background: var(--bg-color) var(--bg-img); - color: var(--fg-color); min-height: -webkit-fill-available; max-height: -webkit-fill-available; min-width: -webkit-fill-available; diff --git a/packages/design-system/src/icons/nodeSlot2.svg b/packages/design-system/src/icons/nodeSlot2.svg deleted file mode 100644 index cc1280570..000000000 --- a/packages/design-system/src/icons/nodeSlot2.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - diff --git a/packages/design-system/src/icons/nodeSlot3.svg b/packages/design-system/src/icons/nodeSlot3.svg deleted file mode 100644 index fc94a178b..000000000 --- a/packages/design-system/src/icons/nodeSlot3.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index ac15a78b1..75d8b8db2 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -120,8 +120,8 @@ describe('formatUtil', () => { }) it('should handle null and undefined gracefully', () => { - expect(getMediaTypeFromFilename(null as any)).toBe('image') - expect(getMediaTypeFromFilename(undefined as any)).toBe('image') + expect(getMediaTypeFromFilename(null)).toBe('image') + expect(getMediaTypeFromFilename(undefined)).toBe('image') }) it('should handle special characters in filenames', () => { diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 032d1c9ed..fde399eb9 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -537,7 +537,9 @@ export function truncateFilename( * @param filename The filename to analyze * @returns The media type: 'image', 'video', 'audio', or '3D' */ -export function getMediaTypeFromFilename(filename: string): MediaType { +export function getMediaTypeFromFilename( + filename: string | null | undefined +): MediaType { if (!filename) return 'image' const ext = filename.split('.').pop()?.toLowerCase() if (!ext) return 'image' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8011e1c10..eced908c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,6 @@ catalogs: '@playwright/test': specifier: ^1.57.0 version: 1.57.0 - '@prettier/plugin-oxc': - specifier: ^0.1.3 - version: 0.1.3 '@primeuix/forms': specifier: 0.0.2 version: 0.0.2 @@ -96,9 +93,6 @@ catalogs: '@tailwindcss/vite': specifier: ^4.1.12 version: 4.1.12 - '@trivago/prettier-plugin-sort-imports': - specifier: ^5.2.0 - version: 5.2.2 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -192,6 +186,9 @@ catalogs: jsdom: specifier: ^27.4.0 version: 27.4.0 + jsonata: + specifier: ^2.1.0 + version: 2.1.0 knip: specifier: ^5.75.1 version: 5.75.1 @@ -207,6 +204,9 @@ catalogs: nx: specifier: 22.2.6 version: 22.2.6 + oxfmt: + specifier: ^0.26.0 + version: 0.26.0 oxlint: specifier: ^1.33.0 version: 1.33.0 @@ -222,9 +222,6 @@ catalogs: postcss-html: specifier: ^1.8.0 version: 1.8.0 - prettier: - specifier: ^3.7.4 - version: 3.7.4 pretty-bytes: specifier: ^7.1.0 version: 7.1.0 @@ -455,6 +452,9 @@ importers: glob: specifier: ^11.0.3 version: 11.0.3 + jsonata: + specifier: 'catalog:' + version: 2.1.0 jsondiffpatch: specifier: ^0.6.0 version: 0.6.0 @@ -540,9 +540,6 @@ importers: '@playwright/test': specifier: 'catalog:' version: 1.57.0 - '@prettier/plugin-oxc': - specifier: 'catalog:' - version: 0.1.3 '@sentry/vite-plugin': specifier: 'catalog:' version: 4.6.0 @@ -561,9 +558,6 @@ importers: '@tailwindcss/vite': specifier: 'catalog:' version: 4.1.12(vite@8.0.0-beta.8(@types/node@24.10.4)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) - '@trivago/prettier-plugin-sort-imports': - specifier: 'catalog:' - version: 5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4) '@types/fs-extra': specifier: 'catalog:' version: 11.0.4 @@ -654,6 +648,9 @@ importers: nx: specifier: 'catalog:' version: 22.2.6 + oxfmt: + specifier: 'catalog:' + version: 0.26.0 oxlint: specifier: 'catalog:' version: 1.33.0(oxlint-tsgolint@0.9.1) @@ -666,9 +663,6 @@ importers: postcss-html: specifier: 'catalog:' version: 1.8.0 - prettier: - specifier: 'catalog:' - version: 3.7.4 pretty-bytes: specifier: 'catalog:' version: 7.1.0 @@ -2517,95 +2511,6 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - '@oxc-parser/binding-android-arm64@0.99.0': - resolution: {integrity: sha512-V4jhmKXgQQdRnm73F+r3ZY4pUEsijQeSraFeaCGng7abSNJGs76X6l82wHnmjLGFAeY00LWtjcELs7ZmbJ9+lA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxc-parser/binding-darwin-arm64@0.99.0': - resolution: {integrity: sha512-Rp41nf9zD5FyLZciS9l1GfK8PhYqrD5kEGxyTOA2esTLeAy37rZxetG2E3xteEolAkeb2WDkVrlxPtibeAncMg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxc-parser/binding-darwin-x64@0.99.0': - resolution: {integrity: sha512-WVonp40fPPxo5Gs0POTI57iEFv485TvNKOHMwZRhigwZRhZY2accEAkYIhei9eswF4HN5B44Wybkz7Gd1Qr/5Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxc-parser/binding-freebsd-x64@0.99.0': - resolution: {integrity: sha512-H30bjOOttPmG54gAqu6+HzbLEzuNOYO2jZYrIq4At+NtLJwvNhXz28Hf5iEAFZIH/4hMpLkM4VN7uc+5UlNW3Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxc-parser/binding-linux-arm-gnueabihf@0.99.0': - resolution: {integrity: sha512-0Z/Th0SYqzSRDPs6tk5lQdW0i73UCupnim3dgq2oW0//UdLonV/5wIZCArfKGC7w9y4h8TxgXpgtIyD1kKzzlQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm-musleabihf@0.99.0': - resolution: {integrity: sha512-xo0wqNd5bpbzQVNpAIFbHk1xa+SaS/FGBABCd942SRTnrpxl6GeDj/s1BFaGcTl8MlwlKVMwOcyKrw/2Kdfquw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm64-gnu@0.99.0': - resolution: {integrity: sha512-u26I6LKoLTPTd4Fcpr0aoAtjnGf5/ulMllo+QUiBhupgbVCAlaj4RyXH/mvcjcsl2bVBv9E/gYJZz2JjxQWXBA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxc-parser/binding-linux-arm64-musl@0.99.0': - resolution: {integrity: sha512-qhftDo2D37SqCEl3ZTa367NqWSZNb1Ddp34CTmShLKFrnKdNiUn55RdokLnHtf1AL5ssaQlYDwBECX7XiBWOhw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxc-parser/binding-linux-riscv64-gnu@0.99.0': - resolution: {integrity: sha512-zxn/xkf519f12FKkpL5XwJipsylfSSnm36h6c1zBDTz4fbIDMGyIhHfWfwM7uUmHo9Aqw1pLxFpY39Etv398+Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxc-parser/binding-linux-s390x-gnu@0.99.0': - resolution: {integrity: sha512-Y1eSDKDS5E4IVC7Oxw+NbYAKRmJPMJTIjW+9xOWwteDHkFqpocKe0USxog+Q1uhzalD9M0p9eXWEWdGQCMDBMQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@oxc-parser/binding-linux-x64-gnu@0.99.0': - resolution: {integrity: sha512-YVJMfk5cFWB8i2/nIrbk6n15bFkMHqWnMIWkVx7r2KwpTxHyFMfu2IpeVKo1ITDSmt5nBrGdLHD36QRlu2nDLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxc-parser/binding-linux-x64-musl@0.99.0': - resolution: {integrity: sha512-2+SDPrie5f90A1b9EirtVggOgsqtsYU5raZwkDYKyS1uvJzjqHCDhG/f4TwQxHmIc5YkczdQfwvN91lwmjsKYQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxc-parser/binding-wasm32-wasi@0.99.0': - resolution: {integrity: sha512-DKA4j0QerUWSMADziLM5sAyM7V53Fj95CV9SjP77bPfEfT7MnvFKnneaRMqPK1cpzjAGiQF52OBUIKyk0dwOQA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.99.0': - resolution: {integrity: sha512-EaB3AvsxqdNUhh9FOoAxRZ2L4PCRwDlDb//QXItwyOJrX7XS+uGK9B1KEUV4FZ/7rDhHsWieLt5e07wl2Ti5AQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxc-parser/binding-win32-x64-msvc@0.99.0': - resolution: {integrity: sha512-sJN1Q8h7ggFOyDn0zsHaXbP/MklAVUvhrbq0LA46Qum686P3SZQHjbATqJn9yaVEvaSKXCshgl0vQ1gWkGgpcQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@oxc-project/runtime@0.108.0': resolution: {integrity: sha512-J1cESY4anMO4i9KtCPmCfQAzAR00Uw4SWsDPFP10CIwDMugkh34UrTKByuYKuPaHy0XAk8LlJiZJq2OLMfbuIQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2613,9 +2518,6 @@ packages: '@oxc-project/types@0.108.0': resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==} - '@oxc-project/types@0.99.0': - resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==} - '@oxc-resolver/binding-android-arm-eabi@11.15.0': resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==} cpu: [arm] @@ -2716,6 +2618,46 @@ packages: cpu: [x64] os: [win32] + '@oxfmt/darwin-arm64@0.26.0': + resolution: {integrity: sha512-AAGc+8CffkiWeVgtWf4dPfQwHEE5c/j/8NWH7VGVxxJRCZFdmWcqCXprvL2H6qZFewvDLrFbuSPRCqYCpYGaTQ==} + cpu: [arm64] + os: [darwin] + + '@oxfmt/darwin-x64@0.26.0': + resolution: {integrity: sha512-xFx5ijCTjw577wJvFlZEMmKDnp3HSCcbYdCsLRmC5i3TZZiDe9DEYh3P46uqhzj8BkEw1Vm1ZCWdl48aEYAzvQ==} + cpu: [x64] + os: [darwin] + + '@oxfmt/linux-arm64-gnu@0.26.0': + resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==} + cpu: [arm64] + os: [linux] + + '@oxfmt/linux-arm64-musl@0.26.0': + resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==} + cpu: [arm64] + os: [linux] + + '@oxfmt/linux-x64-gnu@0.26.0': + resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==} + cpu: [x64] + os: [linux] + + '@oxfmt/linux-x64-musl@0.26.0': + resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==} + cpu: [x64] + os: [linux] + + '@oxfmt/win32-arm64@0.26.0': + resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==} + cpu: [arm64] + os: [win32] + + '@oxfmt/win32-x64@0.26.0': + resolution: {integrity: sha512-m8TfIljU22i9UEIkD+slGPifTFeaCwIUfxszN3E6ABWP1KQbtwSw9Ak0TdoikibvukF/dtbeyG3WW63jv9DnEg==} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/darwin-arm64@0.9.1': resolution: {integrity: sha512-vk+8kChWqN+F+QUOvp4/6jDTlDCzXPgYGkxdi6EOUSOmCP1ix0uYOlIi/ytH2imXmC8YfPgLR/1BhqbsuDKuew==} cpu: [arm64] @@ -2824,10 +2766,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@prettier/plugin-oxc@0.1.3': - resolution: {integrity: sha512-aABz3zIRilpWMekbt1FL1JVBQrQLR8L4Td2SRctECrWSsXGTNn/G1BqNSKCdbvQS1LWstAXfqcXzDki7GAAJyg==} - engines: {node: '>=14'} - '@primeuix/forms@0.0.2': resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==} engines: {node: '>=12.11.0'} @@ -3571,22 +3509,6 @@ packages: '@tmcp/auth': optional: true - '@trivago/prettier-plugin-sort-imports@5.2.2': - resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} - engines: {node: '>18.12'} - peerDependencies: - '@vue/compiler-sfc': 3.x - prettier: 2.x - 3.x - prettier-plugin-svelte: 3.x - svelte: 4.x || 5.x - peerDependenciesMeta: - '@vue/compiler-sfc': - optional: true - prettier-plugin-svelte: - optional: true - svelte: - optional: true - '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -6039,9 +5961,6 @@ packages: engines: {node: '>=10'} hasBin: true - javascript-natural-sort@0.7.1: - resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} - jest-diff@30.2.0: resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6132,6 +6051,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsonata@2.1.0: + resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==} + engines: {node: '>= 8'} + jsonc-eslint-parser@2.4.0: resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6889,13 +6812,14 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-parser@0.99.0: - resolution: {integrity: sha512-MpS1lbd2vR0NZn1v0drpgu7RUFu3x9Rd0kxExObZc2+F+DIrV0BOMval/RO3BYGwssIOerII6iS8EbbpCCZQpQ==} - engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.15.0: resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==} + oxfmt@0.26.0: + resolution: {integrity: sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + oxlint-tsgolint@0.9.1: resolution: {integrity: sha512-w1lIvUDkkiAPFyo268SFGrdh1LQ3Lcs1XShES7I4X75TliQA0os5XJ5hNZ4lYsSevqcofgEtq4xq7rBumv69iQ==} hasBin: true @@ -7796,6 +7720,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@2.0.0: + resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -10677,59 +10605,10 @@ snapshots: '@one-ini/wasm@0.1.1': {} - '@oxc-parser/binding-android-arm64@0.99.0': - optional: true - - '@oxc-parser/binding-darwin-arm64@0.99.0': - optional: true - - '@oxc-parser/binding-darwin-x64@0.99.0': - optional: true - - '@oxc-parser/binding-freebsd-x64@0.99.0': - optional: true - - '@oxc-parser/binding-linux-arm-gnueabihf@0.99.0': - optional: true - - '@oxc-parser/binding-linux-arm-musleabihf@0.99.0': - optional: true - - '@oxc-parser/binding-linux-arm64-gnu@0.99.0': - optional: true - - '@oxc-parser/binding-linux-arm64-musl@0.99.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-gnu@0.99.0': - optional: true - - '@oxc-parser/binding-linux-s390x-gnu@0.99.0': - optional: true - - '@oxc-parser/binding-linux-x64-gnu@0.99.0': - optional: true - - '@oxc-parser/binding-linux-x64-musl@0.99.0': - optional: true - - '@oxc-parser/binding-wasm32-wasi@0.99.0': - dependencies: - '@napi-rs/wasm-runtime': 1.1.1 - optional: true - - '@oxc-parser/binding-win32-arm64-msvc@0.99.0': - optional: true - - '@oxc-parser/binding-win32-x64-msvc@0.99.0': - optional: true - '@oxc-project/runtime@0.108.0': {} '@oxc-project/types@0.108.0': {} - '@oxc-project/types@0.99.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.15.0': optional: true @@ -10792,6 +10671,30 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.15.0': optional: true + '@oxfmt/darwin-arm64@0.26.0': + optional: true + + '@oxfmt/darwin-x64@0.26.0': + optional: true + + '@oxfmt/linux-arm64-gnu@0.26.0': + optional: true + + '@oxfmt/linux-arm64-musl@0.26.0': + optional: true + + '@oxfmt/linux-x64-gnu@0.26.0': + optional: true + + '@oxfmt/linux-x64-musl@0.26.0': + optional: true + + '@oxfmt/win32-arm64@0.26.0': + optional: true + + '@oxfmt/win32-x64@0.26.0': + optional: true + '@oxlint-tsgolint/darwin-arm64@0.9.1': optional: true @@ -10866,10 +10769,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@prettier/plugin-oxc@0.1.3': - dependencies: - oxc-parser: 0.99.0 - '@primeuix/forms@0.0.2': dependencies: '@primeuix/utils': 0.3.2 @@ -11585,20 +11484,6 @@ snapshots: esm-env: 1.2.2 tmcp: 1.19.0(typescript@5.9.3) - '@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)': - dependencies: - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - javascript-natural-sort: 0.7.1 - lodash: 4.17.21 - prettier: 3.7.4 - optionalDependencies: - '@vue/compiler-sfc': 3.5.25 - transitivePeerDependencies: - - supports-color - '@tweenjs/tween.js@23.1.3': {} '@tybys/wasm-util@0.10.1': @@ -14431,8 +14316,6 @@ snapshots: filelist: 1.0.4 minimatch: 3.1.2 - javascript-natural-sort@0.7.1: {} - jest-diff@30.2.0: dependencies: '@jest/diff-sequences': 30.0.1 @@ -14530,6 +14413,8 @@ snapshots: json5@2.2.3: {} + jsonata@2.1.0: {} + jsonc-eslint-parser@2.4.0: dependencies: acorn: 8.15.0 @@ -15531,26 +15416,6 @@ snapshots: safe-push-apply: 1.0.0 optional: true - oxc-parser@0.99.0: - dependencies: - '@oxc-project/types': 0.99.0 - optionalDependencies: - '@oxc-parser/binding-android-arm64': 0.99.0 - '@oxc-parser/binding-darwin-arm64': 0.99.0 - '@oxc-parser/binding-darwin-x64': 0.99.0 - '@oxc-parser/binding-freebsd-x64': 0.99.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.99.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.99.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.99.0 - '@oxc-parser/binding-linux-arm64-musl': 0.99.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.99.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.99.0 - '@oxc-parser/binding-linux-x64-gnu': 0.99.0 - '@oxc-parser/binding-linux-x64-musl': 0.99.0 - '@oxc-parser/binding-wasm32-wasi': 0.99.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.99.0 - '@oxc-parser/binding-win32-x64-msvc': 0.99.0 - oxc-resolver@11.15.0: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.15.0 @@ -15574,6 +15439,19 @@ snapshots: '@oxc-resolver/binding-win32-ia32-msvc': 11.15.0 '@oxc-resolver/binding-win32-x64-msvc': 11.15.0 + oxfmt@0.26.0: + dependencies: + tinypool: 2.0.0 + optionalDependencies: + '@oxfmt/darwin-arm64': 0.26.0 + '@oxfmt/darwin-x64': 0.26.0 + '@oxfmt/linux-arm64-gnu': 0.26.0 + '@oxfmt/linux-arm64-musl': 0.26.0 + '@oxfmt/linux-x64-gnu': 0.26.0 + '@oxfmt/linux-x64-musl': 0.26.0 + '@oxfmt/win32-arm64': 0.26.0 + '@oxfmt/win32-x64': 0.26.0 + oxlint-tsgolint@0.9.1: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 0.9.1 @@ -15754,7 +15632,8 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.7.4: {} + prettier@3.7.4: + optional: true pretty-bytes@7.1.0: {} @@ -16717,6 +16596,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@2.0.0: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 76c515b7d..0c91ec424 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,6 @@ catalog: '@nx/vite': 22.2.6 '@pinia/testing': ^1.0.3 '@playwright/test': ^1.57.0 - '@prettier/plugin-oxc': ^0.1.3 '@primeuix/forms': 0.0.2 '@primeuix/styled': 0.3.2 '@primeuix/utils': ^0.3.2 @@ -33,7 +32,6 @@ catalog: '@storybook/vue3': ^10.1.9 '@storybook/vue3-vite': ^10.1.9 '@tailwindcss/vite': ^4.1.12 - '@trivago/prettier-plugin-sort-imports': ^5.2.0 '@types/fs-extra': ^11.0.4 '@types/jsdom': ^21.1.7 '@types/node': ^24.1.0 @@ -64,18 +62,19 @@ catalog: happy-dom: ^20.0.11 husky: ^9.1.7 jiti: 2.6.1 + jsonata: ^2.1.0 jsdom: ^27.4.0 knip: ^5.75.1 lint-staged: ^16.2.7 markdown-table: ^3.0.4 mixpanel-browser: ^2.71.0 nx: 22.2.6 + oxfmt: ^0.26.0 oxlint: ^1.33.0 oxlint-tsgolint: ^0.9.1 picocolors: ^1.1.1 pinia: ^3.0.4 postcss-html: ^1.8.0 - prettier: ^3.7.4 pretty-bytes: ^7.1.0 primeicons: ^7.0.0 primevue: ^4.2.5 diff --git a/src/App.vue b/src/App.vue index 6b7c56be0..c3a879b17 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,7 @@ diff --git a/src/components/common/UrlInput.test.ts b/src/components/common/UrlInput.test.ts index e3fc81d29..9c34c11c5 100644 --- a/src/components/common/UrlInput.test.ts +++ b/src/components/common/UrlInput.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createApp, nextTick } from 'vue' import UrlInput from './UrlInput.vue' +import type { ComponentProps } from 'vue-component-type-helpers' describe('UrlInput', () => { beforeEach(() => { @@ -14,7 +15,13 @@ describe('UrlInput', () => { app.use(PrimeVue) }) - const mountComponent = (props: any, options = {}) => { + const mountComponent = ( + props: ComponentProps & { + placeholder?: string + disabled?: boolean + }, + options = {} + ) => { return mount(UrlInput, { global: { plugins: [PrimeVue], @@ -169,25 +176,25 @@ describe('UrlInput', () => { await input.setValue(' https://leading-space.com') await input.trigger('input') await nextTick() - expect(wrapper.vm.internalValue).toBe('https://leading-space.com') + expect(input.element.value).toBe('https://leading-space.com') // Test trailing whitespace await input.setValue('https://trailing-space.com ') await input.trigger('input') await nextTick() - expect(wrapper.vm.internalValue).toBe('https://trailing-space.com') + expect(input.element.value).toBe('https://trailing-space.com') // Test both leading and trailing whitespace await input.setValue(' https://both-spaces.com ') await input.trigger('input') await nextTick() - expect(wrapper.vm.internalValue).toBe('https://both-spaces.com') + expect(input.element.value).toBe('https://both-spaces.com') // Test whitespace in the middle of the URL await input.setValue('https:// middle-space.com') await input.trigger('input') await nextTick() - expect(wrapper.vm.internalValue).toBe('https://middle-space.com') + expect(input.element.value).toBe('https://middle-space.com') }) it('trims whitespace when value set externally', async () => { @@ -196,15 +203,17 @@ describe('UrlInput', () => { placeholder: 'Enter URL' }) + const input = wrapper.find('input') + // Check initial value is trimmed - expect(wrapper.vm.internalValue).toBe('https://initial-value.com') + expect(input.element.value).toBe('https://initial-value.com') // Update props with whitespace await wrapper.setProps({ modelValue: ' https://updated-value.com ' }) await nextTick() // Check updated value is trimmed - expect(wrapper.vm.internalValue).toBe('https://updated-value.com') + expect(input.element.value).toBe('https://updated-value.com') }) }) }) diff --git a/src/components/common/UserAvatar.test.ts b/src/components/common/UserAvatar.test.ts index 0c6b26e4e..0b5df7052 100644 --- a/src/components/common/UserAvatar.test.ts +++ b/src/components/common/UserAvatar.test.ts @@ -1,3 +1,5 @@ +import type { ComponentProps } from 'vue-component-type-helpers' + import { mount } from '@vue/test-utils' import Avatar from 'primevue/avatar' import PrimeVue from 'primevue/config' @@ -27,7 +29,7 @@ describe('UserAvatar', () => { app.use(PrimeVue) }) - const mountComponent = (props: any = {}) => { + const mountComponent = (props: ComponentProps = {}) => { return mount(UserAvatar, { global: { plugins: [PrimeVue, i18n], diff --git a/src/components/common/VirtualGrid.vue b/src/components/common/VirtualGrid.vue index 134778778..fb6b4374c 100644 --- a/src/components/common/VirtualGrid.vue +++ b/src/components/common/VirtualGrid.vue @@ -1,16 +1,20 @@ @@ -28,19 +32,22 @@ type GridState = { const { items, + gridStyle, bufferRows = 1, scrollThrottle = 64, resizeDebounce = 64, defaultItemHeight = 200, - defaultItemWidth = 200 + defaultItemWidth = 200, + maxColumns = Infinity } = defineProps<{ items: (T & { key: string })[] - gridStyle: Partial + gridStyle: CSSProperties bufferRows?: number scrollThrottle?: number resizeDebounce?: number defaultItemHeight?: number defaultItemWidth?: number + maxColumns?: number }>() const emit = defineEmits<{ @@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, { eventListenerOptions: { passive: true } }) -const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1) +const cols = computed(() => + Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns) +) + +const mergedGridStyle = computed(() => { + if (maxColumns === Infinity) return gridStyle + return { + ...gridStyle, + gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))` + } +}) + const viewRows = computed(() => Math.ceil(height.value / itemHeight.value)) const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value)) const isValidGrid = computed(() => height.value && width.value && items?.length) @@ -83,6 +101,16 @@ const renderedItems = computed(() => isValidGrid.value ? items.slice(state.value.start, state.value.end) : [] ) +function rowsToHeight(rows: number): string { + return `${(rows / cols.value) * itemHeight.value}px` +} +const topSpacerStyle = computed(() => ({ + height: rowsToHeight(state.value.start) +})) +const bottomSpacerStyle = computed(() => ({ + height: rowsToHeight(items.length - state.value.end) +})) + whenever( () => state.value.isNearEnd, () => { @@ -109,15 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce) watch([width, height], onResize, { flush: 'post' }) whenever(() => items, updateItemSize, { flush: 'post' }) onBeforeUnmount(() => { - onResize.cancel() // Clear pending debounced calls + onResize.cancel() }) - - diff --git a/src/components/common/WorkspaceProfilePic.vue b/src/components/common/WorkspaceProfilePic.vue new file mode 100644 index 000000000..bc147a61c --- /dev/null +++ b/src/components/common/WorkspaceProfilePic.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/common/statusBadge.variants.ts b/src/components/common/statusBadge.variants.ts new file mode 100644 index 000000000..479a0dda8 --- /dev/null +++ b/src/components/common/statusBadge.variants.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from 'cva' +import { cva } from 'cva' + +export const statusBadgeVariants = cva({ + base: 'inline-flex items-center justify-center rounded-full', + variants: { + severity: { + default: 'bg-primary-background text-base-foreground', + secondary: 'bg-secondary-background text-base-foreground', + warn: 'bg-warning-background text-base-background', + danger: 'bg-destructive-background text-white', + contrast: 'bg-base-foreground text-base-background' + }, + variant: { + label: 'h-3.5 px-1 text-xxxs font-semibold uppercase', + dot: 'size-2', + circle: 'size-3.5 text-xxxs font-semibold' + } + }, + defaultVariants: { + severity: 'default', + variant: 'label' + } +}) + +export type StatusBadgeVariants = VariantProps diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index aeb98971b..0276435f1 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -3,17 +3,14 @@ :content-title="$t('templateWorkflows.title', 'Workflow Templates')" class="workflow-template-selector-dialog" > + + + diff --git a/src/components/dialog/content/setting/SettingItem.test.ts b/src/components/dialog/content/setting/SettingItem.test.ts index 17e3bab06..673fe5894 100644 --- a/src/components/dialog/content/setting/SettingItem.test.ts +++ b/src/components/dialog/content/setting/SettingItem.test.ts @@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({ })) describe('SettingItem', () => { - const mountComponent = (props: any, options = {}): any => { + const mountComponent = (props: Record, options = {}) => { return mount(SettingItem, { global: { plugins: [PrimeVue, i18n, createPinia()], @@ -32,6 +32,7 @@ describe('SettingItem', () => { 'i-material-symbols:experiment-outline': true } }, + // @ts-expect-error - Test utility accepts flexible props for testing edge cases props, ...options }) @@ -48,8 +49,9 @@ describe('SettingItem', () => { } }) - // Get the options property of the FormItem - const options = wrapper.vm.formItem.options + // Check the FormItem component's item prop for the options + const formItem = wrapper.findComponent({ name: 'FormItem' }) + const options = formItem.props('item').options expect(options).toEqual([ { text: 'Correctly Translated', value: 'Correctly Translated' } ]) @@ -67,7 +69,8 @@ describe('SettingItem', () => { }) // Should not throw an error and tooltip should be preserved as-is - expect(wrapper.vm.formItem.tooltip).toBe( + const formItem = wrapper.findComponent({ name: 'FormItem' }) + expect(formItem.props('item').tooltip).toBe( 'This will load a larger version of @mtb/markdown-parser that bundles shiki' ) }) diff --git a/src/components/dialog/content/setting/UsageLogsTable.test.ts b/src/components/dialog/content/setting/UsageLogsTable.test.ts index b98664668..72a4fbdba 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.test.ts +++ b/src/components/dialog/content/setting/UsageLogsTable.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' +import type { AuditLog } from '@/services/customerEventsService' import { EventType } from '@/services/customerEventsService' import UsageLogsTable from './UsageLogsTable.vue' @@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue' type ComponentInstance = InstanceType & { loading: boolean error: string | null - events: any[] + events: Partial[] pagination: { page: number limit: number diff --git a/src/components/dialog/content/setting/WorkspacePanel.vue b/src/components/dialog/content/setting/WorkspacePanel.vue new file mode 100644 index 000000000..aff8f3733 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanel.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue new file mode 100644 index 000000000..c4ac049e9 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspaceSidebarItem.vue b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue new file mode 100644 index 000000000..cab92c7a8 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/dialog/content/signin/ApiKeyForm.test.ts b/src/components/dialog/content/signin/ApiKeyForm.test.ts index 5d6a726b8..4d073cb08 100644 --- a/src/components/dialog/content/signin/ApiKeyForm.test.ts +++ b/src/components/dialog/content/signin/ApiKeyForm.test.ts @@ -1,3 +1,5 @@ +import type { ComponentProps } from 'vue-component-type-helpers' + import { Form } from '@primevue/forms' import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' @@ -63,7 +65,7 @@ describe('ApiKeyForm', () => { mockLoading.mockReset() }) - const mountComponent = (props: any = {}) => { + const mountComponent = (props: ComponentProps = {}) => { return mount(ApiKeyForm, { global: { plugins: [PrimeVue, createPinia(), i18n], diff --git a/src/components/dialog/content/signin/SignInForm.test.ts b/src/components/dialog/content/signin/SignInForm.test.ts index da898532a..c27d15929 100644 --- a/src/components/dialog/content/signin/SignInForm.test.ts +++ b/src/components/dialog/content/signin/SignInForm.test.ts @@ -112,8 +112,10 @@ describe('SignInForm', () => { // Mock getElementById to track focus const mockFocus = vi.fn() - const mockElement = { focus: mockFocus } - vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any) + const mockElement: Partial = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Click forgot password link while email is empty await forgotPasswordSpan.trigger('click') @@ -138,7 +140,10 @@ describe('SignInForm', () => { it('calls handleForgotPassword with email when link is clicked', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Spy on handleForgotPassword const handleForgotPasswordSpy = vi.spyOn( @@ -161,7 +166,10 @@ describe('SignInForm', () => { describe('Form Submission', () => { it('emits submit event when onSubmit is called with valid data', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Call onSubmit directly with valid data component.onSubmit({ @@ -181,7 +189,10 @@ describe('SignInForm', () => { it('does not emit submit event when form is invalid', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Call onSubmit with invalid form component.onSubmit({ valid: false, values: {} }) @@ -254,12 +265,17 @@ describe('SignInForm', () => { describe('Focus Behavior', () => { it('focuses email input when handleForgotPassword is called with invalid email', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Mock getElementById to track focus const mockFocus = vi.fn() - const mockElement = { focus: mockFocus } - vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any) + const mockElement: Partial = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Call handleForgotPassword with no email await component.handleForgotPassword('', false) @@ -273,12 +289,17 @@ describe('SignInForm', () => { it('does not focus email input when valid email is provided', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Mock getElementById const mockFocus = vi.fn() - const mockElement = { focus: mockFocus } - vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any) + const mockElement: Partial = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Call handleForgotPassword with valid email await component.handleForgotPassword('test@example.com', true) diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue new file mode 100644 index 000000000..6dca4c414 --- /dev/null +++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue new file mode 100644 index 000000000..dea2da18d --- /dev/null +++ b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue new file mode 100644 index 000000000..8fa6213ef --- /dev/null +++ b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/dialog/content/workspace/InviteMemberDialogContent.vue b/src/components/dialog/content/workspace/InviteMemberDialogContent.vue new file mode 100644 index 000000000..479a6b6d1 --- /dev/null +++ b/src/components/dialog/content/workspace/InviteMemberDialogContent.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue new file mode 100644 index 000000000..6a3d16c36 --- /dev/null +++ b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue b/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue new file mode 100644 index 000000000..2d085bec4 --- /dev/null +++ b/src/components/dialog/content/workspace/RemoveMemberDialogContent.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue b/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue new file mode 100644 index 000000000..1d9dacd9a --- /dev/null +++ b/src/components/dialog/content/workspace/RevokeInviteDialogContent.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 48d82e44b..370665899 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -160,6 +160,9 @@ import { isNativeWindow } from '@/utils/envUtil' import { forEachNode } from '@/utils/graphTraversalUtil' import SelectionRectangle from './SelectionRectangle.vue' +import { isCloud } from '@/platform/distribution/types' +import { useFeatureFlags } from '@/composables/useFeatureFlags' +import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader' const emit = defineEmits<{ ready: [] @@ -394,6 +397,9 @@ const loadCustomNodesI18n = async () => { const comfyAppReady = ref(false) const workflowPersistence = useWorkflowPersistence() +const { flags } = useFeatureFlags() +// Set up invite loader during setup phase so useRoute/useRouter work correctly +const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null useCanvasDrop(canvasRef) useLitegraphSettings() useNodeBadge() @@ -459,6 +465,22 @@ onMounted(async () => { // Load template from URL if present await workflowPersistence.loadTemplateFromUrlIfPresent() + // Accept workspace invite from URL if present (e.g., ?invite=TOKEN) + // Uses watch because feature flags load asynchronously - flag may be false initially + // then become true once remoteConfig or websocket features are loaded + if (inviteUrlLoader) { + const stopWatching = watch( + () => flags.teamWorkspacesEnabled, + async (enabled) => { + if (enabled) { + stopWatching() + await inviteUrlLoader.loadInviteFromUrl() + } + }, + { immediate: true } + ) + } + // Initialize release store to fetch releases from comfy-api (fire-and-forget) const { useReleaseStore } = await import('@/platform/updates/common/releaseStore') diff --git a/src/components/graph/SelectionToolbox.test.ts b/src/components/graph/SelectionToolbox.test.ts index 0938a3fe3..441ca027f 100644 --- a/src/components/graph/SelectionToolbox.test.ts +++ b/src/components/graph/SelectionToolbox.test.ts @@ -5,9 +5,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import SelectionToolbox from '@/components/graph/SelectionToolbox.vue' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useExtensionService } from '@/services/extensionService' +import { + createMockCanvas, + createMockPositionable +} from '@/utils/__tests__/litegraphTestUtils' + +function createMockExtensionService(): ReturnType { + return { + extensionCommands: { value: new Map() }, + loadExtensions: vi.fn(), + registerExtension: vi.fn(), + invokeExtensions: vi.fn(() => []), + invokeExtensionsAsync: vi.fn() + } as Partial> as ReturnType< + typeof useExtensionService + > +} // Mock the composables and services vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ @@ -112,12 +129,7 @@ describe('SelectionToolbox', () => { canvasStore = useCanvasStore() // Mock the canvas to avoid "getCanvas: canvas is null" errors - canvasStore.canvas = { - setDirty: vi.fn(), - state: { - selectionChanged: false - } - } as any + canvasStore.canvas = createMockCanvas() vi.resetAllMocks() }) @@ -184,30 +196,27 @@ describe('SelectionToolbox', () => { describe('Button Visibility Logic', () => { beforeEach(() => { const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) }) it('should show info button only for single selections', () => { // Single node selection - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.info-button').exists()).toBe(true) // Multiple node selection canvasStore.selectedItems = [ - { type: 'TestNode1' }, - { type: 'TestNode2' } - ] as any + createMockPositionable(), + createMockPositionable() + ] wrapper.unmount() const wrapper2 = mountComponent() expect(wrapper2.find('.info-button').exists()).toBe(false) }) it('should not show info button when node definition is not found', () => { - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] // mock nodedef and return null nodeDefMock = null // remount component @@ -217,7 +226,7 @@ describe('SelectionToolbox', () => { it('should show color picker for all selections', () => { // Single node selection - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe( true @@ -225,9 +234,9 @@ describe('SelectionToolbox', () => { // Multiple node selection canvasStore.selectedItems = [ - { type: 'TestNode1' }, - { type: 'TestNode2' } - ] as any + createMockPositionable(), + createMockPositionable() + ] wrapper.unmount() const wrapper2 = mountComponent() expect( @@ -237,15 +246,15 @@ describe('SelectionToolbox', () => { it('should show frame nodes only for multiple selections', () => { // Single node selection - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.frame-nodes').exists()).toBe(false) // Multiple node selection canvasStore.selectedItems = [ - { type: 'TestNode1' }, - { type: 'TestNode2' } - ] as any + createMockPositionable(), + createMockPositionable() + ] wrapper.unmount() const wrapper2 = mountComponent() expect(wrapper2.find('.frame-nodes').exists()).toBe(true) @@ -253,22 +262,22 @@ describe('SelectionToolbox', () => { it('should show bypass button for appropriate selections', () => { // Single node selection - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true) // Multiple node selection canvasStore.selectedItems = [ - { type: 'TestNode1' }, - { type: 'TestNode2' } - ] as any + createMockPositionable(), + createMockPositionable() + ] wrapper.unmount() const wrapper2 = mountComponent() expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true) }) it('should show common buttons for all selections', () => { - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true) @@ -286,13 +295,13 @@ describe('SelectionToolbox', () => { // Single image node isImageNodeSpy.mockReturnValue(true) - canvasStore.selectedItems = [{ type: 'ImageNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.mask-editor-button').exists()).toBe(true) // Single non-image node isImageNodeSpy.mockReturnValue(false) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] wrapper.unmount() const wrapper2 = mountComponent() expect(wrapper2.find('.mask-editor-button').exists()).toBe(false) @@ -304,13 +313,13 @@ describe('SelectionToolbox', () => { // Single Load3D node isLoad3dNodeSpy.mockReturnValue(true) - canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true) // Single non-Load3D node isLoad3dNodeSpy.mockReturnValue(false) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] wrapper.unmount() const wrapper2 = mountComponent() expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false) @@ -326,17 +335,17 @@ describe('SelectionToolbox', () => { // With output node selected isOutputNodeSpy.mockReturnValue(true) - filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any) - canvasStore.selectedItems = [ - { type: 'SaveImage', constructor: { nodeData: { output_node: true } } } - ] as any + filterOutputNodesSpy.mockReturnValue([ + { type: 'SaveImage' } + ] as LGraphNode[]) + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.execute-button').exists()).toBe(true) // Without output node selected isOutputNodeSpy.mockReturnValue(false) filterOutputNodesSpy.mockReturnValue([]) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] wrapper.unmount() const wrapper2 = mountComponent() expect(wrapper2.find('.execute-button').exists()).toBe(false) @@ -352,7 +361,7 @@ describe('SelectionToolbox', () => { describe('Divider Visibility Logic', () => { it('should show dividers between button groups when both groups have buttons', () => { // Setup single node to show info + other buttons - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() const dividers = wrapper.findAll('.vertical-divider') @@ -378,10 +387,13 @@ describe('SelectionToolbox', () => { ['test-command', { id: 'test-command', title: 'Test Command' }] ]) }, - invokeExtensions: vi.fn(() => ['test-command']) - } as any) + loadExtensions: vi.fn(), + registerExtension: vi.fn(), + invokeExtensions: vi.fn(() => ['test-command']), + invokeExtensionsAsync: vi.fn() + } as ReturnType) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.extension-command-button').exists()).toBe(true) @@ -389,12 +401,9 @@ describe('SelectionToolbox', () => { it('should not render extension commands when none available', () => { const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() expect(wrapper.find('.extension-command-button').exists()).toBe(false) @@ -404,12 +413,9 @@ describe('SelectionToolbox', () => { describe('Container Styling', () => { it('should apply minimap container styles', () => { const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() const panel = wrapper.find('.panel') @@ -418,12 +424,9 @@ describe('SelectionToolbox', () => { it('should have correct CSS classes', () => { const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() const panel = wrapper.find('.panel') @@ -435,12 +438,9 @@ describe('SelectionToolbox', () => { it('should handle animation class conditionally', () => { const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() const panel = wrapper.find('.panel') @@ -453,16 +453,18 @@ describe('SelectionToolbox', () => { const mockCanvasInteractions = vi.mocked(useCanvasInteractions) const forwardEventToCanvasSpy = vi.fn() mockCanvasInteractions.mockReturnValue({ - forwardEventToCanvas: forwardEventToCanvasSpy - } as any) + handleWheel: vi.fn(), + handlePointer: vi.fn(), + forwardEventToCanvas: forwardEventToCanvasSpy, + shouldHandleNodePointerEvents: { value: true } as ReturnType< + typeof useCanvasInteractions + >['shouldHandleNodePointerEvents'] + } as ReturnType) const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) - canvasStore.selectedItems = [{ type: 'TestNode' }] as any + canvasStore.selectedItems = [createMockPositionable()] const wrapper = mountComponent() const panel = wrapper.find('.panel') @@ -475,10 +477,7 @@ describe('SelectionToolbox', () => { describe('No Selection State', () => { beforeEach(() => { const mockExtensionService = vi.mocked(useExtensionService) - mockExtensionService.mockReturnValue({ - extensionCommands: { value: new Map() }, - invokeExtensions: vi.fn(() => []) - } as any) + mockExtensionService.mockReturnValue(createMockExtensionService()) }) it('should hide most buttons when no items selected', () => { diff --git a/src/components/graph/selectionToolbox/BypassButton.test.ts b/src/components/graph/selectionToolbox/BypassButton.test.ts index 9fdcd971f..a6fa3ff94 100644 --- a/src/components/graph/selectionToolbox/BypassButton.test.ts +++ b/src/components/graph/selectionToolbox/BypassButton.test.ts @@ -6,14 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCommandStore } from '@/stores/commandStore' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' -const mockLGraphNode = { - type: 'TestNode', - title: 'Test Node', - mode: LGraphEventMode.ALWAYS +function getMockLGraphNode(): LGraphNode { + return createMockLGraphNode({ type: 'TestNode' }) } vi.mock('@/utils/litegraphUtil', () => ({ @@ -59,21 +59,21 @@ describe('BypassButton', () => { } it('should render bypass button', () => { - canvasStore.selectedItems = [mockLGraphNode] as any + canvasStore.selectedItems = [getMockLGraphNode()] const wrapper = mountComponent() const button = wrapper.find('button') expect(button.exists()).toBe(true) }) it('should have correct test id', () => { - canvasStore.selectedItems = [mockLGraphNode] as any + canvasStore.selectedItems = [getMockLGraphNode()] const wrapper = mountComponent() const button = wrapper.find('[data-testid="bypass-button"]') expect(button.exists()).toBe(true) }) it('should execute bypass command when clicked', async () => { - canvasStore.selectedItems = [mockLGraphNode] as any + canvasStore.selectedItems = [getMockLGraphNode()] const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue() const wrapper = mountComponent() @@ -85,8 +85,10 @@ describe('BypassButton', () => { }) it('should show bypassed styling when node is bypassed', async () => { - const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS } - canvasStore.selectedItems = [bypassedNode] as any + const bypassedNode = Object.assign(getMockLGraphNode(), { + mode: LGraphEventMode.BYPASS + }) + canvasStore.selectedItems = [bypassedNode] vi.spyOn(commandStore, 'execute').mockResolvedValue() const wrapper = mountComponent() @@ -100,7 +102,7 @@ describe('BypassButton', () => { it('should handle multiple selected items', () => { vi.spyOn(commandStore, 'execute').mockResolvedValue() - canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any + canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()] const wrapper = mountComponent() const button = wrapper.find('button') expect(button.exists()).toBe(true) diff --git a/src/components/graph/selectionToolbox/ColorPickerButton.test.ts b/src/components/graph/selectionToolbox/ColorPickerButton.test.ts index ccf07e8b9..cc4d7d690 100644 --- a/src/components/graph/selectionToolbox/ColorPickerButton.test.ts +++ b/src/components/graph/selectionToolbox/ColorPickerButton.test.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import PrimeVue from 'primevue/config' @@ -8,7 +9,20 @@ import { createI18n } from 'vue-i18n' // Import after mocks import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils' + +function createMockWorkflow( + overrides: Partial = {} +): LoadedComfyWorkflow { + return { + changeTracker: { + checkState: vi.fn() as Mock + }, + ...overrides + } as Partial as LoadedComfyWorkflow +} // Mock the litegraph module vi.mock('@/lib/litegraph/src/litegraph', async () => { @@ -70,11 +84,7 @@ describe('ColorPickerButton', () => { canvasStore.selectedItems = [] // Mock workflow store - workflowStore.activeWorkflow = { - changeTracker: { - checkState: vi.fn() - } - } as any + workflowStore.activeWorkflow = createMockWorkflow() }) const createWrapper = () => { @@ -90,13 +100,13 @@ describe('ColorPickerButton', () => { it('should render when nodes are selected', () => { // Add a mock node to selectedItems - canvasStore.selectedItems = [{ type: 'LGraphNode' } as any] + canvasStore.selectedItems = [createMockPositionable()] const wrapper = createWrapper() expect(wrapper.find('button').exists()).toBe(true) }) it('should toggle color picker visibility on button click', async () => { - canvasStore.selectedItems = [{ type: 'LGraphNode' } as any] + canvasStore.selectedItems = [createMockPositionable()] const wrapper = createWrapper() const button = wrapper.find('button') diff --git a/src/components/graph/selectionToolbox/ExecuteButton.test.ts b/src/components/graph/selectionToolbox/ExecuteButton.test.ts index b75977b43..6fe236d5b 100644 --- a/src/components/graph/selectionToolbox/ExecuteButton.test.ts +++ b/src/components/graph/selectionToolbox/ExecuteButton.test.ts @@ -1,23 +1,16 @@ import { mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import PrimeVue from 'primevue/config' import Tooltip from 'primevue/tooltip' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue' +import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCommandStore } from '@/stores/commandStore' -// Mock the stores -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: vi.fn() -})) - -vi.mock('@/stores/commandStore', () => ({ - useCommandStore: vi.fn() -})) - // Mock the utils vi.mock('@/utils/litegraphUtil', () => ({ isLGraphNode: vi.fn((node) => !!node?.type) @@ -37,10 +30,8 @@ vi.mock('@/composables/graph/useSelectionState', () => ({ })) describe('ExecuteButton', () => { - let mockCanvas: any - let mockCanvasStore: any - let mockCommandStore: any - let mockSelectedNodes: any[] + let mockCanvas: LGraphCanvas + let mockSelectedNodes: LGraphNode[] const i18n = createI18n({ legacy: false, @@ -57,27 +48,27 @@ describe('ExecuteButton', () => { }) beforeEach(async () => { - setActivePinia(createPinia()) + // Set up Pinia with testing utilities + setActivePinia( + createTestingPinia({ + createSpy: vi.fn + }) + ) // Reset mocks - mockCanvas = { + const partialCanvas: Partial = { setDirty: vi.fn() } + mockCanvas = partialCanvas as Partial as LGraphCanvas mockSelectedNodes = [] - mockCanvasStore = { - getCanvas: vi.fn(() => mockCanvas), - selectedItems: [] - } + // Get store instances and mock methods + const canvasStore = useCanvasStore() + const commandStore = useCommandStore() - mockCommandStore = { - execute: vi.fn() - } - - // Setup store mocks - vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any) - vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any) + vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas) + vi.spyOn(commandStore, 'execute').mockResolvedValue() // Update the useSelectionState mock const { useSelectionState } = vi.mocked( @@ -87,7 +78,7 @@ describe('ExecuteButton', () => { selectedNodes: { value: mockSelectedNodes } - } as any) + } as ReturnType) vi.clearAllMocks() }) @@ -114,15 +105,16 @@ describe('ExecuteButton', () => { describe('Click Handler', () => { it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => { + const commandStore = useCommandStore() const wrapper = mountComponent() const button = wrapper.find('button') await button.trigger('click') - expect(mockCommandStore.execute).toHaveBeenCalledWith( + expect(commandStore.execute).toHaveBeenCalledWith( 'Comfy.QueueSelectedOutputNodes' ) - expect(mockCommandStore.execute).toHaveBeenCalledTimes(1) + expect(commandStore.execute).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index d6b3edcd8..626709593 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -200,7 +200,13 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { if (item.state === 'running' || item.state === 'initialization') { // Running/initializing jobs: interrupt execution - await api.interrupt(promptId) + // Cloud backend uses deleteItem, local uses interrupt + if (isCloud) { + await api.deleteItem('queue', promptId) + } else { + await api.interrupt(promptId) + } + executionStore.clearInitializationByPromptId(promptId) await queueStore.update() } else if (item.state === 'pending') { // Pending jobs: remove from queue @@ -268,7 +274,15 @@ const inspectJobAsset = wrapWithErrorHandlingAsync( ) const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { + // Capture pending promptIds before clearing + const pendingPromptIds = queueStore.pendingTasks + .map((task) => task.promptId) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + await commandStore.execute('Comfy.ClearPendingTasks') + + // Clear initialization state for removed prompts + executionStore.clearInitializationByPromptIds(pendingPromptIds) }) const interruptAll = wrapWithErrorHandlingAsync(async () => { @@ -284,10 +298,14 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => { // on cloud to ensure we cancel the workflow the user clicked. if (isCloud) { await Promise.all(promptIds.map((id) => api.deleteItem('queue', id))) + executionStore.clearInitializationByPromptIds(promptIds) + await queueStore.update() return } await Promise.all(promptIds.map((id) => api.interrupt(id))) + executionStore.clearInitializationByPromptIds(promptIds) + await queueStore.update() }) const showClearHistoryDialog = () => { diff --git a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue index d3ee425dd..0b1b88820 100644 --- a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue +++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue @@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil' import TransitionCollapse from './TransitionCollapse.vue' -const props = defineProps<{ +const { + disabled, + label, + enableEmptyState, + tooltip, + class: className +} = defineProps<{ disabled?: boolean label?: string enableEmptyState?: boolean tooltip?: string + class?: string }>() const isCollapse = defineModel('collapse', { default: false }) -const isExpanded = computed(() => !isCollapse.value && !props.disabled) +const isExpanded = computed(() => !isCollapse.value && !disabled) const tooltipConfig = computed(() => { - if (!props.tooltip) return undefined - return { value: props.tooltip, showDelay: 1000 } + if (!tooltip) return undefined + return { value: tooltip, showDelay: 1000 } }) diff --git a/src/components/topbar/WorkspaceSwitcherPopover.vue b/src/components/topbar/WorkspaceSwitcherPopover.vue new file mode 100644 index 000000000..631514598 --- /dev/null +++ b/src/components/topbar/WorkspaceSwitcherPopover.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/components/ui/AGENTS.md b/src/components/ui/AGENTS.md new file mode 100644 index 000000000..53b9979b7 --- /dev/null +++ b/src/components/ui/AGENTS.md @@ -0,0 +1,19 @@ +# UI Component Guidelines + +## Adding New Components + +```bash +pnpm dlx shadcn-vue@latest add --yes +``` + +After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories. + +## Reka UI Wrapper Components + +- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps()` +- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults +- Import siblings directly (`./Component.vue`), not from barrel (`'.'`) +- Use `cn()` for class merging with `className` +- Use Iconify icons: `` +- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default` +- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]` diff --git a/src/components/ui/button/button.variants.ts b/src/components/ui/button/button.variants.ts index 768332325..faf9a4444 100644 --- a/src/components/ui/button/button.variants.ts +++ b/src/components/ui/button/button.variants.ts @@ -26,7 +26,8 @@ export const buttonVariants = cva({ md: 'h-8 rounded-lg p-2 text-xs', lg: 'h-10 rounded-lg px-4 py-2 text-sm', icon: 'size-8', - 'icon-sm': 'size-5 p-0' + 'icon-sm': 'size-5 p-0', + unset: '' } }, diff --git a/src/components/ui/select/Select.stories.ts b/src/components/ui/select/Select.stories.ts new file mode 100644 index 000000000..ba2a37e48 --- /dev/null +++ b/src/components/ui/select/Select.stories.ts @@ -0,0 +1,261 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import Select from './Select.vue' +import SelectContent from './SelectContent.vue' +import SelectGroup from './SelectGroup.vue' +import SelectItem from './SelectItem.vue' +import SelectLabel from './SelectLabel.vue' +import SelectSeparator from './SelectSeparator.vue' +import SelectTrigger from './SelectTrigger.vue' +import SelectValue from './SelectValue.vue' + +const meta = { + title: 'Components/Select', + component: Select, + tags: ['autodocs'], + argTypes: { + modelValue: { + control: 'text', + description: 'Selected value' + }, + disabled: { + control: 'boolean', + description: 'When true, disables the select' + }, + 'onUpdate:modelValue': { action: 'update:modelValue' } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref(args.modelValue || '') + return { value, args } + }, + template: ` + +
+ Selected: {{ value || 'None' }} +
+ ` + }), + args: { + disabled: false + } +} + +export const WithPlaceholder: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + return { value, args } + }, + template: ` + + ` + }), + args: { + disabled: false + } +} + +export const Disabled: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('apple') + return { value, args } + }, + template: ` + + ` + }) +} + +export const WithGroups: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + return { value, args } + }, + template: ` + +
+ Selected: {{ value || 'None' }} +
+ ` + }), + args: { + disabled: false + } +} + +export const Scrollable: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + const items = Array.from({ length: 20 }, (_, i) => ({ + value: `item-${i + 1}`, + label: `Option ${i + 1}` + })) + return { value, items, args } + }, + template: ` + + ` + }), + args: { + disabled: false + } +} + +export const CustomWidth: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + return { value, args } + }, + template: ` +
+ + + +
+ ` + }), + args: { + disabled: false + } +} diff --git a/src/components/ui/select/Select.vue b/src/components/ui/select/Select.vue new file mode 100644 index 000000000..f685b3189 --- /dev/null +++ b/src/components/ui/select/Select.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/ui/select/SelectContent.vue b/src/components/ui/select/SelectContent.vue new file mode 100644 index 000000000..a88e26b9f --- /dev/null +++ b/src/components/ui/select/SelectContent.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/ui/select/SelectGroup.vue b/src/components/ui/select/SelectGroup.vue new file mode 100644 index 000000000..11f3da9f6 --- /dev/null +++ b/src/components/ui/select/SelectGroup.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/select/SelectItem.vue b/src/components/ui/select/SelectItem.vue new file mode 100644 index 000000000..4edeeb3ca --- /dev/null +++ b/src/components/ui/select/SelectItem.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/ui/select/SelectLabel.vue b/src/components/ui/select/SelectLabel.vue new file mode 100644 index 000000000..bafe45da9 --- /dev/null +++ b/src/components/ui/select/SelectLabel.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/ui/select/SelectScrollDownButton.vue b/src/components/ui/select/SelectScrollDownButton.vue new file mode 100644 index 000000000..1b1dc1a27 --- /dev/null +++ b/src/components/ui/select/SelectScrollDownButton.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/select/SelectScrollUpButton.vue b/src/components/ui/select/SelectScrollUpButton.vue new file mode 100644 index 000000000..ee1ef9263 --- /dev/null +++ b/src/components/ui/select/SelectScrollUpButton.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/select/SelectSeparator.vue b/src/components/ui/select/SelectSeparator.vue new file mode 100644 index 000000000..37947fd0d --- /dev/null +++ b/src/components/ui/select/SelectSeparator.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/ui/select/SelectTrigger.vue b/src/components/ui/select/SelectTrigger.vue new file mode 100644 index 000000000..768048ab1 --- /dev/null +++ b/src/components/ui/select/SelectTrigger.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ui/select/SelectValue.vue b/src/components/ui/select/SelectValue.vue new file mode 100644 index 000000000..4ffa580ca --- /dev/null +++ b/src/components/ui/select/SelectValue.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/tags-input/TagsInput.vue b/src/components/ui/tags-input/TagsInput.vue index f4a3fa001..c07dcdf7f 100644 --- a/src/components/ui/tags-input/TagsInput.vue +++ b/src/components/ui/tags-input/TagsInput.vue @@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {