Compare commits

..

1 Commits

Author SHA1 Message Date
Si Feng
c0500f2f4e testenvs 2026-01-23 16:36:28 -08:00
122 changed files with 1411 additions and 3899 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

@@ -539,4 +539,4 @@ For comprehensive troubleshooting and technical support, please refer to our off
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting

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

@@ -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

@@ -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

@@ -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

@@ -10,6 +10,7 @@
<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'
@@ -20,13 +21,15 @@ 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 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

View File

@@ -12,10 +12,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 +33,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
function createWrapper() {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -56,7 +53,7 @@ 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,
@@ -145,71 +142,6 @@ describe('TopMenuSection', () => {
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' })

View File

@@ -45,13 +45,7 @@
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"
@@ -61,11 +55,7 @@
{{ 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" />
@@ -87,7 +77,6 @@
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
@@ -119,7 +108,6 @@ 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'
@@ -138,10 +126,8 @@ 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,12 +144,6 @@ 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'))
)
@@ -205,10 +185,6 @@ onMounted(() => {
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex size-8 items-center justify-center rounded-md text-base font-semibold text-white"
class="flex size-6 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)'

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

@@ -11,7 +11,7 @@
: ''
]"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"
>
<template #header>
@@ -41,15 +41,12 @@
</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'
import { computed } from 'vue'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
@@ -57,22 +54,6 @@ const teamWorkspacesEnabled = computed(
)
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>
@@ -92,13 +73,10 @@ function getDialogPt(item: {
.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 {

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

@@ -9,66 +9,17 @@
{{ workspaceName }}
</h1>
</div>
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
<Tabs :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 class="w-full">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</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"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
@@ -85,7 +36,7 @@
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
item.disabled ? 'pointer-events-auto' : ''
]"
@click="
item.command?.({
@@ -102,12 +53,9 @@
</template>
</div>
<TabPanels unstyled>
<TabPanels>
<TabPanel value="plan">
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
<SubscriptionPanelContent />
</TabPanel>
</TabPanels>
</Tabs>
@@ -126,11 +74,8 @@ 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 SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
@@ -143,20 +88,12 @@ 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 { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
@@ -186,16 +123,6 @@ const deleteTooltip = computed(() => {
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 = []
@@ -232,7 +159,5 @@ const menuItems = computed(() => {
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -79,7 +79,8 @@ const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})

View File

@@ -69,7 +69,7 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})

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,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

@@ -14,24 +14,16 @@ function toggleLinearMode() {
<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"
>
<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"
>

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

@@ -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

@@ -27,7 +27,7 @@
: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>

View File

@@ -36,6 +36,15 @@
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
<div
v-if="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
@@ -83,23 +92,15 @@
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) -->
<SubscribeButton
v-else-if="isPersonalWorkspace"
v-else
disabled
: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" />
@@ -197,6 +198,7 @@ import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
@@ -219,7 +221,8 @@ const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed
isWorkspaceSubscribed,
subscriptionPlan
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
@@ -237,12 +240,24 @@ const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const { t } = useI18n()
const displayedCredits = computed(() => {
const isSubscribed = isPersonalWorkspace.value
? isActiveSubscription.value
: isWorkspaceSubscribed.value
return isSubscribed ? totalCredits.value : '0'
const displayedCredits = computed(() =>
isWorkspaceSubscribed.value ? totalCredits.value : '0'
)
// Workspace subscription tier name (not user tier)
const workspaceTierName = computed(() => {
if (!isWorkspaceSubscribed.value) return null
if (!subscriptionPlan.value) return null
// Convert plan to display name
if (subscriptionPlan.value === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (subscriptionPlan.value === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
})
const canUpgrade = computed(() => {

View File

@@ -38,22 +38,13 @@
: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">
<span class="text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span
v-if="workspace.type !== 'personal'"
class="text-sm text-muted-foreground"
>
{{ getRoleLabel(workspace.role) }}
</span>
</div>
@@ -67,6 +58,8 @@
</template>
</template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
@@ -114,23 +107,19 @@ 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
import { cn } from '@/utils/tailwindUtil'
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
}
const emit = defineEmits<{
@@ -140,7 +129,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
@@ -151,9 +139,7 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
id: w.id,
name: w.name,
type: w.type,
role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan
role: w.role
}))
)
@@ -167,22 +153,6 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
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) {

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,9 +71,6 @@ export const useNodeBadge = () => {
}
onMounted(() => {
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
// TODO: Fix the composables and watchers being setup in onMounted
const nodePricing = useNodePricing()
watch(

View File

@@ -4,7 +4,6 @@ import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { PriceBadge } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// -----------------------------------------------------------------------------
// Test Types
@@ -27,6 +26,13 @@ interface MockNodeData {
price_badge?: PriceBadge
}
interface MockNode {
id: string
widgets: MockNodeWidget[]
inputs: MockNodeInput[]
constructor: { nodeData: MockNodeData }
}
// -----------------------------------------------------------------------------
// Test Helpers
// -----------------------------------------------------------------------------
@@ -74,8 +80,8 @@ function createMockNodeWithPriceBadge(
link: connected ? 1 : null
}))
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
const node: MockNode = {
id: Math.random().toString(),
widgets: mockWidgets,
inputs: mockInputs,
constructor: {
@@ -85,7 +91,9 @@ function createMockNodeWithPriceBadge(
price_badge: priceBadge
}
}
})
}
return node as unknown as LGraphNode
}
/** Helper to create a price badge with defaults */
@@ -100,20 +108,6 @@ const priceBadge = (
depends_on: { widgets, inputs, input_groups: inputGroups }
})
/** Helper to create a mock node for edge case testing */
function createMockNode(
nodeData: MockNodeData,
widgets: MockNodeWidget[] = [],
inputs: MockNodeInput[] = []
): LGraphNode {
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
widgets,
inputs,
constructor: { nodeData }
})
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -462,23 +456,37 @@ describe('useNodePricing', () => {
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode({
name: 'RegularNode',
api_node: false
})
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const price = getNodeDisplayPrice(node)
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
expect(price).toBe('')
})
it('should return empty string for nodes without price_badge', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode({
name: 'ApiNodeNoPricing',
api_node: true
})
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'ApiNodeNoPricing',
api_node: true
}
}
}
const price = getNodeDisplayPrice(node)
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
expect(price).toBe('')
})
@@ -551,23 +559,37 @@ describe('useNodePricing', () => {
it('should return undefined for nodes without price_badge', () => {
const { getNodePricingConfig } = useNodePricing()
const node = createMockNode({
name: 'NoPricingNode',
api_node: true
})
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'NoPricingNode',
api_node: true
}
}
}
const config = getNodePricingConfig(node)
const config = getNodePricingConfig(node as unknown as LGraphNode)
expect(config).toBeUndefined()
})
it('should return undefined for non-API nodes', () => {
const { getNodePricingConfig } = useNodePricing()
const node = createMockNode({
name: 'RegularNode',
api_node: false
})
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
const config = getNodePricingConfig(node)
const config = getNodePricingConfig(node as unknown as LGraphNode)
expect(config).toBeUndefined()
})
})
@@ -620,12 +642,21 @@ describe('useNodePricing', () => {
it('should not throw for non-API nodes', () => {
const { triggerPriceRecalculation } = useNodePricing()
const node = createMockNode({
name: 'RegularNode',
api_node: false
})
const node: MockNode = {
id: 'test',
widgets: [],
inputs: [],
constructor: {
nodeData: {
name: 'RegularNode',
api_node: false
}
}
}
expect(() => triggerPriceRecalculation(node)).not.toThrow()
expect(() =>
triggerPriceRecalculation(node as unknown as LGraphNode)
).not.toThrow()
})
})
@@ -720,32 +751,35 @@ describe('useNodePricing', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Create a node with autogrow-style inputs (group.input1, group.input2, etc.)
const node = createMockNode(
{
name: 'TestInputGroupNode',
api_node: true,
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": inputGroups.videos * 0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
},
[],
[
const node: MockNode = {
id: Math.random().toString(),
widgets: [],
inputs: [
{ name: 'videos.clip1', link: 1 }, // connected
{ name: 'videos.clip2', link: 2 }, // connected
{ name: 'videos.clip3', link: null }, // disconnected
{ name: 'other_input', link: 3 } // connected but not in group
]
)
],
constructor: {
nodeData: {
name: 'TestInputGroupNode',
api_node: true,
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": inputGroups.videos * 0.05}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
}
}
}
getNodeDisplayPrice(node)
getNodeDisplayPrice(node as unknown as LGraphNode)
await new Promise((r) => setTimeout(r, 50))
const price = getNodeDisplayPrice(node)
const price = getNodeDisplayPrice(node as unknown as LGraphNode)
// 2 connected inputs in 'videos' group * 0.05 = 0.10
expect(price).toBe(creditsLabel(0.1))
})

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()

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,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])

View File

@@ -1,7 +1,6 @@
{
"g": {
"user": "User",
"you": "You",
"currentUser": "Current user",
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
@@ -2101,10 +2100,6 @@
"creator": "30 min",
"pro": "1 hr",
"founder": "30 min"
},
"billingComingSoon": {
"title": "Coming Soon",
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
}
},
"userSettings": {
@@ -2118,38 +2113,8 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
"planCredits": "Plan & Credits"
},
"menu": {
"editWorkspace": "Edit workspace details",
@@ -2172,32 +2137,6 @@
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
},
"removeMemberDialog": {
"title": "Remove this member?",
"message": "This member will be removed from your workspace. Credits they've used will not be refunded.",
"remove": "Remove member",
"success": "Member removed",
"error": "Failed to remove member"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
@@ -2206,34 +2145,19 @@
"create": "Create"
},
"toast": {
"workspaceCreated": {
"title": "Workspace created",
"message": "Subscribe to a plan, invite teammates, and start collaborating.",
"subscribe": "Subscribe"
},
"workspaceUpdated": {
"title": "Workspace updated",
"message": "Workspace details have been saved."
},
"workspaceDeleted": {
"title": "Workspace deleted",
"message": "The workspace has been permanently deleted."
},
"workspaceLeft": {
"title": "Left workspace",
"message": "You have left the workspace."
},
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
"failedToLeaveWorkspace": "Failed to leave workspace"
}
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"personal": "Personal",
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
@@ -2759,8 +2683,7 @@
"noneSearchDesc": "No items match your search",
"nodesNoneDesc": "NO NODES",
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs"
"fallbackNodeTitle": "Node"
},
"help": {
"recentReleases": "Recent releases",
@@ -2786,10 +2709,7 @@
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
}
},
"workspaceAuth": {
"errors": {
@@ -2800,7 +2720,6 @@
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
},
"nightly": {
"badge": {
"label": "Preview Version",

View File

@@ -2329,7 +2329,7 @@
}
},
"EmptyHunyuanImageLatent": {
"display_name": "空Latent图像Hunyuan",
"display_name": "空Latent图像",
"inputs": {
"batch_size": {
"name": "批次大小"

View File

@@ -14,7 +14,6 @@ import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
@@ -44,10 +43,6 @@ const firebaseApp = initializeApp(getFirebaseConfig())
const app = createApp(App)
const pinia = createPinia()
const bootstrapStore = useBootstrapStore(pinia)
bootstrapStore.startEarlyBootstrap()
Sentry.init({
app,
dsn: __SENTRY_DSN__,
@@ -93,6 +88,4 @@ app
modules: [VueFireAuth()]
})
void bootstrapStore.startStoreBootstrap()
app.mount('#vue-app')

View File

@@ -1,7 +1,5 @@
<template>
<div
class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1 select-none"
>
<div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<span
v-for="badge in badges"
:key="badge.label"

View File

@@ -7,18 +7,19 @@
:right-panel-title="$t('assetBrowser.modelInfo.title')"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[comfy--ai-model] size-4" />
<h2 class="flex-auto select-none text-base font-semibold text-nowrap">
{{ displayTitle }}
</h2>
</template>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedNavItem"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items
/>
>
<template #header-icon>
<div class="icon-[comfy--ai-model] size-4" />
</template>
<template #header-title>
<span class="capitalize">{{ displayTitle }}</span>
</template>
</LeftSidePanel>
</template>
<template #header>

View File

@@ -7,7 +7,7 @@
:tabindex="interactive ? 0 : -1"
:class="
cn(
'select-none rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4',
focused && 'bg-secondary-background outline-solid'

View File

@@ -79,9 +79,8 @@ const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref<SortOption>('recent')
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
() => assets
)
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
const emit = defineEmits<{
filterChange: [filters: FilterState]

View File

@@ -15,7 +15,7 @@
</div>
<div
v-else-if="assets.length === 0"
class="flex h-full select-none flex-col items-center justify-center py-16 text-muted-foreground"
class="flex h-full flex-col items-center justify-center py-16 text-muted-foreground"
>
<i class="mb-4 icon-[lucide--search] size-10" />
<h3 class="mb-2 text-lg font-medium">

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-2 px-4 py-2 text-sm text-base-foreground">
<div class="flex items-center justify-between relative">
<span class="select-none">{{ label }}</span>
<span>{{ label }}</span>
<slot name="label-action" />
</div>
<slot />

View File

@@ -5,7 +5,7 @@
>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter select-none">
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.basicInfo') }}
</span>
</template>
@@ -58,7 +58,7 @@
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter select-none">
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.modelTagging') }}
</span>
</template>
@@ -134,7 +134,7 @@
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter select-none">
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.modelDescription') }}
</span>
</template>

View File

@@ -149,13 +149,3 @@ export function getAssetUserDescription(asset: AssetItem): string {
? asset.user_metadata.user_description
: ''
}
/**
* Gets the filename for an asset with fallback chain
* Checks user_metadata.filename first, then metadata.filename, then asset.name
* @param asset - The asset to extract filename from
* @returns The filename string
*/
export function getAssetFilename(asset: AssetItem): string {
return getStringProperty(asset, 'filename') ?? asset.name
}

View File

@@ -226,30 +226,6 @@ describe('createModelNodeFromAsset', () => {
expect(result.success).toBe(true)
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
})
it('should fallback to asset.metadata.filename when user_metadata.filename missing', async () => {
const asset = createMockAsset({
user_metadata: {},
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
})
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe(
'models/checkpoints/from-metadata.safetensors'
)
})
it('should fallback to asset.name when both filename sources missing', async () => {
const asset = createMockAsset({
user_metadata: {},
metadata: undefined
})
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe('test-model.safetensors')
})
it('should add node to active subgraph when present', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
@@ -277,18 +253,27 @@ describe('createModelNodeFromAsset', () => {
})
it.each([
{
case: 'missing user_metadata with no fallback',
overrides: { user_metadata: undefined, metadata: undefined, name: '' },
case: 'missing user_metadata',
overrides: { user_metadata: undefined },
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string/
errorPattern: /missing required user_metadata/
},
{
case: 'empty filename with no fallback',
overrides: {
user_metadata: { filename: '' },
metadata: undefined,
name: ''
},
case: 'missing filename property',
overrides: { user_metadata: {} },
expectedCode: 'INVALID_ASSET' as const,
errorPattern:
/Invalid filename.*expected non-empty string, got undefined/
},
{
case: 'non-string filename',
overrides: { user_metadata: { filename: 123 } },
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string, got number/
},
{
case: 'empty filename',
overrides: { user_metadata: { filename: '' } },
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string/
}

View File

@@ -6,7 +6,6 @@ import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -70,8 +69,21 @@ export function createModelNodeFromAsset(
const validAsset = validatedAsset.data
const filename = getAssetFilename(validAsset)
if (filename.length === 0) {
const userMetadata = validAsset.user_metadata
if (!userMetadata) {
console.error(`Asset ${validAsset.id} missing required user_metadata`)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset missing required user_metadata',
assetId: validAsset.id
}
}
}
const filename = userMetadata.filename
if (typeof filename !== 'string' || filename.length === 0) {
console.error(
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
)

View File

@@ -26,16 +26,14 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: true }))
const mockRemoteConfig = vi.hoisted(() => ({
value: {
team_workspaces_enabled: true
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
const mockWorkspace = {
@@ -624,11 +622,11 @@ describe('useWorkspaceAuthStore', () => {
describe('feature flag disabled', () => {
beforeEach(() => {
mockTeamWorkspacesEnabled.value = false
mockRemoteConfig.value.team_workspaces_enabled = false
})
afterEach(() => {
mockTeamWorkspacesEnabled.value = true
mockRemoteConfig.value.team_workspaces_enabled = true
})
it('initializeFromSession returns false when flag disabled', () => {

View File

@@ -14,7 +14,7 @@ const mockSubscriptionTier = ref<
const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockGetFirebaseAuthHeader = vi.fn(() =>
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
@@ -53,7 +53,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getFirebaseAuthHeader: mockGetFirebaseAuthHeader
getAuthHeader: mockGetAuthHeader
}),
FirebaseAuthStoreError: class extends Error {}
}))

View File

@@ -68,7 +68,7 @@
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import { computed, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -85,9 +85,7 @@ const SubscriptionPanelContentWorkspace = defineAsyncComponent(
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const { buildDocsUrl, docsPaths } = useExternalLink()

View File

@@ -1,12 +1,10 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
>
<div class="flex items-center justify-between gap-2">
<!-- OWNER Unsubscribed State -->
<template v-if="showSubscribePrompt">
<template v-if="isOwnerUnsubscribed">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
@@ -17,7 +15,6 @@
</div>
<Button
variant="primary"
size="lg"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
@@ -68,14 +65,12 @@
</div>
</div>
<div
<template
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<Button
size="lg"
variant="secondary"
class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
@@ -85,24 +80,23 @@
{{ $t('subscription.managePayment') }}
</Button>
<Button
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal text-text-primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</div>
</template>
</template>
</div>
</div>
@@ -253,7 +247,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
@@ -271,34 +264,26 @@ import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed, isInPersonalWorkspace } =
storeToRefs(workspaceStore)
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
const { showBillingComingSoonDialog } = useDialogService()
// Show subscribe prompt to owners without active subscription
const showSubscribePrompt = computed(() => {
if (workspaceRole.value !== 'owner') return false
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
return !isWorkspaceSubscribed.value
})
// OWNER with unsubscribed workspace - can see subscribe button
const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
)
// MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => showSubscribePrompt.value || isMemberView.value
() => isOwnerUnsubscribed.value || isMemberView.value
)
// Subscribe workspace - show billing coming soon dialog for team workspaces
// Demo: Subscribe workspace to PRO monthly plan
function handleSubscribeWorkspace() {
if (!isInPersonalWorkspace.value) {
showBillingComingSoonDialog()
return
}
subscribeWorkspace('PRO_MONTHLY')
}

View File

@@ -203,10 +203,6 @@ function useSubscriptionInternal() {
if (loggedIn) {
try {
await fetchSubscriptionStatus()
} catch (error) {
// Network errors are expected during navigation/component unmount
// and when offline - log for debugging but don't surface to user
console.error('Failed to fetch subscription status:', error)
} finally {
isInitialized.value = true
}

View File

@@ -35,8 +35,8 @@ export async function performSubscriptionCheckout(
): Promise<void> {
if (!isCloud) return
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
const authHeader = await getFirebaseAuthHeader()
const { getAuthHeader } = useFirebaseAuthStore()
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))

View File

@@ -56,9 +56,7 @@ const writeToStorage = (
}
export const hydratePreservedQuery = (namespace: string) => {
if (preservedQueries.has(namespace)) {
return
}
if (preservedQueries.has(namespace)) return
const payload = readFromStorage(namespace)
if (payload) {
preservedQueries.set(namespace, payload)
@@ -79,9 +77,7 @@ export const capturePreservedQuery = (
}
})
if (Object.keys(payload).length === 0) {
return
}
if (Object.keys(payload).length === 0) return
preservedQueries.set(namespace, payload)
writeToStorage(namespace, payload)

View File

@@ -1,4 +1,3 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite'
TEMPLATE: 'template'
} as const

View File

@@ -2,27 +2,25 @@
<div
:class="
teamWorkspacesEnabled
? 'flex h-full w-full overflow-auto flex-col md:flex-row'
? 'flex h-[80vh] w-full overflow-hidden'
: 'settings-container'
"
>
<ScrollPanel
:class="
teamWorkspacesEnabled
? 'w-full md:w-64 md:min-w-64 md:max-w-64 shrink-0 p-2'
? 'w-48 shrink-0 p-2 2xl:w-64'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
"
>
<div :class="teamWorkspacesEnabled ? 'px-4' : ''">
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
<Listbox
v-model="activeCategory"
:options="groupedMenuTreeNodes"
@@ -64,7 +62,7 @@
:lazy="true"
:class="
teamWorkspacesEnabled
? 'h-full flex-1 overflow-auto scrollbar-custom'
? 'h-full flex-1 overflow-x-auto'
: 'settings-content h-full w-full'
"
>

View File

@@ -1174,15 +1174,5 @@ export const CORE_SETTINGS: SettingParams[] = [
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Node.AlwaysShowAdvancedWidgets',
category: ['LiteGraph', 'Node Widget', 'AlwaysShowAdvancedWidgets'],
name: 'Always show advanced widgets on all nodes',
tooltip:
'When enabled, advanced widgets are always visible on all nodes without needing to expand them individually.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.39.0'
}
]

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -33,7 +32,7 @@ describe('useSettingStore', () => {
let store: ReturnType<typeof useSettingStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
setActivePinia(createPinia())
store = useSettingStore()
vi.clearAllMocks()
})
@@ -43,18 +42,18 @@ describe('useSettingStore', () => {
expect(store.settingsById).toEqual({})
})
describe('load', () => {
describe('loadSettingValues', () => {
it('should load settings from API', async () => {
const mockSettings = { 'test.setting': 'value' }
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any)
await store.load()
await store.loadSettingValues()
expect(store.settingValues).toEqual(mockSettings)
expect(api.getSettings).toHaveBeenCalled()
})
it('should set error if settings are loaded after registration', async () => {
it('should throw error if settings are loaded after registration', async () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'test.setting',
@@ -63,14 +62,9 @@ describe('useSettingStore', () => {
}
store.addSetting(setting)
await store.load()
expect(store.error).toBeInstanceOf(Error)
if (store.error instanceof Error) {
expect(store.error.message).toBe(
'Setting values must be loaded before any setting is registered.'
)
}
await expect(store.loadSettingValues()).rejects.toThrow(
'Setting values must be loaded before any setting is registered.'
)
})
})
@@ -88,24 +82,18 @@ describe('useSettingStore', () => {
expect(store.settingsById['test.setting']).toEqual(setting)
})
it('should warn and skip for duplicate setting ID', () => {
it('should throw error for duplicate setting ID', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default'
}
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
store.addSetting(setting)
store.addSetting(setting)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Setting already registered: test.setting'
expect(() => store.addSetting(setting)).toThrow(
'Setting test.setting must have a unique ID.'
)
consoleWarnSpy.mockRestore()
})
it('should migrate deprecated values', () => {

View File

@@ -1,5 +1,4 @@
import _ from 'es-toolkit/compat'
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare, valid } from 'semver'
import { ref } from 'vue'
@@ -48,31 +47,6 @@ export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Record<string, any>>({})
const settingsById = ref<Record<string, SettingParams>>({})
const {
isReady,
isLoading,
error,
execute: loadSettingValues
} = useAsyncState(
async () => {
if (Object.keys(settingsById.value).length) {
throw new Error(
'Setting values must be loaded before any setting is registered.'
)
}
settingValues.value = await api.getSettings()
await migrateZoomThresholdToFontSize()
},
undefined,
{ immediate: false }
)
async function load() {
if (!isReady.value && !isLoading.value) {
return loadSettingValues()
}
}
/**
* Check if a setting's value exists, i.e. if the user has set it manually.
* @param key - The key of the setting to check.
@@ -196,11 +170,7 @@ export const useSettingStore = defineStore('setting', () => {
throw new Error('Settings must have an ID')
}
if (setting.id in settingsById.value) {
// Setting already registered - skip to allow component remounting
// TODO: Add store reset methods to bootstrapStore and settingStore, then
// replace window.location.reload() with router.push() in SidebarLogoutIcon.vue
console.warn(`Setting already registered: ${setting.id}`)
return
throw new Error(`Setting ${setting.id} must have a unique ID.`)
}
settingsById.value[setting.id] = setting
@@ -214,6 +184,22 @@ export const useSettingStore = defineStore('setting', () => {
onChange(setting, get(setting.id), undefined)
}
/*
* Load setting values from server.
* This needs to be called before any setting is registered.
*/
async function loadSettingValues() {
if (Object.keys(settingsById.value).length) {
throw new Error(
'Setting values must be loaded before any setting is registered.'
)
}
settingValues.value = await api.getSettings()
// Migrate old zoom threshold setting to new font size setting
await migrateZoomThresholdToFontSize()
}
/**
* Migrate the old zoom threshold setting to the new font size setting.
* Preserves the exact zoom threshold behavior by converting it to equivalent font size.
@@ -256,11 +242,8 @@ export const useSettingStore = defineStore('setting', () => {
return {
settingValues,
settingsById,
isReady,
isLoading,
error,
load,
addSetting,
loadSettingValues,
set,
get,
exists,

View File

@@ -1,5 +1,4 @@
import _ from 'es-toolkit/compat'
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
import type { Raw } from 'vue'
@@ -501,67 +500,48 @@ export const useWorkflowStore = defineStore('workflow', () => {
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
)
)
const syncWorkflows = async (dir: string = '') => {
await syncEntities(
dir ? 'workflows/' + dir : 'workflows',
workflowLookup.value,
(file) =>
new ComfyWorkflow({
path: file.path,
modified: file.modified,
size: file.size
}),
(existingWorkflow, file) => {
const isActiveWorkflow =
activeWorkflow.value?.path === existingWorkflow.path
const {
isReady: isSyncReady,
isLoading: isSyncLoading,
execute: executeSyncWorkflows
} = useAsyncState(
async (dir: string = '') => {
await syncEntities(
dir ? 'workflows/' + dir : 'workflows',
workflowLookup.value,
(file) =>
new ComfyWorkflow({
path: file.path,
modified: file.modified,
size: file.size
}),
(existingWorkflow, file) => {
const isActiveWorkflow =
activeWorkflow.value?.path === existingWorkflow.path
const nextLastModified = Math.max(
existingWorkflow.lastModified,
file.modified
)
const nextLastModified = Math.max(
existingWorkflow.lastModified,
file.modified
)
const isMetadataUnchanged =
nextLastModified === existingWorkflow.lastModified &&
file.size === existingWorkflow.size
const isMetadataUnchanged =
nextLastModified === existingWorkflow.lastModified &&
file.size === existingWorkflow.size
if (!isMetadataUnchanged) {
existingWorkflow.lastModified = nextLastModified
existingWorkflow.size = file.size
}
if (!isMetadataUnchanged) {
existingWorkflow.lastModified = nextLastModified
existingWorkflow.size = file.size
}
// Never unload the active workflow - it may contain unsaved in-memory edits.
if (isActiveWorkflow) {
return
}
// Never unload the active workflow - it may contain unsaved in-memory edits.
if (isActiveWorkflow) {
return
}
// If nothing changed, keep any loaded content cached.
if (isMetadataUnchanged) {
return
}
// If nothing changed, keep any loaded content cached.
if (isMetadataUnchanged) {
return
}
existingWorkflow.unload()
},
/* exclude */ (workflow) => workflow.isTemporary
)
},
undefined,
{ immediate: false }
)
async function syncWorkflows(dir: string = '') {
return executeSyncWorkflows(0, dir)
}
async function loadWorkflows() {
if (!isSyncReady.value && !isSyncLoading.value) {
return syncWorkflows()
}
existingWorkflow.unload()
},
/* exclude */ (workflow) => workflow.isTemporary
)
}
const bookmarkStore = useWorkflowBookmarkStore()
@@ -869,7 +849,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
modifiedWorkflows,
getWorkflowByPath,
syncWorkflows,
loadWorkflows,
isSubgraphActive,
activeSubgraph,

View File

@@ -22,7 +22,6 @@ export interface Member {
name: string
email: string
joined_at: string
role: WorkspaceRole
}
interface PaginationInfo {
@@ -111,18 +110,6 @@ async function getAuthHeaderOrThrow() {
return authHeader
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
function handleAxiosError(err: unknown): never {
if (axios.isAxiosError(err)) {
const status = err.response?.status
@@ -309,10 +296,9 @@ export const workspaceApi = {
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
*/
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
const headers = await getFirebaseHeaderOrThrow()
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),

View File

@@ -1,232 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInviteUrlLoader } from './useInviteUrlLoader'
/**
* Unit tests for useInviteUrlLoader composable
*
* Tests the behavior of accepting workspace invites via URL query parameters:
* - ?invite=TOKEN accepts the invite and shows success toast
* - Invalid/missing token is handled gracefully
* - API errors show error toast
* - URL is cleaned up after processing
* - Preserved query is restored after login redirect
*/
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('vue-i18n', () => ({
createI18n: () => ({
global: {
t: (key: string) => key
}
}),
useI18n: () => ({
t: vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
if (key === 'workspace.addedToWorkspace') {
return `You have been added to ${params?.workspaceName}`
}
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
if (key === 'g.unknownError') return 'Unknown error'
return key
})
})
}))
const mockAcceptInvite = vi.hoisted(() => vi.fn())
vi.mock('../stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
acceptInvite: mockAcceptInvite
})
}))
describe('useInviteUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('loadInviteFromUrl', () => {
it('does nothing when no invite param present', async () => {
mockRouteQuery.value = {}
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('restores preserved query and processes invite', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
invite: 'preserved-token'
})
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { invite: 'preserved-token' }
})
expect(mockAcceptInvite).toHaveBeenCalledWith('preserved-token')
})
it('accepts invite and shows success toast on success', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
})
})
it('shows error toast when invite acceptance fails', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite',
life: 5000
})
})
it('cleans up URL after processing invite', async () => {
mockRouteQuery.value = { invite: 'valid-token', other: 'param' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Should replace with query without invite param
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('clears preserved query after processing', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('clears preserved query even on error', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('sends any token format to backend for validation', async () => {
mockRouteQuery.value = { invite: 'any-token-format==' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid token'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Token is sent to backend, which validates and rejects
expect(mockAcceptInvite).toHaveBeenCalledWith('any-token-format==')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token',
life: 5000
})
})
it('ignores empty invite param', async () => {
mockRouteQuery.value = { invite: '' }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
it('ignores non-string invite param', async () => {
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,107 +0,0 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/**
* Composable for loading workspace invites from URL query parameters
*
* Supports URLs like:
* - /?invite=TOKEN (accepts workspace invite)
*
* The invite token is preserved through login redirects via the
* preserved query system (sessionStorage), following the same pattern
* as the template URL loader.
*/
export function useInviteUrlLoader() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
/**
* Hydrates preserved query from sessionStorage and merges into route.
* This restores the invite token after login redirects.
*/
const ensureInviteQueryFromIntent = async () => {
hydratePreservedQuery(INVITE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
INVITE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
/**
* Removes invite parameter from URL using Vue Router
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.invite
void router.replace({ query: newQuery })
}
/**
* Loads and accepts workspace invite from URL query parameters if present.
* Handles errors internally and shows appropriate user feedback.
*
* Flow:
* 1. Restore preserved query (for post-login redirect)
* 2. Check for invite token in route.query
* 3. Accept the invite via API (backend validates token)
* 4. Show toast notification
* 5. Clean up URL and preserved query
*/
const loadInviteFromUrl = async () => {
// Restore preserved query from sessionStorage (handles login redirect case)
const query = await ensureInviteQueryFromIntent()
const inviteParam = query.invite
if (!inviteParam || typeof inviteParam !== 'string') {
return
}
try {
const result = await workspaceStore.acceptInvite(inviteParam)
toast.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
cleanupUrlParams()
clearPreservedQuery(INVITE_NAMESPACE)
}
}
return {
loadInviteFromUrl
}
}

View File

@@ -6,11 +6,6 @@ import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/** Permission flags for workspace actions */
interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
@@ -18,14 +13,6 @@ interface WorkspacePermissions {
/** UI configuration for workspace role */
interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
@@ -37,11 +24,6 @@ function getPermissions(
): WorkspacePermissions {
if (type === 'personal') {
return {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
@@ -50,11 +32,6 @@ function getPermissions(
if (role === 'owner') {
return {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
@@ -63,11 +40,6 @@ function getPermissions(
// member role
return {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
@@ -80,14 +52,6 @@ function getUIConfig(
): WorkspaceUIConfig {
if (type === 'personal') {
return {
showMembersList: false,
showPendingTab: false,
showSearch: false,
showDateColumn: false,
showRoleBadge: false,
membersGridCols: 'grid-cols-1',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-1',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
@@ -96,14 +60,6 @@ function getUIConfig(
if (role === 'owner') {
return {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
@@ -113,14 +69,6 @@ function getUIConfig(
// member role
return {
showMembersList: true,
showPendingTab: false,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[1fr_auto]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[1fr_auto]',
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null

View File

@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
import type {
@@ -14,15 +12,14 @@ import type {
} from '../api/workspaceApi'
import { workspaceApi } from '../api/workspaceApi'
export interface WorkspaceMember {
interface WorkspaceMember {
id: string
name: string
email: string
joinDate: Date
role: 'owner' | 'member'
}
export interface PendingInvite {
interface PendingInvite {
id: string
email: string
token: string
@@ -46,8 +43,7 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
id: member.id,
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role
joinDate: new Date(member.joined_at)
}
}
@@ -64,8 +60,7 @@ function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return {
...workspace,
// Personal workspaces use user-scoped subscription from useSubscription()
isSubscribed: workspace.type === 'personal',
isSubscribed: false,
subscriptionPlan: null,
members: [],
pendingInvites: []
@@ -372,9 +367,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Clear context and switch to new workspace
workspaceAuthStore.clearWorkspaceContext()
// Clear any preserved invite query to prevent stale invites from being
// processed after the reload (prevents owner adding themselves as member)
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.INVITE)
setLastWorkspaceId(newWorkspace.id)
window.location.reload()

View File

@@ -159,7 +159,7 @@ async function rerun(e: Event) {
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="object-contain flex-1 md:contain-size md:p-3"
class="object-contain flex-1 md:contain-size"
/>
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"

View File

@@ -1,20 +1,12 @@
<script setup lang="ts">
import {
useAsyncState,
useEventListener,
useInfiniteScroll,
useScroll
} from '@vueuse/core'
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
import type { MaybeRef } from 'vue'
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
import { computed, ref, toRaw, useTemplateRef, watch } from 'vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
@@ -24,21 +16,13 @@ import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { getJobDetail } from '@/services/jobOutputCache'
import { useQueueStore, ResultItemImpl } from '@/stores/queueStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
const displayWorkflows = ref(false)
const outputs = useMediaAssets('output')
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
progressPercentStyle
} = useProgressBarBackground()
const { totalPercent, currentNodePercent } = useQueueProgress()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
@@ -62,14 +46,14 @@ defineExpose({ onWheel })
const selectedIndex = ref<[number, number]>([-1, 0])
function doEmit() {
watch(selectedIndex, () => {
const [index] = selectedIndex.value
emit('updateSelection', [
outputs.media.value[index],
selectedOutput.value,
selectedIndex.value[0] <= 0
])
}
})
const outputsRef = useTemplateRef('outputsRef')
const { reset: resetInfiniteScroll } = useInfiniteScroll(
@@ -88,76 +72,36 @@ watch(selectedIndex, () => {
const [index, key] = selectedIndex.value
if (!outputsRef.value) return
const outputElement = outputsRef.value?.querySelectorAll(
`[data-output-index="${index}"]`
)?.[key]
const outputElement = outputsRef.value?.children?.[index]?.children?.[key]
if (!outputElement) return
//container: 'nearest' is nice, but bleeding edge and chrome only
outputElement.scrollIntoView({ block: 'nearest' })
})
function outputCount(item?: AssetItem) {
function allOutputs(item?: AssetItem) {
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
return user_metadata?.outputCount ?? 0
}
if (!user_metadata?.allOutputs) return []
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
]): ResultItemImpl[] {
const knownOutputs: Record<string, ResultItem[]> = {}
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
outputs.map(
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
)
)
}
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
if (!user_metadata) return []
if (
user_metadata.allOutputs &&
user_metadata.outputCount &&
user_metadata.outputCount < user_metadata.allOutputs.length
)
return user_metadata.allOutputs
const outputRef = useAsyncState(
getJobDetail(user_metadata.promptId).then((jobDetail) => {
if (!jobDetail?.outputs) return []
return Object.entries(jobDetail.outputs).flatMap(flattenNodeOutput)
}),
[]
).state
outputsCache[item!.id] = outputRef
return outputRef
return user_metadata.allOutputs
}
const selectedOutput = computed(() => {
const [index, key] = selectedIndex.value
if (index < 0) return undefined
return toValue(allOutputs(outputs.media.value[index]))[key]
const output = allOutputs(outputs.media.value[index])[key]
if (output) return output
return allOutputs(outputs.media.value[0])[0]
})
watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (selectedIndex.value[0] <= 0) {
//force update
selectedIndex.value = [0, 0]
return
}
@@ -176,7 +120,8 @@ function gotoNextOutput() {
selectedIndex.value = [0, 0]
return
}
if (key + 1 < outputCount(outputs.media.value[index])) {
const currentItem = outputs.media.value[index]
if (allOutputs(currentItem)[key + 1]) {
selectedIndex.value = [index, key + 1]
return
}
@@ -194,8 +139,8 @@ function gotoPreviousOutput() {
}
if (index > 0) {
const len = outputCount(outputs.media.value[index - 1])
selectedIndex.value = [index - 1, len - 1]
const currentItem = outputs.media.value[index - 1]
selectedIndex.value = [index - 1, allOutputs(currentItem).length - 1]
return
}
@@ -301,24 +246,12 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
queueStore.runningTasks.length + queueStore.pendingTasks.length
"
/>
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
<div :class="progressBarContainerClass">
<div
:class="progressBarPrimaryClass"
:style="progressPercentStyle(totalPercent)"
/>
<div
:class="progressBarSecondaryClass"
:style="progressPercentStyle(currentNodePercent)"
/>
</div>
</div>
</section>
<template v-for="(item, index) in outputs.media.value" :key="index">
<div
class="border-border-subtle not-md:border-l md:border-t first:border-none not-md:h-21 md:w-full m-3"
/>
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
<template v-for="(output, key) in allOutputs(item)" :key>
<img
v-if="getMediaType(output) === 'images'"
:class="
@@ -329,7 +262,6 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
'border-2'
)
"
:data-output-index="index"
:src="output.url"
@click="selectedIndex = [index, key]"
/>
@@ -343,7 +275,6 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
'border-2'
)
"
:data-output-index="index"
@click="selectedIndex = [index, key]"
>
<i

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, useTemplateRef, watch } from 'vue'
import { useTemplateRef, watch } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
@@ -11,12 +10,12 @@ const { modelUrl } = defineProps<{
const containerRef = useTemplateRef('containerRef')
const viewer = ref(useLoad3dViewer())
const viewer = useLoad3dViewer()
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
await viewer.initializeStandaloneViewer(containerRef.value, modelUrl)
})
//TODO: refactor to add control buttons
@@ -30,25 +29,14 @@ watch([containerRef, () => modelUrl], async () => {
@resize="viewer.handleResize"
>
<div class="pointer-events-none absolute top-0 left-0 size-full">
<Load3DControls
v-model:scene-config="viewer"
v-model:model-config="viewer"
v-model:camera-config="viewer"
v-model:light-config="viewer"
:is-splat-model="viewer.isSplatModel"
:is-ply-model="viewer.isPlyModel"
:has-skeleton="viewer.hasSkeleton"
@update-background-image="viewer.handleBackgroundImageUpdate"
@export-model="viewer.exportModel"
/>
<AnimationControls
v-if="viewer.animations && viewer.animations.length > 0"
v-model:animations="viewer.animations"
v-model:playing="viewer.playing"
v-model:selected-speed="viewer.selectedSpeed"
v-model:selected-animation="viewer.selectedAnimation"
v-model:animation-progress="viewer.animationProgress"
v-model:animation-duration="viewer.animationDuration"
v-if="viewer.animations.value && viewer.animations.value.length > 0"
v-model:animations="viewer.animations.value"
v-model:playing="viewer.playing.value"
v-model:selected-speed="viewer.selectedSpeed.value"
v-model:selected-animation="viewer.selectedAnimation.value"
v-model:animation-progress="viewer.animationProgress.value"
v-model:animation-duration="viewer.animationDuration.value"
@seek="viewer.handleSeek"
/>
</div>

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