Compare commits

..

26 Commits

Author SHA1 Message Date
Yourz
0a5baa6256 fix: update for coderabbit 2026-01-22 18:51:16 +08:00
Yourz
d87a2fe2f9 fix: unit tests 2026-01-22 17:53:09 +08:00
Yourz
25d34c6163 fix: refactoring 2026-01-22 17:20:07 +08:00
Yourz
31c3dc643b fix: refactoring 2026-01-22 17:02:54 +08:00
Yourz
3e86090427 fix: virtual list 2026-01-22 16:16:46 +08:00
Yourz
cf54456763 temp commit 2026-01-22 16:16:46 +08:00
Yourz
b7c5aaa99d fix: re-arrange the code 2026-01-22 16:16:46 +08:00
Yourz
e539439d8f fix: 3rd level nodes scroll stuck 2026-01-22 16:16:46 +08:00
Yourz
2e7fda6a9c fix: multiple sub nodes 2026-01-22 16:16:46 +08:00
Yourz
1cd97cb7e7 fix: multiple sub nodes 2026-01-22 16:16:46 +08:00
GitHub Action
77ab1a58a8 [automated] Apply ESLint and Prettier fixes 2026-01-22 16:16:45 +08:00
Yourz
2810184d12 fix: unit tests 2026-01-22 16:16:45 +08:00
Yourz
cc3509430b fix: update buffer rows 2026-01-22 16:16:45 +08:00
Yourz
f06649ae42 fix: restore tree container ref 2026-01-22 16:16:45 +08:00
Yourz
22f2ef1898 fix: update virtual list of tree by virtual grid component 2026-01-22 16:16:45 +08:00
Yourz
89d0554746 fix: remove throttle 2026-01-22 16:16:45 +08:00
Yourz
bd47fb376c fix: solve blank sidebar 2026-01-22 16:16:45 +08:00
Yourz
7042c5dbb4 refactor: extract nested ternary into early return pattern 2026-01-22 16:16:45 +08:00
Yourz
489c82fb3b fix: add error handling for async node.handleClick
Wrap node.handleClick() with errorHandling.wrapWithErrorHandlingAsync
to prevent unhandled promise rejections
2026-01-22 16:16:45 +08:00
Yourz
ed186f9033 refactor: extract SCROLL_THROTTLE_MS constant and inline applyWindow
Apply coderabbitai suggestions
2026-01-22 16:16:45 +08:00
Yourz
e094e42c07 refactor: apply coderabbitai suggestions
- Use createInitialWindowRange and calculateSpacerHeights in getTotalSpacerHeights
- Use active range after shift for recursive window processing
- Remove unused export from findNodeByKey
2026-01-22 16:16:44 +08:00
Yourz
0738773b15 refactor: extract common shiftNodeWindow helper function
Apply DRY principle suggestion from coderabbitai
2026-01-22 16:16:44 +08:00
Yourz
8656cc8468 refactor: optimize TreeExplorer based on coderabbitai suggestions 2026-01-22 16:16:44 +08:00
Yourz
6a6301012f refactor: use calculateScrollPercentage utility function
Apply suggestion from coderabbitai bot
2026-01-22 16:16:44 +08:00
Yourz
19d43ea455 fix: set tree container max height to calc(100vh - 144px) 2026-01-22 16:16:44 +08:00
Yourz
d0005770aa feat: implement virtual list with sliding window for TreeExplorer 2026-01-22 16:16:44 +08:00
204 changed files with 7963 additions and 10430 deletions

View File

@@ -300,12 +300,6 @@ When referencing Comfy-Org repos:
Rules for agent-based coding tasks.
### Chrome DevTools MCP
When using `take_snapshot` to inspect dropdowns, listboxes, or other components with dynamic options:
- Use `verbose: true` to see the full accessibility tree including list items
- Non-verbose snapshots often omit nested options in comboboxes/listboxes
### Temporary Files
- Put planning documents under `/temp/plans/`

View File

@@ -119,7 +119,8 @@ class NodeSlotReference {
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility
console.warn(
// eslint-disable-next-line no-console
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{
nodePos: [node.pos[0], node.pos[1]],

View File

@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
window.__capturedMessages!.clientFeatureFlags = parsed
window.__capturedMessages.clientFeatureFlags = parsed
}
} catch (e) {
// Not JSON, ignore
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window.__capturedMessages.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
// Wait for both client and server feature flags
await newPage.waitForFunction(
() =>
window.__capturedMessages!.clientFeatureFlags !== null &&
window.__capturedMessages!.serverFeatureFlags !== null,
window.__capturedMessages.clientFeatureFlags !== null &&
window.__capturedMessages.serverFeatureFlags !== null,
{ timeout: 10000 }
)
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
expect(messages!.clientFeatureFlags).toBeTruthy()
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages!.clientFeatureFlags).toHaveProperty('data')
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
expect(messages.clientFeatureFlags).toBeTruthy()
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages.clientFeatureFlags).toHaveProperty('data')
expect(messages.clientFeatureFlags.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
typeof messages.clientFeatureFlags.data.supports_preview_metadata
).toBe('boolean')
// Verify server sent feature flags back
expect(messages!.serverFeatureFlags).toBeTruthy()
expect(messages!.serverFeatureFlags).toHaveProperty(
expect(messages.serverFeatureFlags).toBeTruthy()
expect(messages.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
await newPage.close()
})
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverFeatureFlags
return window['app'].api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverSupportsFeature(
return window['app'].api.serverSupportsFeature(
'supports_preview_metadata'
)
})
@@ -124,17 +124,15 @@ test.describe('Feature Flags', () => {
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverSupportsFeature(
'non_existent_feature_xyz'
)
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window['app']!.api.serverFeatureFlags
window['app']!.api.serverFeatureFlags = {
const original = window['app'].api.serverFeatureFlags
window['app'].api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -143,15 +141,15 @@ test.describe('Feature Flags', () => {
}
const results = {
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
string_value: window['app']!.api.serverSupportsFeature('string_value'),
number_value: window['app']!.api.serverSupportsFeature('number_value'),
null_value: window['app']!.api.serverSupportsFeature('null_value')
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
string_value: window['app'].api.serverSupportsFeature('string_value'),
number_value: window['app'].api.serverSupportsFeature('number_value'),
null_value: window['app'].api.serverSupportsFeature('null_value')
}
// Restore original
window['app']!.api.serverFeatureFlags = original
window['app'].api.serverFeatureFlags = original
return results
})
@@ -168,20 +166,20 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature('supports_preview_metadata')
return window['app'].api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature('max_upload_size')
return window['app'].api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature(
return window['app'].api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
@@ -194,7 +192,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeatures()
return window['app'].api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
@@ -207,14 +205,14 @@ test.describe('Feature Flags', () => {
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window['app']!.api.getClientFeatureFlags()
const flags2 = window['app']!.api.getClientFeatureFlags()
const flags1 = window['app'].api.getClientFeatureFlags()
const flags2 = window['app'].api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window['app']!.api.getClientFeatureFlags()
const flags3 = window['app'].api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
@@ -240,14 +238,14 @@ test.describe('Feature Flags', () => {
}) => {
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window['app']!.api.getServerFeatures()
const features1 = window['app'].api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window['app']!.api.getServerFeatures()
const features2 = window['app'].api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
@@ -276,8 +274,7 @@ test.describe('Feature Flags', () => {
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// Track when various app components are ready
window.__appReadiness = {
;(window as any).__appReadiness = {
featureFlagsReceived: false,
apiInitialized: false,
appInitialized: false
@@ -289,10 +286,7 @@ test.describe('Feature Flags', () => {
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
window.__appReadiness = {
...window.__appReadiness,
featureFlagsReceived: true
}
;(window as any).__appReadiness.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
}
}, 10)
@@ -300,10 +294,7 @@ test.describe('Feature Flags', () => {
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
window.__appReadiness = {
...window.__appReadiness,
apiInitialized: true
}
;(window as any).__appReadiness.apiInitialized = true
clearInterval(checkApi)
}
}, 10)
@@ -311,10 +302,7 @@ test.describe('Feature Flags', () => {
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
window.__appReadiness = {
...window.__appReadiness,
appInitialized: true
}
;(window as any).__appReadiness.appInitialized = true
clearInterval(checkApp)
}
}, 10)
@@ -343,8 +331,8 @@ test.describe('Feature Flags', () => {
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window['app']!.api.serverFeatureFlags
...(window as any).__appReadiness,
currentFlags: window['app'].api.serverFeatureFlags
}
})

View File

@@ -2,17 +2,15 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const app = window['app']!
const app = window['app']
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
@@ -347,7 +345,7 @@ This is documentation for a custom node.
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window['app']!.graph!.nodes.map((n) => n.id)
return window['app'].graph.nodes.map((n: any) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -16,7 +15,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const openMoreOptions = async (comfyPage: any) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')

View File

@@ -82,7 +82,9 @@ test.describe('Templates', () => {
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Getting Started' })
.locator(
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()

View File

@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = window['app']
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = window['app']
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)

View File

@@ -10,21 +10,7 @@
<meta name="mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<style>
@media (prefers-color-scheme: dark) {
body {
/* Setting it early for background during load */
--bg-color: #202020;
}
}
body {
background-color: var(--bg-color);
background-image: var(--bg-img);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
</style>
<link rel="manifest" href="manifest.json">
</head>

View File

@@ -11,6 +11,6 @@
}
],
"display": "standalone",
"background_color": "#172dd7",
"theme_color": "#f0ff41"
"background_color": "#ffffff",
"theme_color": "#000000"
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.10",
"version": "1.38.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -169,7 +169,6 @@
"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",

View File

@@ -584,6 +584,8 @@ 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;

View File

@@ -120,8 +120,8 @@ describe('formatUtil', () => {
})
it('should handle null and undefined gracefully', () => {
expect(getMediaTypeFromFilename(null)).toBe('image')
expect(getMediaTypeFromFilename(undefined)).toBe('image')
expect(getMediaTypeFromFilename(null as any)).toBe('image')
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
})
it('should handle special characters in filenames', () => {

View File

@@ -537,9 +537,7 @@ export function truncateFilename(
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
*/
export function getMediaTypeFromFilename(
filename: string | null | undefined
): MediaType {
export function getMediaTypeFromFilename(filename: string): MediaType {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'

12
pnpm-lock.yaml generated
View File

@@ -186,9 +186,6 @@ 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
@@ -452,9 +449,6 @@ 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
@@ -6051,10 +6045,6 @@ 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}
@@ -14413,8 +14403,6 @@ snapshots:
json5@2.2.3: {}
jsonata@2.1.0: {}
jsonc-eslint-parser@2.4.0:
dependencies:
acorn: 8.15.0

View File

@@ -62,7 +62,6 @@ 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

View File

@@ -17,7 +17,6 @@
- Clear public interfaces
- Restrict extension access
- Clean up subscriptions
- Only expose state/actions that are used externally; keep internal state private
## General Guidelines

View File

@@ -9,24 +9,32 @@
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import { useEventListener } from '@vueuse/core'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { t } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil'
import { app } from '@/scripts/app'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
const showContextMenu = (event: MouseEvent) => {
const { target } = event
@@ -46,15 +54,23 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }
})
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
window.addEventListener('vite:preloadError', async (_event) => {
// Auto-reload if app is not ready or there are no unsaved changes
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
window.location.reload()
} else {
console.error('[vite:preloadError]', event.payload)
// Show confirmation dialog if there are unsaved changes
await dialogService
.confirm({
title: t('g.vitePreloadErrorTitle'),
message: t('g.vitePreloadErrorMessage')
})
.then((confirmed) => {
if (confirmed) {
window.location.reload()
}
})
}
})

View File

@@ -14,7 +14,6 @@ interface IdleDeadline {
interface IDisposable {
dispose(): void
}
type GlobalWindow = typeof globalThis
/**
* Internal implementation function that handles the actual scheduling logic.
@@ -22,7 +21,7 @@ type GlobalWindow = typeof globalThis
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
targetWindow: GlobalWindow,
targetWindow: any,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
@@ -38,7 +37,7 @@ export let runWhenGlobalIdle: (
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
const safeGlobal: any = globalThis
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||

View File

@@ -1,6 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -12,10 +11,7 @@ import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -36,7 +32,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
function createWrapper() {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -46,8 +42,7 @@ function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
activeJobsShort: '{count} active | {count} active'
}
}
}
@@ -56,17 +51,12 @@ function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
return mount(TopMenuSection, {
global: {
plugins: [pinia, i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
LoginButton: true
},
directives: {
tooltip: () => {}
@@ -144,89 +134,4 @@ describe('TopMenuSection', () => {
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
await nextTick()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
})

View File

@@ -45,30 +45,18 @@
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
:aria-pressed="isQueueOverlayExpanded"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -87,7 +75,6 @@
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
@@ -97,8 +84,6 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -116,10 +101,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -136,12 +119,9 @@ const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -158,30 +138,12 @@ const activeJobsLabel = computed(() => {
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
@@ -205,26 +167,9 @@ onMounted(() => {
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -152,8 +151,8 @@ describe('BaseTerminal', () => {
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
const selectionCallback = mockCalls[0][0] as () => void
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
selectionCallback()
await nextTick()

View File

@@ -7,7 +7,6 @@ import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('FormRadioGroup', () => {
beforeAll(() => {
@@ -15,8 +14,7 @@ describe('FormRadioGroup', () => {
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
const mountComponent = (props: any, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
@@ -94,9 +92,9 @@ describe('FormRadioGroup', () => {
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
{ name: 'First Option', id: 1 },
{ name: 'Second Option', id: 2 },
{ name: 'Third Option', id: 3 }
]
const wrapper = mountComponent({
@@ -110,9 +108,9 @@ describe('FormRadioGroup', () => {
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
expect(radioButtons[0].props('value')).toBe(1)
expect(radioButtons[1].props('value')).toBe(2)
expect(radioButtons[2].props('value')).toBe(3)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
@@ -169,7 +167,10 @@ describe('FormRadioGroup', () => {
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const options = [
{ label: 'Option 1', val: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
@@ -178,10 +179,11 @@ describe('FormRadioGroup', () => {
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
expect(radioButtons).toHaveLength(2)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
expect(labels[1].text()).toBe('Option 2')
})
})

View File

@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
const props = defineProps<{
modelValue: any
options?: (string | SettingOption | Record<string, string>)[]
options: (SettingOption | string)[]
optionLabel?: string
optionValue?: string
id?: string

View File

@@ -1,45 +1,53 @@
<template>
<Tree
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
:class="props.class"
:value="renderedRoot.children"
selection-mode="single"
:pt="{
nodeLabel: 'tree-explorer-node-label',
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
e.stopImmediatePropagation()
}
})
}"
<div
ref="treeContainerRef"
class="tree-container overflow-y-auto max-h-[calc(100vh-144px)]"
>
<template #folder="{ node }">
<slot name="folder" :node="node">
<TreeExplorerTreeNode :node="node" />
</slot>
</template>
<template #node="{ node }">
<slot name="node" :node="node">
<TreeExplorerTreeNode :node="node" />
</slot>
</template>
</Tree>
<Tree
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
:class="props.class"
:value="displayRoot.children"
selection-mode="single"
:pt="{
nodeLabel: 'tree-explorer-node-label',
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
e.stopImmediatePropagation()
}
}),
nodeChildren: ({ instance }) =>
getNodeChildrenStyle(instance.node as RenderedTreeExplorerNode)
}"
>
<template #folder="{ node }">
<slot name="folder" :node="node">
<TreeExplorerTreeNode :node="node" />
</slot>
</template>
<template #node="{ node }">
<slot name="node" :node="node">
<TreeExplorerTreeNode :node="node" />
</slot>
</template>
</Tree>
</div>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
import { computed, provide, ref } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
@@ -53,14 +61,23 @@ import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
import { combineTrees } from '@/utils/treeUtil'
import type { WindowRange } from '@/utils/virtualListUtils'
import {
applyWindow,
calculateChildrenListBounds,
calculateSpacerHeightsVariable,
calculateTreePositionsAndHeights,
calculateWindowRangeByHeights,
createInitialWindowRange,
mergeWindowRange
} from '@/utils/virtualListUtils'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
provide(InjectKeyExpandedKeys, expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
@@ -84,28 +101,91 @@ const {
}
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
const DEFAULT_NODE_HEIGHT = 32
const SCROLL_THROTTLE = 64
const treeContainerRef = ref<HTMLDivElement | null>(null)
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
const parentNodeWindowRanges = ref<Record<string, WindowRange>>({})
const nodeHeightsCache = ref<Map<string, number>>(new Map())
const { height: containerHeight } = useElementSize(treeContainerRef)
const { y: scrollY } = useScroll(treeContainerRef, {
throttle: SCROLL_THROTTLE,
eventListenerOptions: { passive: true }
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.getIcon) {
const icon = node.getIcon()
if (icon) {
return icon
}
} else if (node.icon) {
return node.icon
}
// node.icon is undefined
if (node.leaf) {
return 'pi pi-file'
}
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
const viewRows = computed(() =>
containerHeight.value
? Math.ceil(containerHeight.value / DEFAULT_NODE_HEIGHT)
: 0
)
const bufferRows = computed(() => Math.max(1, Math.floor(viewRows.value / 3)))
const windowSize = computed(() => viewRows.value + bufferRows.value * 2)
const isNodeExpanded = (node: RenderedTreeExplorerNode): boolean =>
!!(node.children && !node.leaf && expandedKeys.value?.[node.key])
const calculateNodePositionsAndHeights = (
root: RenderedTreeExplorerNode
): { positions: Map<string, number>; heights: Map<string, number> } => {
return calculateTreePositionsAndHeights({
root,
itemHeight: DEFAULT_NODE_HEIGHT,
getChildren: (node) => node.children,
isExpanded: isNodeExpanded
})
}
const calculateNodeWindowRange = (
node: RenderedTreeExplorerNode,
nodePositions: Map<string, number>,
nodeHeights: Map<string, number>,
scrollTop: number,
scrollBottom: number,
bufferHeight: number
): WindowRange | null => {
if (!isNodeExpanded(node)) return null
const children = node.children!
if (!children.length) return null
const { listStart, listEnd } = calculateChildrenListBounds({
node,
children,
nodePositions,
nodeHeights,
itemHeight: DEFAULT_NODE_HEIGHT
})
const getItemStart = (child: RenderedTreeExplorerNode) =>
nodePositions.get(child.key) ?? 0
const getItemHeight = (child: RenderedTreeExplorerNode) =>
nodeHeights.get(child.key) ?? DEFAULT_NODE_HEIGHT
return calculateWindowRangeByHeights({
items: children,
listStart,
listEnd,
scrollTop,
scrollBottom,
bufferHeight,
bufferRows: bufferRows.value,
windowSize: windowSize.value,
getItemStart,
getItemHeight
})
}
const getTreeNodeIcon = (node: TreeExplorerNode): string => {
const customIcon = node.getIcon?.() ?? node.icon
if (customIcon) return customIcon
if (node.leaf) return 'pi pi-file'
return expandedKeys.value?.[node.key] ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
@@ -117,37 +197,182 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText() : undefined,
badgeText: node.getBadgeText?.() ?? undefined,
isEditingLabel: node.key === renameEditingNode.value?.key
}
}
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const root = fillNodeInfo(props.root)
return newFolderNode.value ? combineTrees(root, newFolderNode.value) : root
})
// Build a lookup map for O(1) node access instead of O(n) tree traversal
const nodeKeyMap = computed<Record<string, RenderedTreeExplorerNode>>(() => {
const map: Record<string, RenderedTreeExplorerNode> = {}
const buildMap = (node: RenderedTreeExplorerNode) => {
map[node.key] = node
node.children?.forEach(buildMap)
}
buildMap(renderedRoot.value)
return map
})
const updateVisibleParentRanges = () => {
if (!containerHeight.value || !renderedRoot.value.children) {
return
}
const scrollTop = scrollY.value
const scrollBottom = scrollTop + containerHeight.value
const bufferHeight = bufferRows.value * DEFAULT_NODE_HEIGHT
const { positions: nodePositions, heights: nodeHeights } =
calculateNodePositionsAndHeights(renderedRoot.value)
nodeHeightsCache.value = nodeHeights
const currentRanges = parentNodeWindowRanges.value
const newRanges: Record<string, WindowRange> = {}
let hasChanges = false
const mergeOptions = {
bufferRows: bufferRows.value,
windowSize: windowSize.value
}
const processNode = (node: RenderedTreeExplorerNode) => {
if (!isNodeExpanded(node)) return
const children = node.children!
const calculated = calculateNodeWindowRange(
node,
nodePositions,
nodeHeights,
scrollTop,
scrollBottom,
bufferHeight
)
if (!calculated) return
const { range, changed } = mergeWindowRange(
currentRanges[node.key],
calculated,
{ ...mergeOptions, totalChildren: children.length }
)
newRanges[node.key] = range
if (changed) hasChanges = true
children.forEach(processNode)
}
renderedRoot.value.children.forEach(processNode)
const keysChanged =
Object.keys(newRanges).length !== Object.keys(currentRanges).length
if (hasChanges || keysChanged) {
parentNodeWindowRanges.value = newRanges
}
}
watch([scrollY, containerHeight], updateVisibleParentRanges, {
immediate: true
})
watch(renderedRoot, updateVisibleParentRanges)
watch(expandedKeys, updateVisibleParentRanges, { deep: true })
const displayRoot = computed<RenderedTreeExplorerNode>(() => {
const root = renderedRoot.value
if (!root.children) return root
return {
...root,
children: root.children.map((node) =>
applyWindow(node, parentNodeWindowRanges.value, windowSize.value)
)
}
})
const getNodeChildrenStyle = (node: RenderedTreeExplorerNode) => {
const baseStyle = { class: 'virtual-node-children' }
if (!node || !isNodeExpanded(node)) {
return baseStyle
}
const originalNode = nodeKeyMap.value[node.key]
const children = originalNode?.children
if (!children?.length) {
return baseStyle
}
const range =
parentNodeWindowRanges.value[node.key] ??
createInitialWindowRange(children.length, windowSize.value)
const getHeight = (child: RenderedTreeExplorerNode) =>
nodeHeightsCache.value.get(child.key) ?? DEFAULT_NODE_HEIGHT
const { topSpacer, bottomSpacer } = calculateSpacerHeightsVariable(
children,
range,
getHeight
)
return {
...baseStyle,
style: {
'--top-spacer': `${topSpacer}px`,
'--bottom-spacer': `${bottomSpacer}px`
}
}
}
const errorHandling = useErrorHandling()
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
) => {
): Promise<void> => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
}
if (node.handleClick) {
await node.handleClick(e)
await errorHandling.wrapWithErrorHandlingAsync(
() => node.handleClick?.(e),
node.handleError
)()
}
emit('nodeClick', node, e)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
: []
const targetNode = menuTargetNode.value
if (!targetNode) return []
const contextMenuItems = targetNode.contextMenuItems
if (!contextMenuItems) return []
return typeof contextMenuItems === 'function'
? contextMenuItems(targetNode)
: contextMenuItems
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
):
| ((event: MenuItemCommandEvent) => void)
| ((event: MenuItemCommandEvent) => Promise<void>) => {
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (event: MenuItemCommandEvent) => Promise<void>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
}
const handleNodeLabelEdit = async (
node: RenderedTreeExplorerNode,
newName: string
) => {
): Promise<void> => {
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
@@ -162,39 +387,33 @@ const handleNodeLabelEdit = async (
}
)()
}
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
renameEditingNode.value = node
}
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.()
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
const menuItems = computed<MenuItem[]>(() => {
const targetNode = menuTargetNode.value
if (!targetNode) return []
return [
getAddFolderMenuItem(targetNode),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
}
renameEditingNode.value = targetNode
},
visible: menuTargetNode.value?.handleRename !== undefined
visible: targetNode.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: async () => {
if (menuTargetNode.value) {
await deleteCommand(menuTargetNode.value)
}
await targetNode.handleDelete?.()
emit('nodeDelete', targetNode)
},
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
visible: targetNode.handleDelete !== undefined,
isAsync: true
},
...extraMenuItems.value
].map((menuItem: MenuItem) => ({
@@ -205,38 +424,23 @@ const menuItems = computed<MenuItem[]>(() =>
})
: undefined
}))
)
})
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
if (menuItems.value.filter((item) => item.visible).length > 0) {
if (menuItems.value.some((item) => item.visible)) {
menu.value?.show(e)
}
}
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (...args: any[]) => Promise<any>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
}
defineExpose({
/**
* The command to add a folder to a node via the context menu
* @param targetNodeKey - The key of the node where the folder will be added under
*/
addFolderCommand: (targetNodeKey: string) => {
const targetNode = findNodeByKey(renderedRoot.value, targetNodeKey)
const targetNode = nodeKeyMap.value[targetNodeKey]
if (targetNode) {
addFolderCommand(targetNode)
}
@@ -253,6 +457,19 @@ defineExpose({
flex-grow: 1;
}
/* Virtual scrolling spacers using CSS pseudo-elements (only for ul) */
:deep(ul.virtual-node-children)::before {
content: '';
display: block;
height: var(--top-spacer, 0);
}
:deep(ul.virtual-node-children)::after {
content: '';
display: block;
height: var(--bottom-spacer, 0);
}
/*
* The following styles are necessary to avoid layout shift when dragging nodes over folders.
* By setting the position to relative on the parent and using an absolutely positioned pseudo-element,

View File

@@ -7,7 +7,6 @@ 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(() => {
@@ -15,13 +14,7 @@ describe('UrlInput', () => {
app.use(PrimeVue)
})
const mountComponent = (
props: ComponentProps<typeof UrlInput> & {
placeholder?: string
disabled?: boolean
},
options = {}
) => {
const mountComponent = (props: any, options = {}) => {
return mount(UrlInput, {
global: {
plugins: [PrimeVue],
@@ -176,25 +169,25 @@ describe('UrlInput', () => {
await input.setValue(' https://leading-space.com')
await input.trigger('input')
await nextTick()
expect(input.element.value).toBe('https://leading-space.com')
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await nextTick()
expect(input.element.value).toBe('https://trailing-space.com')
expect(wrapper.vm.internalValue).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(input.element.value).toBe('https://both-spaces.com')
expect(wrapper.vm.internalValue).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(input.element.value).toBe('https://middle-space.com')
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
@@ -203,17 +196,15 @@ describe('UrlInput', () => {
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
// Check initial value is trimmed
expect(input.element.value).toBe('https://initial-value.com')
expect(wrapper.vm.internalValue).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(input.element.value).toBe('https://updated-value.com')
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
})
})
})

View File

@@ -1,5 +1,3 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import PrimeVue from 'primevue/config'
@@ -29,7 +27,7 @@ describe('UserAvatar', () => {
app.use(PrimeVue)
})
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
const mountComponent = (props: any = {}) => {
return mount(UserAvatar, {
global: {
plugins: [PrimeVue, i18n],

View File

@@ -1,43 +0,0 @@
<template>
<div
class="flex size-8 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
>
{{ letter }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
workspaceName: string
}>()
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {
const seed = letter.value.charCodeAt(0)
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const rand = mulberry32(seed)
const hue1 = Math.floor(rand() * 360)
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
const sat = 65 + Math.floor(rand() * 20)
const light = 55 + Math.floor(rand() * 15)
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
})
</script>

View File

@@ -3,14 +3,17 @@
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
<h2 class="text-neutral text-base">
{{ $t('sideToolbar.templates', 'Templates') }}
</h2>
</template>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
</template>
<template #header>

View File

@@ -4,14 +4,9 @@
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)"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"
>
<template #header>
@@ -41,38 +36,11 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
}
</script>
<style>
@@ -87,30 +55,4 @@ function getDialogPt(item: {
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -31,12 +31,7 @@
}}</label>
</div>
<Button
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
>
<Button variant="secondary" autofocus @click="onCancel">
<i class="pi pi-undo" />
{{ $t('g.cancel') }}
</Button>
@@ -78,10 +73,6 @@
<i class="pi pi-eraser" />
{{ $t('desktopMenu.reinstall') }}
</Button>
<!-- Info - just show an OK button -->
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
{{ $t('g.ok') }}
</Button>
<!-- Invalid - just show a close button. -->
<Button v-else variant="primary" @click="onCancel">
<i class="pi pi-times" />

View File

@@ -1,511 +0,0 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
>
<!-- 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"
>
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
})
}}
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-64"
/>
</div>
</div>
<!-- Members Content -->
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
>
{{
$t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
}}
</Button>
</div>
<!-- Date column headers -->
<template v-if="activeView === 'pending'">
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<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>
</div>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<div
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="userPhotoUrl"
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ userDisplayName }}
<span class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ $t('workspaceSwitcher.roleOwner') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ userEmail }}
</span>
</div>
</div>
</div>
</template>
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
<template v-else>
<div
v-for="(member, index) in filteredMembers"
:key="member.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="
isCurrentUser(member) ? userPhotoUrl : undefined
"
:pt:icon:class="{
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
}"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span
v-if="isCurrentUser(member)"
class="text-muted-foreground"
>
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getRoleBadgeLabel(member.role) }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
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"
class="flex items-center justify-end"
>
<Button
v-if="!isCurrentUser(member)"
v-tooltip="{
value: $t('g.moreOptions'),
showDelay: 300
}"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="showMemberMenu($event, member)"
>
<i class="pi pi-ellipsis-h" />
</Button>
</div>
</div>
<!-- Member actions menu (shared for all members) -->
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
</template>
</template>
<!-- Pending Invites -->
<template v-else>
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.pendingGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<!-- Invite info -->
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<!-- Invite date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<!-- Expiry date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="handleCopyInviteLink(invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="
$t('workspacePanel.members.actions.revokeInvite')
"
@click="handleRevokeInvite(invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
</div>
</div>
<div
v-if="filteredPendingInvites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</template>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
</p>
<button
class="underline bg-transparent border-none cursor-pointer"
@click="handleCreateWorkspace"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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 { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectedMember.value = member
memberMenu.value?.toggle(event)
}
function isCurrentUser(member: WorkspaceMember): boolean {
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
}
// All members sorted: owners first, current user second, then rest by join date
const filteredMembers = computed(() => {
let result = [...members.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(member) =>
member.name.toLowerCase().includes(query) ||
member.email.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
// Owners always come first
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
// Current user comes second (after owner)
const aIsCurrentUser = isCurrentUser(a)
const bIsCurrentUser = isCurrentUser(b)
if (aIsCurrentUser && !bIsCurrentUser) return -1
if (!aIsCurrentUser && bIsCurrentUser) return 1
// Then sort by join date
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
return role === 'owner'
? t('workspaceSwitcher.roleOwner')
: t('workspaceSwitcher.roleMember')
}
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
result.sort((a, b) => {
const aDate = a[field]
const bDate = b[field]
if (!aDate || !bDate) return 0
const aValue = aDate.getTime()
const bValue = bDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
}))
describe('SettingItem', () => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
const mountComponent = (props: any, options = {}): any => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
@@ -32,7 +32,6 @@ describe('SettingItem', () => {
'i-material-symbols:experiment-outline': true
}
},
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
props,
...options
})
@@ -49,9 +48,8 @@ describe('SettingItem', () => {
}
})
// Check the FormItem component's item prop for the options
const formItem = wrapper.findComponent({ name: 'FormItem' })
const options = formItem.props('item').options
// Get the options property of the FormItem
const options = wrapper.vm.formItem.options
expect(options).toEqual([
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
@@ -69,8 +67,7 @@ describe('SettingItem', () => {
})
// Should not throw an error and tooltip should be preserved as-is
const formItem = wrapper.findComponent({ name: 'FormItem' })
expect(formItem.props('item').tooltip).toBe(
expect(wrapper.vm.formItem.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})

View File

@@ -12,7 +12,6 @@ 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'
@@ -20,7 +19,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: Partial<AuditLog>[]
events: any[]
pagination: {
page: number
limit: number

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,238 +0,0 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center">
<TabList unstyled class="flex w-full gap-2">
<Tab
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
})
}}
</Tab>
</TabList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="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" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
class="ml-2"
variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
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'
]"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</template>
</Menu>
</template>
</div>
<TabPanels unstyled>
<TabPanel value="plan">
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
</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 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 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'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog()
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog()
}
function handleEditWorkspace() {
showEditWorkspaceDialog()
}
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
// Use workspace's own subscription status, not the global isActiveSubscription
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.value
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return null
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : null
})
const inviteTooltip = computed(() => {
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
const menuItems = computed(() => {
const items = []
// Add edit option for owners
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: handleEditWorkspace
})
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value
? 'text-danger/50 cursor-not-allowed'
: 'text-danger',
disabled: isDeleteDisabled.value,
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
})
} else if (action === 'leave') {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
})
}
return items
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})
</script>

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

@@ -1,5 +1,3 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
@@ -65,7 +63,7 @@ describe('ApiKeyForm', () => {
mockLoading.mockReset()
})
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
const mountComponent = (props: any = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],

View File

@@ -112,10 +112,8 @@ describe('SignInForm', () => {
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
@@ -140,10 +138,7 @@ describe('SignInForm', () => {
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
@@ -166,10 +161,7 @@ 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 typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
@@ -189,10 +181,7 @@ describe('SignInForm', () => {
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
@@ -265,17 +254,12 @@ 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 typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
@@ -289,17 +273,12 @@ describe('SignInForm', () => {
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)

View File

@@ -1,112 +0,0 @@
<template>
<div
class="flex w-full max-w-[400px] 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">
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
</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="onCancel"
>
<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">
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="workspaceName"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
"
@keydown.enter="isValidName && onCreate()"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onCreate"
>
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm?: (name: string) => void | Promise<void>
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'create-workspace' })
}
async function onCreate() {
if (!isValidName.value) return
loading.value = true
try {
const name = workspaceName.value.trim()
// Call optional callback if provided
await onConfirm?.(name)
dialogStore.closeDialog({ key: 'create-workspace' })
// Create workspace and switch to it (triggers reload internally)
await workspaceStore.createWorkspace(name)
} catch (error) {
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,89 +0,0 @@
<template>
<div
class="flex w-full max-w-[360px] 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">
{{ $t('workspacePanel.deleteDialog.title') }}
</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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
workspaceName
? $t('workspacePanel.deleteDialog.messageWithName', {
name: workspaceName
})
: $t('workspacePanel.deleteDialog.message')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onDelete">
{{ $t('g.delete') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { workspaceId, workspaceName } = defineProps<{
workspaceId?: string
workspaceName?: string
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'delete-workspace' })
}
async function onDelete() {
loading.value = true
try {
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
await workspaceStore.deleteWorkspace(workspaceId)
dialogStore.closeDialog({ key: 'delete-workspace' })
window.location.reload()
} catch (error) {
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,104 +0,0 @@
<template>
<div
class="flex w-full max-w-[400px] 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">
{{ $t('workspacePanel.editWorkspaceDialog.title') }}
</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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="newWorkspaceName"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
@keydown.enter="isValidName && onSave()"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onSave"
>
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'edit-workspace' })
}
async function onSave() {
if (!isValidName.value) return
loading.value = true
try {
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
dialogStore.closeDialog({ key: 'edit-workspace' })
toast.add({
severity: 'success',
summary: t('workspacePanel.toast.workspaceUpdated.title'),
detail: t('workspacePanel.toast.workspaceUpdated.message'),
life: 5000
})
} catch (error) {
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,182 +0,0 @@
<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">
{{
step === 'email'
? $t('workspacePanel.inviteMemberDialog.title')
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
}}
</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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body: Email Step -->
<template v-if="step === 'email'">
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.message') }}
</p>
<input
v-model="email"
type="email"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
/>
</div>
<!-- Footer: Email Step -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidEmail"
@click="onCreateLink"
>
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
</Button>
</div>
</template>
<!-- Body: Link Step -->
<template v-else>
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
</p>
<p class="m-0 text-sm font-medium text-base-foreground">
{{ email }}
</p>
<div class="relative">
<input
:value="generatedLink"
readonly
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</div>
</div>
<!-- Footer: Link Step -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onCopyLink">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
</Button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const toast = useToast()
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
function onCancel() {
dialogStore.closeDialog({ key: 'invite-member' })
}
async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
life: 3000
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</script>

View File

@@ -1,78 +0,0 @@
<template>
<div
class="flex w-full max-w-[360px] 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">
{{ $t('workspacePanel.leaveDialog.title') }}
</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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.leaveDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onLeave">
{{ $t('workspacePanel.leaveDialog.leave') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'leave-workspace' })
}
async function onLeave() {
loading.value = true
try {
// leaveWorkspace() handles switching to personal workspace internally and reloads
await workspaceStore.leaveWorkspace()
dialogStore.closeDialog({ key: 'leave-workspace' })
window.location.reload()
} catch (error) {
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,83 +0,0 @@
<template>
<div
class="flex w-full max-w-[360px] 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">
{{ $t('workspacePanel.removeMemberDialog.title') }}
</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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.removeMemberDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRemove">
{{ $t('workspacePanel.removeMemberDialog.remove') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await workspaceStore.removeMember(memberId)
toast.add({
severity: 'success',
summary: t('workspacePanel.removeMemberDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'remove-member' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div
class="flex w-full max-w-[360px] 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">
{{ $t('workspacePanel.revokeInviteDialog.title') }}
</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="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.revokeInviteDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRevoke">
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -93,7 +93,7 @@
</template>
<script setup lang="ts">
import { until, useEventListener } from '@vueuse/core'
import { useEventListener, whenever } from '@vueuse/core'
import {
computed,
nextTick,
@@ -129,7 +129,7 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { t } from '@/i18n'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
@@ -144,15 +144,12 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { UnauthorizedError } from '@/scripts/api'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -163,9 +160,6 @@ 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: []
@@ -178,16 +172,11 @@ const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const bootstrapStore = useBootstrapStore()
const { isI18nReady, i18nError } = storeToRefs(bootstrapStore)
const { isReady: isSettingsReady, error: settingsError } =
storeToRefs(settingStore)
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
@@ -394,81 +383,50 @@ useEventListener(
{ passive: true }
)
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
mergeCustomNodesI18n(i18nData)
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
}
}
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()
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
async (_newLocale, oldLocale) => {
if (!oldLocale) return
await Promise.all([
until(() => isSettingsReady.value || !!settingsError.value).toBe(true),
until(() => isI18nReady.value || !!i18nError.value).toBe(true)
])
if (settingsError.value || i18nError.value) {
console.warn(
'Somehow the Locale setting was changed while the settings or i18n had a setup error'
)
}
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
}
)
useEventListener(
() => canvasStore.canvas?.canvas,
'litegraph:set-graph',
() => {
workflowStore.updateActiveGraph()
}
)
onMounted(async () => {
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
useVueFeatureFlags()
comfyApp.vueAppReady = true
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
await loadCustomNodesI18n()
try {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
} else {
throw error
}
throw settingsError.value
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
newUserService().initializeIfNewUser(settingStore)
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
await newUserService().initializeIfNewUser(settingStore)
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
@@ -501,28 +459,31 @@ 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')
const releaseStore = useReleaseStore()
void releaseStore.initialize()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
async () => {
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})

View File

@@ -5,26 +5,9 @@ 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<typeof useExtensionService> {
return {
extensionCommands: { value: new Map() },
loadExtensions: vi.fn(),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(() => []),
invokeExtensionsAsync: vi.fn()
} as Partial<ReturnType<typeof useExtensionService>> as ReturnType<
typeof useExtensionService
>
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
@@ -129,7 +112,12 @@ describe('SelectionToolbox', () => {
canvasStore = useCanvasStore()
// Mock the canvas to avoid "getCanvas: canvas is null" errors
canvasStore.canvas = createMockCanvas()
canvasStore.canvas = {
setDirty: vi.fn(),
state: {
selectionChanged: false
}
} as any
vi.resetAllMocks()
})
@@ -196,27 +184,30 @@ describe('SelectionToolbox', () => {
describe('Button Visibility Logic', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
})
it('should show info button only for single selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
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 = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
// mock nodedef and return null
nodeDefMock = null
// remount component
@@ -226,7 +217,7 @@ describe('SelectionToolbox', () => {
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
true
@@ -234,9 +225,9 @@ describe('SelectionToolbox', () => {
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(
@@ -246,15 +237,15 @@ describe('SelectionToolbox', () => {
it('should show frame nodes only for multiple selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
@@ -262,22 +253,22 @@ describe('SelectionToolbox', () => {
it('should show bypass button for appropriate selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
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 = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
@@ -295,13 +286,13 @@ describe('SelectionToolbox', () => {
// Single image node
isImageNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'ImageNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
// Single non-image node
isImageNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
@@ -313,13 +304,13 @@ describe('SelectionToolbox', () => {
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
// Single non-Load3D node
isLoad3dNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
@@ -335,17 +326,17 @@ describe('SelectionToolbox', () => {
// With output node selected
isOutputNodeSpy.mockReturnValue(true)
filterOutputNodesSpy.mockReturnValue([
{ type: 'SaveImage' }
] as LGraphNode[])
canvasStore.selectedItems = [createMockPositionable()]
filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any)
canvasStore.selectedItems = [
{ type: 'SaveImage', constructor: { nodeData: { output_node: true } } }
] as any
const wrapper = mountComponent()
expect(wrapper.find('.execute-button').exists()).toBe(true)
// Without output node selected
isOutputNodeSpy.mockReturnValue(false)
filterOutputNodesSpy.mockReturnValue([])
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.execute-button').exists()).toBe(false)
@@ -361,7 +352,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 = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const dividers = wrapper.findAll('.vertical-divider')
@@ -387,13 +378,10 @@ describe('SelectionToolbox', () => {
['test-command', { id: 'test-command', title: 'Test Command' }]
])
},
loadExtensions: vi.fn(),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(() => ['test-command']),
invokeExtensionsAsync: vi.fn()
} as ReturnType<typeof useExtensionService>)
invokeExtensions: vi.fn(() => ['test-command'])
} as any)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
@@ -401,9 +389,12 @@ describe('SelectionToolbox', () => {
it('should not render extension commands when none available', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
@@ -413,9 +404,12 @@ describe('SelectionToolbox', () => {
describe('Container Styling', () => {
it('should apply minimap container styles', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -424,9 +418,12 @@ describe('SelectionToolbox', () => {
it('should have correct CSS classes', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -438,9 +435,12 @@ describe('SelectionToolbox', () => {
it('should handle animation class conditionally', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -453,18 +453,16 @@ describe('SelectionToolbox', () => {
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
const forwardEventToCanvasSpy = vi.fn()
mockCanvasInteractions.mockReturnValue({
handleWheel: vi.fn(),
handlePointer: vi.fn(),
forwardEventToCanvas: forwardEventToCanvasSpy,
shouldHandleNodePointerEvents: { value: true } as ReturnType<
typeof useCanvasInteractions
>['shouldHandleNodePointerEvents']
} as ReturnType<typeof useCanvasInteractions>)
forwardEventToCanvas: forwardEventToCanvasSpy
} as any)
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -477,7 +475,10 @@ describe('SelectionToolbox', () => {
describe('No Selection State', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
})
it('should hide most buttons when no items selected', () => {

View File

@@ -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'
function getMockLGraphNode(): LGraphNode {
return createMockLGraphNode({ type: 'TestNode' })
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node',
mode: LGraphEventMode.ALWAYS
}
vi.mock('@/utils/litegraphUtil', () => ({
@@ -59,21 +59,21 @@ describe('BypassButton', () => {
}
it('should render bypass button', () => {
canvasStore.selectedItems = [getMockLGraphNode()]
canvasStore.selectedItems = [mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should have correct test id', () => {
canvasStore.selectedItems = [getMockLGraphNode()]
canvasStore.selectedItems = [mockLGraphNode] as any
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 = [getMockLGraphNode()]
canvasStore.selectedItems = [mockLGraphNode] as any
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
@@ -85,11 +85,8 @@ describe('BypassButton', () => {
})
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode: Partial<LGraphNode> = {
...getMockLGraphNode(),
mode: LGraphEventMode.BYPASS
}
canvasStore.selectedItems = [bypassedNode as LGraphNode]
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
@@ -103,7 +100,7 @@ describe('BypassButton', () => {
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()]
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)

View File

@@ -1,4 +1,3 @@
import type { Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
@@ -9,20 +8,7 @@ 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> = {}
): LoadedComfyWorkflow {
return {
changeTracker: {
checkState: vi.fn() as Mock
},
...overrides
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
}
// Mock the litegraph module
vi.mock('@/lib/litegraph/src/litegraph', async () => {
@@ -84,7 +70,11 @@ describe('ColorPickerButton', () => {
canvasStore.selectedItems = []
// Mock workflow store
workflowStore.activeWorkflow = createMockWorkflow()
workflowStore.activeWorkflow = {
changeTracker: {
checkState: vi.fn()
}
} as any
})
const createWrapper = () => {
@@ -100,13 +90,13 @@ describe('ColorPickerButton', () => {
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [createMockPositionable()]
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()
const button = wrapper.find('button')

View File

@@ -1,16 +1,23 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, 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)
@@ -30,8 +37,10 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
}))
describe('ExecuteButton', () => {
let mockCanvas: LGraphCanvas
let mockSelectedNodes: LGraphNode[]
let mockCanvas: any
let mockCanvasStore: any
let mockCommandStore: any
let mockSelectedNodes: any[]
const i18n = createI18n({
legacy: false,
@@ -48,27 +57,27 @@ describe('ExecuteButton', () => {
})
beforeEach(async () => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
setActivePinia(createPinia())
// Reset mocks
const partialCanvas: Partial<LGraphCanvas> = {
mockCanvas = {
setDirty: vi.fn()
}
mockCanvas = partialCanvas as Partial<LGraphCanvas> as LGraphCanvas
mockSelectedNodes = []
// Get store instances and mock methods
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
mockCanvasStore = {
getCanvas: vi.fn(() => mockCanvas),
selectedItems: []
}
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(commandStore, 'execute').mockResolvedValue()
mockCommandStore = {
execute: vi.fn()
}
// Setup store mocks
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
@@ -78,7 +87,7 @@ describe('ExecuteButton', () => {
selectedNodes: {
value: mockSelectedNodes
}
} as ReturnType<typeof useSelectionState>)
} as any)
vi.clearAllMocks()
})
@@ -105,16 +114,15 @@ 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(commandStore.execute).toHaveBeenCalledWith(
expect(mockCommandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'
)
expect(commandStore.execute).toHaveBeenCalledTimes(1)
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -8,7 +8,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import { searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -24,7 +24,18 @@ const nodes = computed((): LGraphNode[] => {
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const { widgetsSectionDataList } = computedSectionDataList(nodes)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.value.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return {
widgets: shownWidgets,
node
}
})
})
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
widgetsSectionDataList.value

View File

@@ -7,7 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import { searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -21,14 +21,21 @@ const { t } = useI18n()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
() => nodes
)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
if (includesAdvanced.value) {
return []
}
return nodes
.map((node) => {
const { widgets = [] } = node

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -23,11 +23,7 @@ const settingStore = useSettingStore()
const dialogService = useDialogService()
// NODES settings
const showAdvancedParameters = computed({
get: () => settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'),
set: (value) =>
settingStore.set('Comfy.Node.AlwaysShowAdvancedWidgets', value)
})
const showAdvancedParameters = ref(false) // Placeholder for future implementation
const showToolbox = computed({
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),

View File

@@ -38,22 +38,10 @@ describe('searchWidgets', () => {
expect(searchWidgets(widgets, 'width')).toHaveLength(1)
expect(searchWidgets(widgets, 'slider')).toHaveLength(1)
expect(searchWidgets(widgets, 'high')).toHaveLength(1)
expect(searchWidgets(widgets, 'image')).toHaveLength(1)
})
it('should support fuzzy matching (e.g., "high" matches both "height" and value "high")', () => {
const widgets = [
createWidget('width', 'number', '100', 'Size Control'),
createWidget('height', 'slider', '200', 'Image Height'),
createWidget('quality', 'text', 'high', 'Quality')
]
const results = searchWidgets(widgets, 'high')
expect(results).toHaveLength(2)
expect(results.some((r) => r.widget.name === 'height')).toBe(true)
expect(results.some((r) => r.widget.name === 'quality')).toBe(true)
})
it('should handle multiple search words', () => {
const widgets = [
createWidget('width', 'number', '100', 'Image Width'),

View File

@@ -1,14 +1,11 @@
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
export const GetNodeParentGroupKey: InjectionKey<
(node: LGraphNode) => LGraphGroup | null
@@ -20,18 +17,10 @@ export type NodeWidgetsListList = Array<{
widgets: NodeWidgetsList
}>
interface WidgetSearchItem {
index: number
searchableLabel: string
searchableName: string
searchableType: string
searchableValue: string
}
/**
* Searches widgets in a list using fuzzy search and returns search results.
* Uses Fuse.js for better matching with typo tolerance and relevance ranking.
* Searches widgets in a list and returns search results.
* Filters by name, localized label, type, and user-input value.
* Performs basic tokenization of the query string.
*/
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
list: T,
@@ -40,48 +29,27 @@ export function searchWidgets<T extends { widget: IBaseWidget }[]>(
if (query.trim() === '') {
return list
}
const searchableList: WidgetSearchItem[] = list.map((item, index) => {
const searchableItem = {
index,
searchableLabel: item.widget.label?.toLowerCase() || '',
searchableName: item.widget.name.toLowerCase(),
searchableType: item.widget.type.toLowerCase(),
searchableValue: item.widget.value?.toString().toLowerCase() || ''
}
return searchableItem
})
const fuseOptions: IFuseOptions<WidgetSearchItem> = {
keys: [
{ name: 'searchableName', weight: 0.4 },
{ name: 'searchableLabel', weight: 0.3 },
{ name: 'searchableValue', weight: 0.3 },
{ name: 'searchableType', weight: 0.2 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query.trim())
const matchedItems = new Set(
results.map((result) => list[result.item.index]!)
)
return list.filter((item) => matchedItems.has(item)) as T
}
type NodeSearchItem = {
nodeId: NodeId
searchableTitle: string
const words = query.trim().toLowerCase().split(' ')
return list.filter(({ widget }) => {
const label = widget.label?.toLowerCase()
const name = widget.name.toLowerCase()
const type = widget.type.toLowerCase()
const value = widget.value?.toString().toLowerCase()
return words.every(
(word) =>
name.includes(word) ||
label?.includes(word) ||
type?.includes(word) ||
value?.includes(word)
)
}) as T
}
/**
* Searches widgets and nodes in a list using fuzzy search and returns search results.
* Uses Fuse.js for node title matching with typo tolerance and relevance ranking.
* Searches widgets and nodes in a list and returns search results.
* First checks if the node title matches the query (if so, keeps entire node).
* Otherwise, filters widgets using searchWidgets.
* Performs basic tokenization of the query string.
*/
export function searchWidgetsAndNodes(
list: NodeWidgetsListList,
@@ -90,26 +58,12 @@ export function searchWidgetsAndNodes(
if (query.trim() === '') {
return list
}
const searchableList: NodeSearchItem[] = list.map((item) => ({
nodeId: item.node.id,
searchableTitle: (item.node.getTitle() ?? '').toLowerCase()
}))
const fuseOptions: IFuseOptions<NodeSearchItem> = {
keys: [{ name: 'searchableTitle', weight: 1.0 }],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const nodeMatches = fuse.search(query.trim())
const matchedNodeIds = new Set(
nodeMatches.map((result) => result.item.nodeId)
)
const words = query.trim().toLowerCase().split(' ')
return list
.map((item) => {
if (matchedNodeIds.has(item.node.id)) {
const { node } = item
const title = node.getTitle().toLowerCase()
if (words.every((word) => title.includes(word))) {
return { ...item, keep: true }
}
return {
@@ -249,33 +203,3 @@ function repeatItems<T>(items: T[]): T[] {
}
return result
}
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
const settingStore = useSettingStore()
const includesAdvanced = computed(() =>
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return toValue(nodes).map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
return {
widgetsSectionDataList,
includesAdvanced
}
}

View File

@@ -219,7 +219,7 @@ const extraMenuItems = computed(() => [
{
key: 'settings',
label: t('g.settings'),
icon: 'icon-[lucide--settings]',
icon: 'mdi mdi-cog-outline',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'icon-[lucide--puzzle]',
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
}
])

View File

@@ -5,35 +5,22 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const canvasStore = useCanvasStore()
function toggleLinearMode() {
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
}
</script>
<template>
<div class="p-1 bg-secondary-background rounded-lg w-10">
<Button
v-tooltip="{
value: t('linearMode.linearMode'),
showDelay: 300,
hideDelay: 300
}"
size="icon"
:title="t('linearMode.linearMode')"
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
@click="toggleLinearMode"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
>
<i class="icon-[lucide--panels-top-left]" />
</Button>
<Button
v-tooltip="{
value: t('linearMode.graphMode'),
showDelay: 300,
hideDelay: 300
}"
size="icon"
:title="t('linearMode.graphMode')"
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
@click="toggleLinearMode"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
>
<i class="icon-[comfy--workflow]" />
</Button>

View File

@@ -6,11 +6,7 @@
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
<ActiveJobCard v-for="job in activeJobItems" :key="job.id" :job="job" />
</div>
<!-- Assets Header -->
@@ -59,7 +55,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import ActiveJobCard from '@/components/sidebar/tabs/assets/ActiveJobCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'

View File

@@ -201,12 +201,7 @@
</template>
<script setup lang="ts">
import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage
} from '@vueuse/core'
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
@@ -260,10 +255,7 @@ const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
'grid'
)
const viewMode = ref<'list' | 'grid'>('grid')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ActiveJobCard from './ActiveMediaAssetCard.vue'
import ActiveJobCard from './ActiveJobCard.vue'
import type { JobListItem } from '@/composables/queue/useJobList'

View File

@@ -92,7 +92,7 @@ describe('ResultGallery', () => {
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
allGalleryItems: mockGalleryItems as unknown as ResultItemImpl[],
activeIndex: 0,
...props
},
@@ -117,10 +117,7 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
type GalleryVM = typeof wrapper.vm & {
galleryVisible: boolean
}
const vm = wrapper.vm as GalleryVM
const vm: any = wrapper.vm
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
@@ -170,11 +167,7 @@ describe('ResultGallery', () => {
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
interface GalleriaPT {
prevButton?: { style?: string }
nextButton?: { style?: string }
}
const pt = galleria.props('pt') as GalleriaPT
const pt = galleria.props('pt') as any
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})

View File

@@ -4,10 +4,6 @@ import { nextTick } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
error: boolean
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
@@ -49,7 +45,7 @@ describe('BaseThumbnail', () => {
it('shows error state when image fails to load', async () => {
const wrapper = mountThumbnail()
const vm = wrapper.vm as ComponentInstance
const vm = wrapper.vm as any
// Manually set error since useEventListener is mocked
vm.error = true

View File

@@ -1,4 +1,4 @@
<!-- A button that shows workspace icon (Cloud) or user avatar -->
<!-- A button that shows current authenticated user's avatar -->
<template>
<div>
<Button
@@ -16,18 +16,9 @@
)
"
>
<WorkspaceProfilePic
v-if="showWorkspaceIcon"
:workspace-name="workspaceName"
:class="compact && 'size-full'"
/>
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
/>
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
</Button>
@@ -36,65 +27,38 @@
:show-arrow="false"
:pt="{
root: {
class: 'rounded-lg w-80'
class: 'rounded-lg'
}
}"
>
<!-- Workspace mode: workspace-aware popover -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopover v-else @close="closePopover" />
<CurrentUserPopover @close="closePopover" />
</Popover>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
)
const { showArrow = true, compact = false } = defineProps<{
showArrow?: boolean
compact?: boolean
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
const workspaceName = computed(() => {
if (!showWorkspaceIcon.value) return ''
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
return workspaceName.value
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const closePopover = () => {
popover.value?.hide()
}

View File

@@ -1,322 +0,0 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<!-- Workspace Selector -->
<div
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
@click="toggleWorkspaceSwitcher"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<WorkspaceProfilePic
class="size-6 shrink-0 text-xs"
:workspace-name="workspaceName"
/>
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
<Popover
ref="workspaceSwitcherPopover"
append-to="body"
:pt="{
content: {
class: 'p-0'
}
}"
>
<WorkspaceSwitcherPopover
@select="workspaceSwitcherPopover?.hide()"
@create="handleCreateWorkspace"
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Navigate to workspace settings -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlanAndCreditsSettings"
>
{{ $t('workspaceSwitcher.subscribe') }}
</Button>
</div>
<Divider class="mx-0 my-2" />
</template>
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
v-if="showPlansAndPricing"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="plans-pricing-menu-item"
@click="handleOpenPlansAndPricing"
>
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.plansAndPricing')
}}</span>
<span
v-if="canUpgrade"
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
>
{{ $t('subscription.upgrade') }}
</span>
</div>
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
<div
v-if="showManagePlan"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"
>
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.managePlan')
}}</span>
</div>
<!-- Partner Nodes Pricing (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Workspace Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="workspace-settings-menu-item"
@click="handleOpenWorkspaceSettings"
>
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.workspaceSettings')
}}</span>
</div>
<!-- Account Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.accountSettings')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Logout (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
>
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
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 { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
close: []
}>()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const displayedCredits = computed(() => {
const isSubscribed = isPersonalWorkspace.value
? isActiveSubscription.value
: isWorkspaceSubscribed.value
return isSubscribed ? totalCredits.value : '0'
})
const canUpgrade = computed(() => {
// PRO is currently the only/highest tier, so no upgrades available
// This will need updating when additional tiers are added
return false
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
emit('close')
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.show()
emit('close')
}
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
} else {
dialogService.showSettingsDialog('credits')
}
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')
}
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
dialogService.showCreateWorkspaceDialog()
emit('close')
}
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})
</script>

View File

@@ -1,196 +0,0 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<!-- Loading state -->
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
<div
v-for="i in 2"
:key="i"
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
>
<div class="size-8 rounded-full bg-secondary-background" />
<div class="flex flex-1 flex-col gap-1">
<div class="h-4 w-24 rounded bg-secondary-background" />
<div class="h-3 w-16 rounded bg-secondary-background" />
</div>
</div>
</div>
<!-- Workspace list -->
<template v-else>
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
<div class="border-b border-border-default p-2">
<div
:class="
cn(
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
>
<button
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<div class="flex items-center gap-1.5">
<span class="text-sm text-base-foreground">
{{
workspace.type === 'personal'
? $t('workspaceSwitcher.personal')
: workspace.name
}}
</span>
<span
v-if="getTierLabel(workspace)"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getTierLabel(workspace) }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ getRoleLabel(workspace.role) }}
</span>
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
/>
</button>
</div>
</div>
</template>
</template>
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
:class="
cn(
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
canCreateWorkspace
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'cursor-default'
)
"
@click="canCreateWorkspace && handleCreateWorkspace()"
>
<div
:class="
cn(
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
!canCreateWorkspace && 'opacity-50'
)
"
>
<i class="pi pi-plus text-sm text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span
v-if="canCreateWorkspace"
class="text-sm text-muted-foreground"
>
{{ $t('workspaceSwitcher.createWorkspace') }}
</span>
<span v-else class="text-sm text-muted-foreground">
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
}
const emit = defineEmits<{
select: [workspace: AvailableWorkspace]
create: []
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
storeToRefs(workspaceStore)
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
workspaces.value.map((w) => ({
id: w.id,
name: w.name,
type: w.type,
role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan
}))
)
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')
return ''
}
function getTierLabel(workspace: AvailableWorkspace): string | null {
// Personal workspace: use user's subscription tier
if (workspace.type === 'personal') {
return userSubscriptionTierName.value || null
}
// Team workspace: use workspace subscription plan
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (workspace.subscriptionPlan === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
if (success) {
emit('select', workspace)
}
}
function handleCreateWorkspace() {
emit('create')
}
</script>

View File

@@ -26,8 +26,7 @@ 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',
unset: ''
'icon-sm': 'size-5 p-0'
}
},

View File

@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
<i
v-if="!disabled && !isEditing"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
/>
</TagsInputRoot>
</template>

View File

@@ -117,7 +117,7 @@
</template>
<template #rightPanel>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
<RightSidePanel></RightSidePanel>
</template>
</BaseModalLayout>
</template>
@@ -136,6 +136,7 @@ import SingleSelect from '@/components/input/SingleSelect.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'

View File

@@ -15,6 +15,7 @@ import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseModalLayout from './BaseModalLayout.vue'
interface StoryArgs {
@@ -68,6 +69,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseModalLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
MultiSelect,
SingleSelect,
@@ -173,15 +175,16 @@ const createStoryTemplate = (args: StoryArgs) => ({
template: `
<div>
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel Header Title -->
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<span class="text-neutral text-base">Title</span>
</template>
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
</template>
<!-- Header -->
@@ -296,15 +299,16 @@ const createStoryTemplate = (args: StoryArgs) => ({
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel -->
<!-- Left Panel Header Title -->
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<span class="text-neutral text-base">Title</span>
</template>
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
</template>
<!-- Header -->
@@ -411,7 +415,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Right Panel - Only when hasRightPanel is true -->
<template #rightPanel>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
<RightSidePanel />
</template>
</BaseModalLayout>
</div>

View File

@@ -8,26 +8,13 @@
:style="gridStyle"
>
<nav
class="h-full overflow-hidden bg-modal-panel-background flex flex-col"
class="h-full overflow-hidden"
:inert="!showLeftPanel"
:aria-hidden="!showLeftPanel"
>
<header
data-component-id="LeftPanelHeader"
class="flex w-full h-18 shrink-0 gap-2 pl-6 pr-3 items-center-safe"
>
<slot name="leftPanelHeaderTitle" />
<Button
v-if="!notMobile && showLeftPanel"
size="lg"
class="w-10 p-0 ml-auto"
:aria-label="t('g.hideLeftPanel')"
@click="toggleLeftPanel"
>
<i class="icon-[lucide--panel-left-close]" />
</Button>
</header>
<slot name="leftPanel" />
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
<slot name="leftPanel" />
</div>
</nav>
<div class="flex flex-col bg-base-background overflow-hidden">
@@ -37,13 +24,22 @@
>
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile && !showLeftPanel"
size="lg"
class="w-10 p-0"
:aria-label="t('g.showLeftPanel')"
v-if="!notMobile"
size="icon"
:aria-label="
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
"
@click="toggleLeftPanel"
>
<i class="icon-[lucide--panel-left]" />
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
</Button>
<slot name="header" />
</div>
@@ -73,7 +69,7 @@
<slot name="contentFilter" />
<h2
v-if="!hasLeftPanel"
class="text-xxl m-0 select-none px-6 pt-2 pb-6 capitalize"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
@@ -98,10 +94,7 @@
data-component-id="RightPanelHeader"
class="flex h-18 shrink-0 items-center gap-2 px-6"
>
<h2
v-if="rightPanelTitle"
class="flex-1 select-none text-base font-semibold"
>
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
{{ rightPanelTitle }}
</h2>
<div v-else class="flex-1">
@@ -141,6 +134,7 @@ 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()

View File

@@ -5,7 +5,7 @@
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'

View File

@@ -7,6 +7,20 @@ const meta: Meta<typeof LeftSidePanel> = {
title: 'Components/Widget/Panel/LeftSidePanel',
component: LeftSidePanel,
argTypes: {
'header-icon': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'header-title': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'onUpdate:modelValue': {
table: { disable: true }
}
@@ -45,7 +59,14 @@ export const Default: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
</template>
</LeftSidePanel>
</div>
`
})
@@ -105,7 +126,14 @@ export const WithGroups: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
</template>
</LeftSidePanel>
<div class="mt-4 p-2 text-sm">
Selected: {{ selectedItem }}
</div>
@@ -148,7 +176,14 @@ export const DefaultIcons: Story = {
},
template: `
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
</template>
</LeftSidePanel>
</div>
`
})
@@ -193,7 +228,14 @@ export const LongLabels: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>
</template>
</LeftSidePanel>
</div>
`
})

View File

@@ -1,41 +1,47 @@
<template>
<div
class="flex w-full flex-auto overflow-y-auto gap-1 min-h-0 flex-col bg-modal-panel-background scrollbar-hide px-3"
>
<template
v-for="item in navItems"
:key="'title' in item ? item.title : item.id"
<div class="flex h-full w-full flex-col bg-modal-panel-background">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>
</template>
<slot name="header-title"></slot>
</PanelHeader>
<nav
class="flex scrollbar-hide flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
>
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ subItem.label }}
{{ item.label }}
</NavItem>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ item.label }}
</NavItem>
</div>
</template>
</div>
</template>
</nav>
</div>
</template>
@@ -46,6 +52,8 @@ import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import PanelHeader from './PanelHeader.vue'
const { navItems = [], modelValue } = defineProps<{
navItems?: (NavItemData | NavGroupData)[]
modelValue?: string | null

View File

@@ -0,0 +1,12 @@
<template>
<header class="flex h-16 items-center justify-between px-6">
<div class="flex items-center gap-2 pl-1">
<slot name="icon">
<i class="text-neutral icon-[lucide--puzzle] text-base" />
</slot>
<h2 class="text-neutral text-base font-bold">
<slot></slot>
</h2>
</div>
</header>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4">
<slot></slot>
</div>
</template>

View File

@@ -6,9 +6,6 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { NodeId } from '@/renderer/core/layout/types'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { createMockSubgraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock the app module
vi.mock('@/scripts/app', () => ({
@@ -32,12 +29,10 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
}))
// Mock Positionable objects
// @ts-expect-error - Mock implementation for testing
class MockNode implements Positionable {
pos: [number, number]
size: [number, number]
id: NodeId
boundingRect: ReadOnlyRect
constructor(
pos: [number, number] = [0, 0],
@@ -45,13 +40,6 @@ class MockNode implements Positionable {
) {
this.pos = pos
this.size = size
this.id = 'mock-node'
this.boundingRect = [0, 0, 0, 0]
}
move(): void {}
snapToGrid(_: number): boolean {
return true
}
}
@@ -73,7 +61,7 @@ class MockReroute extends Reroute implements Positionable {
describe('useSelectedLiteGraphItems', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let mockCanvas: { selectedItems: Set<Positionable> }
let mockCanvas: any
beforeEach(() => {
setActivePinia(createPinia())
@@ -85,9 +73,7 @@ describe('useSelectedLiteGraphItems', () => {
}
// Mock getCanvas to return our mock canvas
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
)
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
})
describe('isIgnoredItem', () => {
@@ -100,6 +86,7 @@ describe('useSelectedLiteGraphItems', () => {
it('should return false for non-Reroute items', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const node = new MockNode()
// @ts-expect-error - Test mock
expect(isIgnoredItem(node)).toBe(false)
})
})
@@ -111,11 +98,14 @@ describe('useSelectedLiteGraphItems', () => {
const node2 = new MockNode([100, 100])
const reroute = new MockReroute([50, 50])
// @ts-expect-error - Test mocks
const items = new Set<Positionable>([node1, node2, reroute])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(2)
// @ts-expect-error - Test mocks
expect(filtered.has(node1)).toBe(true)
// @ts-expect-error - Test mocks
expect(filtered.has(node2)).toBe(true)
expect(filtered.has(reroute)).toBe(false)
})
@@ -153,7 +143,9 @@ describe('useSelectedLiteGraphItems', () => {
const selectableItems = getSelectableItems()
expect(selectableItems.size).toBe(2)
// @ts-expect-error - Test mock
expect(selectableItems.has(node1)).toBe(true)
// @ts-expect-error - Test mock
expect(selectableItems.has(node2)).toBe(true)
expect(selectableItems.has(reroute)).toBe(false)
})
@@ -263,7 +255,14 @@ describe('useSelectedLiteGraphItems', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
const subgraphNode = {
id: 1,
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
@@ -280,7 +279,14 @@ describe('useSelectedLiteGraphItems', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
const subgraphNode = {
id: 1,
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
@@ -304,10 +310,14 @@ describe('useSelectedLiteGraphItems', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
const subgraphNode = createMockSubgraphNode([subNode1, subNode2], {
const subgraphNode = {
id: 1,
mode: LGraphEventMode.NEVER // Already in NEVER mode
})
mode: LGraphEventMode.NEVER, // Already in NEVER mode
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode }

View File

@@ -1,3 +1,19 @@
/**
* Shorthand for {@link Parameters} of optional callbacks.
*
* @example
* ```ts
* const { onClick } = CustomClass.prototype
* CustomClass.prototype.onClick = function (...args: CallbackParams<typeof onClick>) {
* const r = onClick?.apply(this, args)
* // ...
* return r
* }
* ```
*/
export type CallbackParams<T extends ((...args: any) => any) | undefined> =
Parameters<Exclude<T, undefined>>
/**
* Chain multiple callbacks together.
*
@@ -5,21 +21,15 @@
* @param callbacks - The callbacks to chain.
* @returns A new callback that chains the original callback with the callbacks.
*/
export function useChainCallback<O, T>(
export const useChainCallback = <
O,
T extends (this: O, ...args: any[]) => void
>(
originalCallback: T | undefined,
...callbacks: NonNullable<T> extends (this: O, ...args: infer P) => unknown
? ((this: O, ...args: P) => void)[]
: never
) {
type Args = NonNullable<T> extends (...args: infer P) => unknown ? P : never
type Ret = NonNullable<T> extends (...args: unknown[]) => infer R ? R : never
return function (this: O, ...args: Args) {
if (typeof originalCallback === 'function') {
;(originalCallback as (this: O, ...args: Args) => Ret).call(this, ...args)
}
for (const callback of callbacks) {
callback.call(this, ...args)
}
} as (this: O, ...args: Args) => Ret
...callbacks: ((this: O, ...args: Parameters<T>) => void)[]
) => {
return function (this: O, ...args: Parameters<T>) {
originalCallback?.call(this, ...args)
for (const callback of callbacks) callback.call(this, ...args)
}
}

View File

@@ -1,37 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import * as measure from '@/lib/litegraph/src/measure'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
createMockLGraphNode,
createMockLGraphGroup
} from '@/utils/__tests__/litegraphTestUtils'
import { useGraphHierarchy } from './useGraphHierarchy'
vi.mock('@/renderer/core/canvas/canvasStore')
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
return {
...createMockLGraphNode(),
boundingRect: new Rectangle(100, 100, 50, 50),
...overrides
} as LGraphNode
}
function createMockGroup(overrides: Partial<LGraphGroup> = {}): LGraphGroup {
return createMockLGraphGroup(overrides)
}
describe('useGraphHierarchy', () => {
let mockCanvasStore: Partial<ReturnType<typeof useCanvasStore>>
let mockCanvasStore: ReturnType<typeof useCanvasStore>
let mockNode: LGraphNode
let mockGroups: LGraphGroup[]
beforeEach(() => {
mockNode = createMockNode()
mockNode = {
boundingRect: [100, 100, 50, 50]
} as unknown as LGraphNode
mockGroups = []
mockCanvasStore = {
@@ -39,21 +25,10 @@ describe('useGraphHierarchy', () => {
graph: {
groups: mockGroups
}
},
$id: 'canvas',
$state: {},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {}
} as unknown as Partial<ReturnType<typeof useCanvasStore>>
}
} as any
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>
)
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore)
})
describe('findParentGroup', () => {
@@ -66,9 +41,9 @@ describe('useGraphHierarchy', () => {
})
it('returns null when node is not in any group', () => {
const group = createMockGroup({
boundingRect: new Rectangle(0, 0, 50, 50)
})
const group = {
boundingRect: [0, 0, 50, 50]
} as unknown as LGraphGroup
mockGroups.push(group)
vi.spyOn(measure, 'containsCentre').mockReturnValue(false)
@@ -80,9 +55,9 @@ describe('useGraphHierarchy', () => {
})
it('returns the only group when node is in exactly one group', () => {
const group = createMockGroup({
boundingRect: new Rectangle(0, 0, 200, 200)
})
const group = {
boundingRect: [0, 0, 200, 200]
} as unknown as LGraphGroup
mockGroups.push(group)
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
@@ -94,12 +69,12 @@ describe('useGraphHierarchy', () => {
})
it('returns the smallest group when node is in multiple groups', () => {
const largeGroup = createMockGroup({
boundingRect: new Rectangle(0, 0, 300, 300)
})
const smallGroup = createMockGroup({
boundingRect: new Rectangle(50, 50, 100, 100)
})
const largeGroup = {
boundingRect: [0, 0, 300, 300]
} as unknown as LGraphGroup
const smallGroup = {
boundingRect: [50, 50, 100, 100]
} as unknown as LGraphGroup
mockGroups.push(largeGroup, smallGroup)
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
@@ -112,12 +87,12 @@ describe('useGraphHierarchy', () => {
})
it('returns the inner group when one group contains another', () => {
const outerGroup = createMockGroup({
boundingRect: new Rectangle(0, 0, 300, 300)
})
const innerGroup = createMockGroup({
boundingRect: new Rectangle(50, 50, 100, 100)
})
const outerGroup = {
boundingRect: [0, 0, 300, 300]
} as unknown as LGraphGroup
const innerGroup = {
boundingRect: [50, 50, 100, 100]
} as unknown as LGraphGroup
mockGroups.push(outerGroup, innerGroup)
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
@@ -138,7 +113,7 @@ describe('useGraphHierarchy', () => {
})
it('handles null canvas gracefully', () => {
mockCanvasStore.canvas = null
mockCanvasStore.canvas = null as any
const { findParentGroup } = useGraphHierarchy()
const result = findParentGroup(mockNode)
@@ -147,7 +122,7 @@ describe('useGraphHierarchy', () => {
})
it('handles null graph gracefully', () => {
mockCanvasStore.canvas!.graph = null
mockCanvasStore.canvas!.graph = null as any
const { findParentGroup } = useGraphHierarchy()
const result = findParentGroup(mockNode)

View File

@@ -1,19 +1,55 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
// Mock composables
// Test interfaces
interface TestNodeConfig {
type?: string
mode?: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
}
interface TestNode {
type: string
mode: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
isSubgraphNode: () => boolean
}
type MockedItem = TestNode | { type: string; isNode: boolean }
// Mock all stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: vi.fn()
}))
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
@@ -27,28 +63,102 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
filterOutputNodes: vi.fn()
}))
// Mock comment/connection objects with additional properties
const mockComment = {
...createMockPositionable({ id: 999 }),
type: 'comment',
isNode: false
}
const mockConnection = {
...createMockPositionable({ id: 1000 }),
type: 'connection',
isNode: false
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
return {
type: config.type || 'TestNode',
mode: config.mode || LGraphEventMode.ALWAYS,
flags: config.flags,
pinned: config.pinned,
removable: config.removable,
isSubgraphNode: () => false
}
}
// Mock comment/connection objects
const mockComment = { type: 'comment', isNode: false }
const mockConnection = { type: 'connection', isNode: false }
describe('useSelectionState', () => {
// Mock store instances
let mockSelectedItems: Ref<MockedItem[]>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Create testing Pinia instance
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Setup mock canvas store with proper ref
mockSelectedItems = ref([])
vi.mocked(useCanvasStore).mockReturnValue({
selectedItems: mockSelectedItems,
// Add minimal required properties for the store
$id: 'canvas',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node def store
vi.mocked(useNodeDefStore).mockReturnValue({
fromLGraphNode: vi.fn((node: TestNode) => {
if (node?.type === 'TestNode') {
return { nodePath: 'test.TestNode', name: 'TestNode' }
}
return null
}),
// Add minimal required properties for the store
$id: 'nodeDef',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock sidebar tab store
const mockToggleSidebarTab = vi.fn()
vi.mocked(useSidebarTabStore).mockReturnValue({
activeSidebarTabId: null,
toggleSidebarTab: mockToggleSidebarTab,
// Add minimal required properties for the store
$id: 'sidebarTab',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node help store
const mockOpenHelp = vi.fn()
const mockCloseHelp = vi.fn()
const mockNodeHelpStore = {
isHelpOpen: false,
currentHelpNode: null,
openHelp: mockOpenHelp,
closeHelp: mockCloseHelp,
// Add minimal required properties for the store
$id: 'nodeHelp',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
}
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
@@ -56,7 +166,7 @@ describe('useSelectionState', () => {
title: 'Node Library',
type: 'custom',
render: () => null
} as ReturnType<typeof useNodeLibrarySidebarTab>)
} as any)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
@@ -67,8 +177,8 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
vi.mocked(filterOutputNodes).mockImplementation(
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
)
})
@@ -79,10 +189,10 @@ describe('useSelectionState', () => {
})
test('should return true when items selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
const node2 = createMockLGraphNode({ id: 2 })
canvasStore.$state.selectedItems = [node1, node2]
// Update the mock data before creating the composable
const node1 = createTestNode()
const node2 = createTestNode()
mockSelectedItems.value = [node1, node2]
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
@@ -91,13 +201,9 @@ describe('useSelectionState', () => {
describe('Node Type Filtering', () => {
test('should pick only LGraphNodes from mixed selections', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 3 })
canvasStore.$state.selectedItems = [
graphNode,
mockComment,
mockConnection
]
// Update the mock data before creating the composable
const graphNode = createTestNode()
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
const { selectedNodes } = useSelectionState()
expect(selectedNodes.value).toHaveLength(1)
@@ -107,12 +213,9 @@ describe('useSelectionState', () => {
describe('Node State Computation', () => {
test('should detect bypassed nodes', () => {
const canvasStore = useCanvasStore()
const bypassedNode = createMockLGraphNode({
id: 4,
mode: LGraphEventMode.BYPASS
})
canvasStore.$state.selectedItems = [bypassedNode]
// Update the mock data before creating the composable
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
mockSelectedItems.value = [bypassedNode]
const { selectedNodes } = useSelectionState()
const isBypassed = selectedNodes.value.some(
@@ -122,13 +225,10 @@ describe('useSelectionState', () => {
})
test('should detect pinned/collapsed states', () => {
const canvasStore = useCanvasStore()
const pinnedNode = createMockLGraphNode({ id: 5, pinned: true })
const collapsedNode = createMockLGraphNode({
id: 6,
flags: { collapsed: true }
})
canvasStore.$state.selectedItems = [pinnedNode, collapsedNode]
// Update the mock data before creating the composable
const pinnedNode = createTestNode({ pinned: true })
const collapsedNode = createTestNode({ flags: { collapsed: true } })
mockSelectedItems.value = [pinnedNode, collapsedNode]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
@@ -144,9 +244,9 @@ describe('useSelectionState', () => {
})
test('should provide non-reactive state computation', () => {
const canvasStore = useCanvasStore()
const node = createMockLGraphNode({ id: 7, pinned: true })
canvasStore.$state.selectedItems = [node]
// Update the mock data before creating the composable
const node = createTestNode({ pinned: true })
mockSelectedItems.value = [node]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
@@ -162,7 +262,7 @@ describe('useSelectionState', () => {
expect(isBypassed).toBe(false)
// Test with empty selection using new composable instance
canvasStore.$state.selectedItems = []
mockSelectedItems.value = []
const { selectedNodes: newSelectedNodes } = useSelectionState()
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)

View File

@@ -75,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as typeof ImageBitmap
} as unknown as typeof globalThis.ImageBitmap
}
describe('useCanvasHistory', () => {

View File

@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
const mockStore = {
imgCanvas: null! as HTMLCanvasElement,
maskCanvas: null! as HTMLCanvasElement,
rgbCanvas: null! as HTMLCanvasElement,
imgCtx: null! as CanvasRenderingContext2D,
maskCtx: null! as CanvasRenderingContext2D,
rgbCtx: null! as CanvasRenderingContext2D,
canvasBackground: null! as HTMLElement,
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
rgbCtx: null as any,
canvasBackground: null as any,
maskColor: { r: 0, g: 0, b: 0 },
maskBlendMode: MaskBlendMode.Black,
maskOpacity: 0.8
@@ -38,30 +38,26 @@ describe('useCanvasManager', () => {
height: 100
} as ImageData
const partialImgCtx: Partial<CanvasRenderingContext2D> = {
mockStore.imgCtx = {
drawImage: vi.fn()
}
mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D
const partialMaskCtx: Partial<CanvasRenderingContext2D> = {
mockStore.maskCtx = {
drawImage: vi.fn(),
getImageData: vi.fn(() => mockImageData),
putImageData: vi.fn(),
globalCompositeOperation: 'source-over',
fillStyle: ''
}
mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D
const partialRgbCtx: Partial<CanvasRenderingContext2D> = {
mockStore.rgbCtx = {
drawImage: vi.fn()
}
mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D
const partialImgCanvas: Partial<HTMLCanvasElement> = {
mockStore.imgCanvas = {
width: 0,
height: 0
}
mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement
mockStore.maskCanvas = {
width: 0,
@@ -69,19 +65,19 @@ describe('useCanvasManager', () => {
style: {
mixBlendMode: '',
opacity: ''
} as Pick<CSSStyleDeclaration, 'mixBlendMode' | 'opacity'>
} as HTMLCanvasElement
}
}
mockStore.rgbCanvas = {
width: 0,
height: 0
} as HTMLCanvasElement
}
mockStore.canvasBackground = {
style: {
backgroundColor: ''
} as Pick<CSSStyleDeclaration, 'backgroundColor'>
} as HTMLElement
}
}
mockStore.maskColor = { r: 0, g: 0, b: 0 }
mockStore.maskBlendMode = MaskBlendMode.Black
@@ -167,7 +163,7 @@ describe('useCanvasManager', () => {
it('should throw error when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.imgCanvas = null! as HTMLCanvasElement
mockStore.imgCanvas = null
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
@@ -180,7 +176,7 @@ describe('useCanvasManager', () => {
it('should throw error when context missing', async () => {
const manager = useCanvasManager()
mockStore.imgCtx = null! as CanvasRenderingContext2D
mockStore.imgCtx = null
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
@@ -263,7 +259,7 @@ describe('useCanvasManager', () => {
it('should return early when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.maskCanvas = null! as HTMLCanvasElement
mockStore.maskCanvas = null
await manager.updateMaskColor()
@@ -273,7 +269,7 @@ describe('useCanvasManager', () => {
it('should return early when context missing', async () => {
const manager = useCanvasManager()
mockStore.maskCtx = null! as CanvasRenderingContext2D
mockStore.maskCtx = null
await manager.updateMaskColor()

View File

@@ -4,37 +4,17 @@ import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
// Mock store interface matching the real store's nullable fields
interface MockMaskEditorStore {
maskCtx: CanvasRenderingContext2D | null
imgCtx: CanvasRenderingContext2D | null
maskCanvas: HTMLCanvasElement | null
imgCanvas: HTMLCanvasElement | null
rgbCtx: CanvasRenderingContext2D | null
rgbCanvas: HTMLCanvasElement | null
maskColor: { r: number; g: number; b: number }
paintBucketTolerance: number
fillOpacity: number
colorSelectTolerance: number
colorComparisonMethod: ColorComparisonMethod
selectionOpacity: number
applyWholeImage: boolean
maskBoundary: boolean
maskTolerance: number
canvasHistory: { saveState: ReturnType<typeof vi.fn> }
}
const mockCanvasHistory = {
saveState: vi.fn()
}
const mockStore: MockMaskEditorStore = {
maskCtx: null,
imgCtx: null,
maskCanvas: null,
imgCanvas: null,
rgbCtx: null,
rgbCanvas: null,
const mockStore = {
maskCtx: null as any,
imgCtx: null as any,
maskCanvas: null as any,
imgCanvas: null as any,
rgbCtx: null as any,
rgbCanvas: null as any,
maskColor: { r: 255, g: 255, b: 255 },
paintBucketTolerance: 10,
fillOpacity: 100,
@@ -77,40 +57,34 @@ describe('useCanvasTools', () => {
mockImgImageData.data[i + 3] = 255
}
const partialMaskCtx: Partial<CanvasRenderingContext2D> = {
mockStore.maskCtx = {
getImageData: vi.fn(() => mockMaskImageData),
putImageData: vi.fn(),
clearRect: vi.fn()
}
mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D
const partialImgCtx: Partial<CanvasRenderingContext2D> = {
mockStore.imgCtx = {
getImageData: vi.fn(() => mockImgImageData)
}
mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D
const partialRgbCtx: Partial<CanvasRenderingContext2D> = {
mockStore.rgbCtx = {
clearRect: vi.fn()
}
mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D
const partialMaskCanvas: Partial<HTMLCanvasElement> = {
mockStore.maskCanvas = {
width: 100,
height: 100
}
mockStore.maskCanvas = partialMaskCanvas as HTMLCanvasElement
const partialImgCanvas: Partial<HTMLCanvasElement> = {
mockStore.imgCanvas = {
width: 100,
height: 100
}
mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement
const partialRgbCanvas: Partial<HTMLCanvasElement> = {
mockStore.rgbCanvas = {
width: 100,
height: 100
}
mockStore.rgbCanvas = partialRgbCanvas as HTMLCanvasElement
mockStore.maskColor = { r: 255, g: 255, b: 255 }
mockStore.paintBucketTolerance = 10
@@ -129,13 +103,13 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
@@ -180,7 +154,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: -1, y: 50 })
expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', () => {
@@ -190,7 +164,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should apply fill opacity', () => {
@@ -224,19 +198,14 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.imgCtx!.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
@@ -247,7 +216,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should respect color tolerance', async () => {
@@ -270,7 +239,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: -1, y: 50 })
expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', async () => {
@@ -280,7 +249,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should apply selection opacity', async () => {
@@ -301,7 +270,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should use LAB color comparison method', async () => {
@@ -311,7 +280,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should respect mask boundary', async () => {
@@ -326,7 +295,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should update last color select point', async () => {
@@ -334,7 +303,7 @@ describe('useCanvasTools', () => {
await tools.colorSelectFill({ x: 30, y: 40 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
})
@@ -351,13 +320,13 @@ describe('useCanvasTools', () => {
tools.invertMask()
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith(
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
@@ -400,7 +369,7 @@ describe('useCanvasTools', () => {
tools.invertMask()
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', () => {
@@ -420,8 +389,8 @@ describe('useCanvasTools', () => {
tools.clearMask()
expect(mockStore.maskCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
@@ -432,7 +401,7 @@ describe('useCanvasTools', () => {
tools.clearMask()
expect(mockStore.maskCtx?.clearRect).not.toHaveBeenCalled()
expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
@@ -443,8 +412,8 @@ describe('useCanvasTools', () => {
tools.clearMask()
expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx?.clearRect).not.toHaveBeenCalled()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
})
@@ -457,26 +426,26 @@ describe('useCanvasTools', () => {
tools.clearLastColorSelectPoint()
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('should handle small canvas', () => {
mockStore.maskCanvas!.width = 1
mockStore.maskCanvas!.height = 1
mockStore.maskCanvas.width = 1
mockStore.maskCanvas.height = 1
mockMaskImageData = {
data: new Uint8ClampedArray(1 * 1 * 4),
width: 1,
height: 1
} as ImageData
mockStore.maskCtx!.getImageData = vi.fn(() => mockMaskImageData)
mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData)
const tools = useCanvasTools()
tools.paintBucketFill({ x: 0, y: 0 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle fractional coordinates', () => {
@@ -484,7 +453,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50.7, y: 50.3 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle maximum tolerance', () => {
@@ -494,7 +463,7 @@ describe('useCanvasTools', () => {
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle zero opacity', () => {

View File

@@ -95,7 +95,7 @@ if (typeof globalThis.ImageData === 'undefined') {
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
}
}
} as typeof ImageData
} as unknown as typeof globalThis.ImageData
}
// Mock ImageBitmap for test environment using safe type casting
@@ -108,7 +108,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
this.height = height
}
close() {}
} as typeof ImageBitmap
} as unknown as typeof globalThis.ImageBitmap
}
describe('useCanvasTransform', () => {

View File

@@ -2,39 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
type MockStore = {
imgCanvas: HTMLCanvasElement | null
maskCanvas: HTMLCanvasElement | null
rgbCanvas: HTMLCanvasElement | null
imgCtx: CanvasRenderingContext2D | null
maskCtx: CanvasRenderingContext2D | null
image: HTMLImageElement | null
}
type MockDataStore = {
inputData: {
baseLayer: { image: HTMLImageElement }
maskLayer: { image: HTMLImageElement }
paintLayer: { image: HTMLImageElement } | null
} | null
}
const mockCanvasManager = {
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
updateMaskColor: vi.fn().mockResolvedValue(undefined)
}
const mockStore: MockStore = {
imgCanvas: null,
maskCanvas: null,
rgbCanvas: null,
imgCtx: null,
maskCtx: null,
image: null
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
image: null as any
}
const mockDataStore: MockDataStore = {
inputData: null
const mockDataStore = {
inputData: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
@@ -50,8 +33,7 @@ vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
fn
createSharedComposable: (fn: any) => fn
}))
describe('useImageLoader', () => {
@@ -79,26 +61,26 @@ describe('useImageLoader', () => {
mockStore.imgCtx = {
clearRect: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
}
mockStore.maskCtx = {
clearRect: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
}
mockStore.imgCanvas = {
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
mockStore.maskCanvas = {
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
mockStore.rgbCanvas = {
width: 0,
height: 0
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
}
mockDataStore.inputData = {
baseLayer: { image: mockBaseImage },
@@ -122,10 +104,10 @@ describe('useImageLoader', () => {
await loader.loadImages()
expect(mockStore.maskCanvas?.width).toBe(512)
expect(mockStore.maskCanvas?.height).toBe(512)
expect(mockStore.rgbCanvas?.width).toBe(512)
expect(mockStore.rgbCanvas?.height).toBe(512)
expect(mockStore.maskCanvas.width).toBe(512)
expect(mockStore.maskCanvas.height).toBe(512)
expect(mockStore.rgbCanvas.width).toBe(512)
expect(mockStore.rgbCanvas.height).toBe(512)
})
it('should clear canvas contexts', async () => {
@@ -133,8 +115,8 @@ describe('useImageLoader', () => {
await loader.loadImages()
expect(mockStore.imgCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
})
it('should call canvasManager methods', async () => {
@@ -206,10 +188,10 @@ describe('useImageLoader', () => {
await loader.loadImages()
expect(mockStore.maskCanvas?.width).toBe(1024)
expect(mockStore.maskCanvas?.height).toBe(768)
expect(mockStore.rgbCanvas?.width).toBe(1024)
expect(mockStore.rgbCanvas?.height).toBe(768)
expect(mockStore.maskCanvas.width).toBe(1024)
expect(mockStore.maskCanvas.height).toBe(768)
expect(mockStore.rgbCanvas.width).toBe(1024)
expect(mockStore.rgbCanvas.height).toBe(768)
})
})
})

View File

@@ -71,19 +71,8 @@ export const useNodeBadge = () => {
}
onMounted(() => {
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
// TODO: Fix the composables and watchers being setup in onMounted
const nodePricing = useNodePricing()
watch(
() => nodePricing.pricingRevision.value,
() => {
if (!showApiPricingBadge.value) return
app.canvas?.setDirty(true, true)
}
)
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
@@ -122,16 +111,17 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
// Get the pricing function to determine if this node has dynamic pricing
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
!!pricingConfig &&
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
typeof pricingConfig?.displayPrice === 'function'
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
return priceBadge.getCreditsBadge(price)
}
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
// (We no longer rely on it to hold the current badge value.)
if (hasDynamicPricing) {
// For dynamic pricing nodes, use computed that watches widget changes
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
@@ -143,63 +133,13 @@ export const useNodeBadge = () => {
triggerCanvasRedraw: true
})
// Ensure watchers are installed; ignore the returned value.
// (This call is what registers the widget listeners in most implementations.)
computedWithWidgetWatch(() => 0)
// Hook into connection changes to trigger price recalculation
// This handles both connect and disconnect in VueNodes mode
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
const inputGroupPrefixes =
pricingConfig?.depends_on?.input_groups ?? []
const hasRelevantInputs =
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
if (hasRelevantInputs) {
const originalOnConnectionsChange = node.onConnectionsChange
node.onConnectionsChange = function (
type,
slotIndex,
isConnected,
link,
ioSlot
) {
originalOnConnectionsChange?.call(
this,
type,
slotIndex,
isConnected,
link,
ioSlot
)
// Only trigger if this input affects pricing
const inputName = ioSlot?.name
if (!inputName) return
const isRelevantInput =
relevantInputs.includes(inputName) ||
inputGroupPrefixes.some((prefix) =>
inputName.startsWith(prefix + '.')
)
if (isRelevantInput) {
nodePricing.triggerPriceRecalculation(node)
}
}
}
creditsBadge = computedWithWidgetWatch(createBadge)
} else {
// For static pricing nodes, use regular computed
creditsBadge = computed(createBadge)
}
let lastLabel = nodePricing.getNodeDisplayPrice(node)
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
const creditsBadgeGetter: () => LGraphBadge = () => {
const label = nodePricing.getNodeDisplayPrice(node)
if (label !== lastLabel) {
lastLabel = label
lastBadge = priceBadge.getCreditsBadge(label)
}
return lastBadge
}
node.badges.push(creditsBadgeGetter)
node.badges.push(() => creditsBadge.value)
}
},
init() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,11 @@ import { nextTick } from 'vue'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock useChainCallback
vi.mock('@/composables/functional/useChainCallback', () => ({
useChainCallback: vi.fn((original, newCallback) => {
return function (this: unknown, ...args: unknown[]) {
return function (this: any, ...args: any[]) {
original?.call(this, ...args)
newCallback.call(this, ...args)
}
@@ -19,12 +18,11 @@ describe('useComputedWithWidgetWatch', () => {
const createMockNode = (
widgets: Array<{
name: string
value: unknown
callback?: (...args: unknown[]) => void
value: any
callback?: (...args: any[]) => void
}> = []
): LGraphNode => {
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
) => {
const mockNode = {
widgets: widgets.map((widget) => ({
name: widget.name,
value: widget.value,
@@ -33,7 +31,9 @@ describe('useComputedWithWidgetWatch', () => {
graph: {
setDirtyCanvas: vi.fn()
}
})
} as unknown as LGraphNode
return mockNode
}
it('should create a reactive computed that responds to widget changes', async () => {
@@ -59,9 +59,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change widget value and trigger callback
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget && widthWidget.callback) {
if (widthWidget) {
widthWidget.value = 150
widthWidget.callback(widthWidget.value)
;(widthWidget.callback as any)?.()
}
await nextTick()
@@ -89,9 +89,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change observed widget
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget && widthWidget.callback) {
if (widthWidget) {
widthWidget.value = 150
widthWidget.callback(widthWidget.value)
;(widthWidget.callback as any)?.()
}
await nextTick()
@@ -117,9 +117,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget && widget.callback) {
if (widget) {
widget.value = 20
widget.callback(widget.value)
;(widget.callback as any)?.()
}
await nextTick()
@@ -139,9 +139,9 @@ describe('useComputedWithWidgetWatch', () => {
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget && widget.callback) {
if (widget) {
widget.value = 20
widget.callback(widget.value)
;(widget.callback as any)?.()
}
await nextTick()
@@ -171,8 +171,8 @@ describe('useComputedWithWidgetWatch', () => {
// Trigger widget callback
const widget = mockNode.widgets?.[0]
if (widget && widget.callback) {
widget.callback(widget.value)
if (widget) {
;(widget.callback as any)?.()
}
await nextTick()

View File

@@ -11,18 +11,13 @@ vi.mock('@/platform/distribution/types', () => ({
const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: (url: string, filename?: string) => {
if (filename === undefined) {
return downloadFileMock(url)
}
return downloadFileMock(url, filename)
}
downloadFile: (...args: any[]) => downloadFileMock(...args)
}))
const copyToClipboardMock = vi.fn()
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: (text: string) => copyToClipboardMock(text)
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args)
})
}))
@@ -35,8 +30,8 @@ vi.mock('@/i18n', () => ({
const mapTaskOutputToAssetItemMock = vi.fn()
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
mapTaskOutputToAssetItem: (taskItem: TaskItemImpl, output: ResultItemImpl) =>
mapTaskOutputToAssetItemMock(taskItem, output)
mapTaskOutputToAssetItem: (...args: any[]) =>
mapTaskOutputToAssetItemMock(...args)
}))
const mediaAssetActionsMock = {
@@ -72,16 +67,14 @@ const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (runningPromptId: string | null) =>
interruptMock(runningPromptId),
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
interrupt: (...args: any[]) => interruptMock(...args),
deleteItem: (...args: any[]) => deleteItemMock(...args)
}
}))
const downloadBlobMock = vi.fn()
vi.mock('@/scripts/utils', () => ({
downloadBlob: (filename: string, blob: Blob) =>
downloadBlobMock(filename, blob)
downloadBlob: (...args: any[]) => downloadBlobMock(...args)
}))
const dialogServiceMock = {
@@ -101,14 +94,11 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => litegraphServiceMock
}))
const nodeDefStoreMock: {
nodeDefsByName: Record<string, Partial<ComfyNodeDefImpl>>
} = {
nodeDefsByName: {}
const nodeDefStoreMock = {
nodeDefsByName: {} as Record<string, any>
}
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => nodeDefStoreMock,
ComfyNodeDefImpl: class {}
useNodeDefStore: () => nodeDefStoreMock
}))
const queueStoreMock = {
@@ -128,13 +118,12 @@ vi.mock('@/stores/executionStore', () => ({
const getJobWorkflowMock = vi.fn()
vi.mock('@/services/jobOutputCache', () => ({
getJobWorkflow: (jobId: string) => getJobWorkflowMock(jobId)
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)
}))
const createAnnotatedPathMock = vi.fn()
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: (filename: string, subfolder: string, type: string) =>
createAnnotatedPathMock(filename, subfolder, type)
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
}))
const appendJsonExtMock = vi.fn((value: string) =>
@@ -146,8 +135,7 @@ vi.mock('@/utils/formatUtil', () => ({
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
type MockTaskRef = Record<string, unknown>
@@ -205,9 +193,9 @@ describe('useJobMenu', () => {
}))
createAnnotatedPathMock.mockReturnValue('annotated-path')
nodeDefStoreMock.nodeDefsByName = {
LoadImage: { name: 'LoadImage' },
LoadVideo: { name: 'LoadVideo' },
LoadAudio: { name: 'LoadAudio' }
LoadImage: { id: 'LoadImage' },
LoadVideo: { id: 'LoadVideo' },
LoadAudio: { id: 'LoadAudio' }
}
// Default: no workflow available via lazy loading
getJobWorkflowMock.mockResolvedValue(undefined)
@@ -269,7 +257,7 @@ describe('useJobMenu', () => {
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async (state) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
setCurrentItem(createJobItem({ state: state as any }))
await cancelJob()
@@ -304,9 +292,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: {
errorMessage: 'Something went wrong'
} as Partial<TaskItemImpl>
taskRef: { errorMessage: 'Something went wrong' } as any
})
)
@@ -338,7 +324,7 @@ describe('useJobMenu', () => {
errorMessage: 'CUDA out of memory',
executionError,
createTime: 12345
} as Partial<TaskItemImpl>
} as any
})
)
@@ -358,9 +344,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: {
errorMessage: 'Job failed with error'
} as Partial<TaskItemImpl>
taskRef: { errorMessage: 'Job failed with error' } as any
})
)
@@ -382,7 +366,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
taskRef: { errorMessage: undefined } as any
})
)
@@ -530,12 +514,7 @@ describe('useJobMenu', () => {
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
@@ -564,12 +543,7 @@ describe('useJobMenu', () => {
it('ignores download request when preview missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
@@ -777,7 +751,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
taskRef: { errorMessage: 'Some error' } as any
})
)

View File

@@ -11,28 +11,13 @@ vi.mock('@/i18n', () => ({
}))
// Mock the execution store
const executionStore = reactive<{
isIdle: boolean
executionProgress: number
executingNode: unknown
executingNodeProgress: number
nodeProgressStates: Record<string, unknown>
activePrompt: {
workflow: {
changeTracker: {
activeState: {
nodes: { id: number; type: string }[]
}
}
}
} | null
}>({
const executionStore = reactive({
isIdle: true,
executionProgress: 0,
executingNode: null,
executingNode: null as any,
executingNodeProgress: 0,
nodeProgressStates: {},
activePrompt: null
nodeProgressStates: {} as any,
activePrompt: null as any
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -40,21 +25,15 @@ vi.mock('@/stores/executionStore', () => ({
// Mock the setting store
const settingStore = reactive({
get: vi.fn((_key: string) => 'Enabled')
get: vi.fn(() => 'Enabled')
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
// Mock the workflow store
const workflowStore = reactive<{
activeWorkflow: {
filename: string
isModified: boolean
isPersisted: boolean
} | null
}>({
activeWorkflow: null
const workflowStore = reactive({
activeWorkflow: null as any
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
@@ -73,13 +52,13 @@ describe('useBrowserTabTitle', () => {
// reset execution store
executionStore.isIdle = true
executionStore.executionProgress = 0
executionStore.executingNode = null
executionStore.executingNode = null as any
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activePrompt = null
// reset setting and workflow stores
vi.mocked(settingStore.get).mockReturnValue('Enabled')
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = null
workspaceStore.shiftDown = false
@@ -95,7 +74,7 @@ describe('useBrowserTabTitle', () => {
})
it('sets workflow name as title when workflow exists and menu enabled', async () => {
vi.mocked(settingStore.get).mockReturnValue('Enabled')
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
@@ -109,7 +88,7 @@ describe('useBrowserTabTitle', () => {
})
it('adds asterisk for unsaved workflow', async () => {
vi.mocked(settingStore.get).mockReturnValue('Enabled')
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
@@ -123,7 +102,7 @@ describe('useBrowserTabTitle', () => {
})
it('hides asterisk when autosave is enabled', async () => {
vi.mocked(settingStore.get).mockImplementation((key: string) => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'after delay'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
@@ -139,7 +118,7 @@ describe('useBrowserTabTitle', () => {
})
it('hides asterisk while Shift key is held', async () => {
vi.mocked(settingStore.get).mockImplementation((key: string) => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'off'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
@@ -158,7 +137,7 @@ describe('useBrowserTabTitle', () => {
// Fails when run together with other tests. Suspect to be caused by leaked
// state from previous tests.
it.skip('disables workflow title when menu disabled', async () => {
vi.mocked(settingStore.get).mockReturnValue('Disabled')
;(settingStore.get as any).mockReturnValue('Disabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,

View File

@@ -4,7 +4,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
describe('useCachedRequest', () => {
let mockRequestFn: (
params: unknown,
params: any,
signal?: AbortSignal
) => Promise<unknown | null>
let abortSpy: () => void
@@ -25,7 +25,7 @@ describe('useCachedRequest', () => {
)
// Create a mock request function that returns different results based on params
mockRequestFn = vi.fn(async (params: unknown) => {
mockRequestFn = vi.fn(async (params: any) => {
// Simulate a request that takes some time
await new Promise((resolve) => setTimeout(resolve, 8))
@@ -138,18 +138,12 @@ describe('useCachedRequest', () => {
it('should use custom cache key function if provided', async () => {
// Create a cache key function that sorts object keys
const cacheKeyFn = (params: unknown) => {
const cacheKeyFn = (params: any) => {
if (typeof params !== 'object' || params === null) return String(params)
return JSON.stringify(
Object.keys(params as Record<string, unknown>)
Object.keys(params)
.sort()
.reduce(
(acc, key) => ({
...acc,
[key]: (params as Record<string, unknown>)[key]
}),
{}
)
.reduce((acc, key) => ({ ...acc, [key]: params[key] }), {})
)
}

View File

@@ -3,11 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// Mock vue-i18n for useExternalLink
const mockLocale = ref('en')
@@ -108,89 +106,30 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
describe('useCoreCommands', () => {
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
const baseNode = createMockLGraphNode({ id })
return Object.assign(baseNode, {
constructor: {
...baseNode.constructor,
comfyClass
}
})
}
const createMockSubgraph = () => {
const mockNodes = [
const mockSubgraph = {
nodes: [
// Mock input node
createMockNode(1, 'SubgraphInputNode'),
{
constructor: { comfyClass: 'SubgraphInputNode' },
id: 'input1'
},
// Mock output node
createMockNode(2, 'SubgraphOutputNode'),
{
constructor: { comfyClass: 'SubgraphOutputNode' },
id: 'output1'
},
// Mock user node
createMockNode(3, 'SomeUserNode'),
{
constructor: { comfyClass: 'SomeUserNode' },
id: 'user1'
},
// Another mock user node
createMockNode(4, 'AnotherUserNode')
]
return {
nodes: mockNodes,
remove: vi.fn(),
events: {
dispatch: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
},
name: 'test-subgraph',
inputNode: undefined,
outputNode: undefined,
add: vi.fn(),
clear: vi.fn(),
serialize: vi.fn(),
configure: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
runStep: vi.fn(),
findNodeByTitle: vi.fn(),
findNodesByTitle: vi.fn(),
findNodesByType: vi.fn(),
findNodeById: vi.fn(),
getNodeById: vi.fn(),
setDirtyCanvas: vi.fn(),
sendActionToCanvas: vi.fn()
} as Partial<typeof app.canvas.subgraph> as typeof app.canvas.subgraph
}
const mockSubgraph = createMockSubgraph()
function createMockSettingStore(
getReturnValue: boolean
): ReturnType<typeof useSettingStore> {
return {
get: vi.fn().mockReturnValue(getReturnValue),
addSetting: vi.fn(),
load: vi.fn(),
set: vi.fn(),
exists: vi.fn(),
getDefaultValue: vi.fn(),
isReady: true,
isLoading: false,
error: undefined,
settingValues: {},
settingsById: {},
$id: 'setting',
$state: {
settingValues: {},
settingsById: {},
isReady: true,
isLoading: false,
error: undefined
},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set()
} satisfies ReturnType<typeof useSettingStore>
{
constructor: { comfyClass: 'AnotherUserNode' },
id: 'user2'
}
],
remove: vi.fn()
}
beforeEach(() => {
@@ -203,7 +142,9 @@ describe('useCoreCommands', () => {
app.canvas.subgraph = undefined
// Mock settings store
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(false) // Skip confirmation dialog
} as any)
// Mock global confirm
global.confirm = vi.fn().mockReturnValue(true)
@@ -226,7 +167,7 @@ describe('useCoreCommands', () => {
it('should preserve input/output nodes when clearing subgraph', async () => {
// Set up subgraph context
app.canvas.subgraph = mockSubgraph
app.canvas.subgraph = mockSubgraph as any
const commands = useCoreCommands()
const clearCommand = commands.find(
@@ -240,19 +181,24 @@ describe('useCoreCommands', () => {
expect(app.rootGraph.clear).not.toHaveBeenCalled()
// Should only remove user nodes, not input/output nodes
const subgraph = app.canvas.subgraph!
expect(subgraph.remove).toHaveBeenCalledTimes(2)
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1
expect(mockSubgraph.remove).toHaveBeenCalledTimes(2)
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[0]
) // input1
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[1]
) // output1
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should respect confirmation setting', async () => {
// Mock confirmation required
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(true) // Require confirmation
} as any)
global.confirm = vi.fn().mockReturnValue(false) // User cancels

View File

@@ -67,9 +67,10 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
@@ -1234,11 +1235,8 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.ToggleLinear',
icon: 'pi pi-database',
label: 'Toggle Simple Mode',
function: (metadata?: Record<string, unknown>) => {
const source =
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
function: () => {
const newMode = !canvasStore.linearMode
if (newMode) useTelemetry()?.trackEnterLinear({ source })
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode

View File

@@ -630,10 +630,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
cleanup,
hasSkeleton: false,
intensity: lightIntensity,
showSkeleton: false
cleanup
}
}

View File

@@ -1,4 +1,4 @@
import { refThrottled, watchDebounced } from '@vueuse/core'
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -84,7 +84,7 @@ export function useTemplateFiltering(
return ['ComfyUI', 'External or Remote API']
})
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -1,17 +1,10 @@
import { t } from '@/i18n'
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
const distribution = getDistribution()
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
})
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
// Zendesk feedback URL - update this with the actual URL
const ZENDESK_FEEDBACK_URL =
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
const buttons: ActionBarButton[] = [
{
@@ -19,7 +12,7 @@ const buttons: ActionBarButton[] = [
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
}
}
]

View File

@@ -2,6 +2,7 @@ import { watchDebounced } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useExtensionService } from '@/services/extensionService'
@@ -25,7 +26,7 @@ useExtensionService().registerExtension({
{ debounce: 256, immediate: true }
)
// Poll for config updates every 10 minutes (with auth)
setInterval(() => void refreshRemoteConfig(), 600_000)
// Poll for config updates every 10 minutes
setInterval(() => void loadRemoteConfig(), 600_000)
}
})

View File

@@ -32,17 +32,13 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudFeedbackTopbarButton')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')
}
}
// Feedback button for cloud and nightly builds
if (isCloud || isNightly) {
await import('./cloudFeedbackTopbarButton')
}
// Nightly-only extensions
if (isNightly && !isCloud) {
await import('./nightlyBadges')

View File

@@ -1,4 +1,7 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import {
type CallbackParams,
useChainCallback
} from '@/composables/functional/useChainCallback'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
@@ -8,10 +11,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -26,7 +26,7 @@ import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
controlValues?: TWidgetValue[]
controlValues?: any[]
lastType?: string
static override category: string
constructor(title: string) {
@@ -561,7 +561,7 @@ app.registerExtension({
const origOnInputDblClick = nodeType.prototype.onInputDblClick
nodeType.prototype.onInputDblClick = function (
this: LGraphNode,
...[slot, ...args]: Parameters<NonNullable<typeof origOnInputDblClick>>
...[slot, ...args]: CallbackParams<typeof origOnInputDblClick>
) {
const r = origOnInputDblClick?.apply(this, [slot, ...args])

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