[refactor] Migrate manager code from src/composables to src/workbench/extensions/manager (2/2) (#5722)

## Summary

Continuation of

- https://github.com/Comfy-Org/ComfyUI_frontend/pull/5662

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5722-refactor-Migrate-manager-code-from-src-composables-to-src-workbench-extensions-manag-2766d73d36508165a4f5e1940967248f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Christian Byrne
2025-09-24 19:40:04 -07:00
committed by GitHub
parent 0db2a2c03e
commit 3fc17ebdac
43 changed files with 137 additions and 102 deletions

View File

@@ -74,12 +74,12 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,

View File

@@ -144,11 +144,6 @@ import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
@@ -157,7 +152,12 @@ import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manage
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'

View File

@@ -168,12 +168,12 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { showAfterWhatsNew = false, conflictedPackages } = defineProps<{
showAfterWhatsNew?: boolean

View File

@@ -44,11 +44,14 @@ vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
}))
}))
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus',
() => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
})
)
const mockToggle = vi.fn()
const mockHide = vi.fn()

View File

@@ -46,9 +46,9 @@ import Popover from 'primevue/popover'
import { valid as validSemver } from 'semver'
import { computed, ref, watch } from 'vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import type { components } from '@/types/comfyRegistryTypes'
import PackVersionSelectorPopover from '@/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const TRUNCATED_HASH_LENGTH = 7

View File

@@ -76,11 +76,14 @@ vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
}))
// Mock the conflict detection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
})
)
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))

View File

@@ -91,10 +91,10 @@ import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components } from '@/types/comfyRegistryTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'

View File

@@ -34,11 +34,11 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256

View File

@@ -27,13 +27,13 @@ import { computed } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import type { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'

View File

@@ -64,9 +64,6 @@ import { useScroll, whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
@@ -76,7 +73,10 @@ import PackEnableToggle from '@/workbench/extensions/manager/components/manager/
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/workbench/extensions/manager/types/comfyManagerTypes'
interface InfoItem {

View File

@@ -45,13 +45,13 @@
import { computed, inject, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
import PackIcon from '@/workbench/extensions/manager/components/manager/packIcon/PackIcon.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { nodePacks, hasConflict } = defineProps<{

View File

@@ -57,9 +57,6 @@
import { useAsyncState } from '@vueuse/core'
import { computed, onUnmounted, provide, toRef } from 'vue'
import { usePacksSelection } from '@/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
@@ -70,6 +67,9 @@ import PackUninstallButton from '@/workbench/extensions/manager/components/manag
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
import PackIconStacked from '@/workbench/extensions/manager/components/manager/packIcon/PackIconStacked.vue'
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]

View File

@@ -27,11 +27,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { t } from '@/i18n'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']

View File

@@ -25,11 +25,11 @@
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictDetection } from '@/composables/useConflictDetection'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { IsInstallingKey } from '@/workbench/extensions/manager/types/comfyManagerTypes'

View File

@@ -67,8 +67,6 @@ import AutoComplete from 'primevue/autocomplete'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
@@ -78,6 +76,8 @@ import type {
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/workbench/extensions/manager/components/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField

View File

@@ -0,0 +1,83 @@
import { whenever } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
const comfyManagerStore = useComfyManagerStore()
// Flag to prevent duplicate fetches during initialization
const isInitializing = ref(false)
const lastFetchedIds = ref<string>('')
const installedPackIds = computed(() =>
Array.from(comfyManagerStore.installedPacksIds)
)
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
useNodePacks(installedPackIds, options)
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
// Prevent duplicate calls during initialization
if (isInitializing.value) {
return
}
isInitializing.value = true
try {
if (comfyManagerStore.installedPacksIds.size === 0) {
await comfyManagerStore.refreshInstalledList()
}
await startFetch()
} finally {
isInitializing.value = false
}
}
// When installedPackIds changes, we need to update the nodePacks
// But only if the IDs actually changed (not just array reference)
whenever(installedPackIds, async (newIds) => {
const newIdsStr = newIds.sort().join(',')
if (newIdsStr !== lastFetchedIds.value && !isInitializing.value) {
lastFetchedIds.value = newIdsStr
await startFetch()
}
})
onUnmounted(() => {
cleanup()
})
// Create a computed property that provides installed pack info with versions
const installedPacksWithVersions = computed(() => {
const result: Array<{ id: string; version: string }> = []
for (const pack of Object.values(comfyManagerStore.installedPacks)) {
const id = pack.cnr_id || pack.aux_id
if (id) {
result.push({
id,
version: pack.ver ?? ''
})
}
}
return result
})
return {
error,
isLoading,
isReady,
installedPacks: nodePacks,
installedPacksWithVersions,
startFetchInstalled,
filterInstalledPack
}
}

View File

@@ -0,0 +1,77 @@
import { groupBy } from 'es-toolkit/compat'
import { computed, onMounted } from 'vue'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Composable to find missing NodePacks from workflow
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches workflow pack data when initialized
*/
export const useMissingNodes = () => {
const nodeDefStore = useNodeDefStore()
const comfyManagerStore = useComfyManagerStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
// Same filtering logic as ManagerDialogContent.vue
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
// Filter only uninstalled packs from workflow packs
const missingNodePacks = computed(() => {
if (!workflowPacks.value.length) return []
return filterMissingPacks(workflowPacks.value)
})
/**
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
* @param packId - The id of the pack to check
* @returns True if the pack is the comfy-core pack, false otherwise
*/
const isCorePack = (packId: NodeProperty) => {
return packId === 'comfy-core'
}
/**
* Check if a node is a missing core node
* A missing core node is a node that is in the workflow and originates from
* the comfy-core pack (pre-installed) but not registered in the node def
* store (the node def was not found on the server)
* @param node - The node to check
* @returns True if the node is a missing core node, false otherwise
*/
const isMissingCoreNode = (node: LGraphNode) => {
const packId = node.properties?.cnr_id
if (packId === undefined || !isCorePack(packId)) return false
const nodeName = node.type
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
return !isRegisteredNodeDef
}
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
const missingNodes = collectAllNodes(app.graph, isMissingCoreNode)
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
await startFetchWorkflowPacks()
}
})
return {
missingNodePacks,
missingCoreNodes,
isLoading,
error
}
}

View File

@@ -0,0 +1,43 @@
import { get, useAsyncState } from '@vueuse/core'
import type { Ref } from 'vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
/**
* Handles fetching node packs from the registry given a list of node pack IDs
*/
export const useNodePacks = (
packsIds: string[] | Ref<string[]>,
options: UseNodePacksOptions = {}
) => {
const { immediate = false } = options
const { getPacksByIds } = useComfyRegistryStore()
const fetchPacks = () => getPacksByIds.call(get(packsIds).filter(Boolean))
const {
isReady,
isLoading,
error,
execute,
state: nodePacks
} = useAsyncState(fetchPacks, [], {
immediate
})
const cleanup = () => {
getPacksByIds.cancel()
isReady.value = false
isLoading.value = false
}
return {
error,
isLoading,
isReady,
nodePacks,
startFetch: execute,
cleanup
}
}

View File

@@ -0,0 +1,35 @@
import { compare, valid } from 'semver'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const usePackUpdateStatus = (
nodePack: components['schemas']['Node']
) => {
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const installedVersion = computed(() =>
getInstalledPackVersion(nodePack.id ?? '')
)
const latestVersion = computed(() => nodePack.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !valid(installedVersion.value)
)
const isUpdateAvailable = computed(() => {
if (!isInstalled.value || isNightlyPack.value || !latestVersion.value) {
return false
}
return compare(latestVersion.value, installedVersion.value) > 0
})
return {
isUpdateAvailable,
isNightlyPack,
installedVersion,
latestVersion
}
}

View File

@@ -0,0 +1,51 @@
import { type Ref, computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']
type SelectionState = 'all-installed' | 'none-installed' | 'mixed'
/**
* Composable for managing multi-package selection states
* Handles installation status tracking and selection state determination
*/
export function usePacksSelection(nodePacks: Ref<NodePack[]>) {
const managerStore = useComfyManagerStore()
const installedPacks = computed(() =>
nodePacks.value.filter((pack) => managerStore.isPackInstalled(pack.id))
)
const notInstalledPacks = computed(() =>
nodePacks.value.filter((pack) => !managerStore.isPackInstalled(pack.id))
)
const isAllInstalled = computed(
() => installedPacks.value.length === nodePacks.value.length
)
const isNoneInstalled = computed(
() => notInstalledPacks.value.length === nodePacks.value.length
)
const isMixed = computed(
() => installedPacks.value.length > 0 && notInstalledPacks.value.length > 0
)
const selectionState = computed<SelectionState>(() => {
if (isAllInstalled.value) return 'all-installed'
if (isNoneInstalled.value) return 'none-installed'
return 'mixed'
})
return {
installedPacks,
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed,
selectionState
}
}

View File

@@ -0,0 +1,63 @@
import { type Ref, computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
type NodePack = components['schemas']['Node']
type NodeStatus = components['schemas']['NodeStatus']
type NodeVersionStatus = components['schemas']['NodeVersionStatus']
const STATUS_PRIORITY = [
'NodeStatusBanned',
'NodeVersionStatusBanned',
'NodeStatusDeleted',
'NodeVersionStatusDeleted',
'NodeVersionStatusFlagged',
'NodeVersionStatusPending',
'NodeStatusActive',
'NodeVersionStatusActive'
] as const
/**
* Composable for managing package status with priority
* Handles import failures and determines the most important status
*/
export function usePacksStatus(nodePacks: Ref<NodePack[]>) {
const conflictDetectionStore = useConflictDetectionStore()
const hasImportFailed = computed(() => {
return nodePacks.value.some((pack) => {
if (!pack.id) return false
const conflicts = conflictDetectionStore.getConflictsForPackageByID(
pack.id
)
return (
conflicts?.conflicts?.some((c) => c.type === 'import_failed') || false
)
})
})
const overallStatus = computed<NodeStatus | NodeVersionStatus>(() => {
// Check for import failed first (highest priority for installed packages)
if (hasImportFailed.value) {
// Import failed doesn't have a specific status enum, so we return active
// but the PackStatusMessage will handle it via hasImportFailed prop
return 'NodeVersionStatusActive' as NodeVersionStatus
}
// Find the highest priority status from all packages
for (const priorityStatus of STATUS_PRIORITY) {
if (nodePacks.value.some((pack) => pack.status === priorityStatus)) {
return priorityStatus as NodeStatus | NodeVersionStatus
}
}
// Default to active if no specific status found
return 'NodeVersionStatusActive' as NodeVersionStatus
})
return {
hasImportFailed,
overallStatus
}
}

View File

@@ -0,0 +1,82 @@
import { compare, valid } from 'semver'
import { computed, onMounted } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
/**
* Composable to find NodePacks that have updates available
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches installed pack data when initialized
*/
export const useUpdateAvailableNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { installedPacks, isLoading, error, startFetchInstalled } =
useInstalledPacks()
// Check if a pack has updates available (same logic as usePackUpdateStatus)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const isInstalled = comfyManagerStore.isPackInstalled(pack?.id)
if (!isInstalled) return false
const installedVersion = comfyManagerStore.getInstalledPackVersion(
pack.id ?? ''
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !valid(installedVersion)
if (isNightlyPack || !latestVersion) {
return false
}
return compare(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
// Filter only outdated packs from installed packs
const updateAvailableNodePacks = computed(() => {
if (!installedPacks.value.length) return []
return filterOutdatedPacks(installedPacks.value)
})
// Filter only enabled outdated packs
const enabledUpdateAvailableNodePacks = computed(() => {
return updateAvailableNodePacks.value.filter((pack) =>
comfyManagerStore.isPackEnabled(pack.id)
)
})
// Check if there are any enabled outdated packs
const hasUpdateAvailable = computed(() => {
return enabledUpdateAvailableNodePacks.value.length > 0
})
// Check if there are disabled packs with updates
const hasDisabledUpdatePacks = computed(() => {
return (
updateAvailableNodePacks.value.length >
enabledUpdateAvailableNodePacks.value.length
)
})
// Automatically fetch installed pack data when composable is used
onMounted(async () => {
if (!installedPacks.value.length && !isLoading.value) {
await startFetchInstalled()
}
})
return {
updateAvailableNodePacks,
enabledUpdateAvailableNodePacks,
hasUpdateAvailable,
hasDisabledUpdatePacks,
isLoading,
error
}
}

View File

@@ -0,0 +1,156 @@
import { computed, onUnmounted, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type WorkflowPack = {
id:
| ComfyWorkflowJSON['nodes'][number]['properties']['cnr_id']
| ComfyWorkflowJSON['nodes'][number]['properties']['aux_id']
version: ComfyWorkflowJSON['nodes'][number]['properties']['ver']
}
const CORE_NODES_PACK_NAME = 'comfy-core'
/**
* Handles parsing node pack metadata from nodes on the graph and fetching the
* associated node packs from the registry
*/
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
if (typeof node.properties?.cnr_id === 'string') {
return node.properties.cnr_id
}
if (typeof node.properties?.aux_id === 'string') {
return node.properties.aux_id
}
return undefined
}
/**
* Clean the version string to be used in the registry search.
* Removes the leading 'v' and trims whitespace and line terminators.
*/
const cleanVersionString = (version: string) =>
version.replace(/^v/, '').trim()
/**
* Infer the pack for a node by searching the registry for packs that have nodes
* with the same name.
*/
const inferPack = async (
node: LGraphNode
): Promise<WorkflowPack | undefined> => {
const nodeName = node.type
// Check if node is a core node
const nodeDef = nodeDefStore.nodeDefsByName[nodeName]
if (nodeDef?.nodeSource.type === 'core') {
if (!systemStatsStore.systemStats) {
await systemStatsStore.refetchSystemStats()
}
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ?? 'nightly'
}
}
// Query the registry to find which pack provides this node
const pack = await inferPackFromNodeName.call(nodeName)
if (pack) {
return {
id: pack.id,
version: pack.latest_version?.version ?? 'nightly'
}
}
// No pack found - this node doesn't exist in the registry or couldn't be
// extracted from the parent node pack successfully
return undefined
}
/**
* Map a workflow node to its pack using the node pack metadata.
* If the node pack metadata is not available, fallback to searching the
* registry for packs that have nodes with the same name.
*/
const workflowNodeToPack = async (
node: LGraphNode
): Promise<WorkflowPack | undefined> => {
const packId = getWorkflowNodePackId(node)
if (!packId) return inferPack(node) // Fallback
if (packId === CORE_NODES_PACK_NAME) return undefined
const version =
typeof node.properties.ver === 'string'
? cleanVersionString(node.properties.ver)
: undefined
return {
id: packId,
version
}
}
/**
* Get the node packs for all nodes in the workflow (including subgraphs).
*/
const getWorkflowPacks = async () => {
if (!app.graph) return []
const allNodes = collectAllNodes(app.graph)
if (!allNodes.length) return []
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
workflowPacks.value = packs.filter((pack) => pack !== undefined)
}
const packsToUniqueIds = (packs: WorkflowPack[]) =>
packs.reduce((acc, pack) => {
if (pack?.id) acc.add(pack.id)
return acc
}, new Set<string>())
const workflowPacksIds = computed(() =>
Array.from(packsToUniqueIds(workflowPacks.value))
)
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
useNodePacks(workflowPacksIds, options)
const isIdInWorkflow = (packId: string) =>
workflowPacksIds.value.includes(packId)
const filterWorkflowPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !!pack.id && isIdInWorkflow(pack.id))
onUnmounted(() => {
cleanup()
})
return {
error,
isLoading,
isReady,
workflowPacks: nodePacks,
startFetchWorkflowPacks: async () => {
await getWorkflowPacks() // Parse the packs from the workflow nodes
await startFetch() // Fetch the packs infos from the registry
},
filterWorkflowPack
}
}

View File

@@ -0,0 +1,101 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
/**
* LocalStorage keys for conflict acknowledgment tracking
*/
const STORAGE_KEYS = {
CONFLICT_MODAL_DISMISSED: 'Comfy.ConflictModalDismissed',
CONFLICT_RED_DOT_DISMISSED: 'Comfy.ConflictRedDotDismissed',
CONFLICT_WARNING_BANNER_DISMISSED: 'Comfy.ConflictWarningBannerDismissed'
} as const
/**
* Interface for conflict acknowledgment state
*/
interface ConflictAcknowledgmentState {
modal_dismissed: boolean
red_dot_dismissed: boolean
warning_banner_dismissed: boolean
}
// Shared state - initialized once and reused across all composable calls
const modalDismissed = useStorage(STORAGE_KEYS.CONFLICT_MODAL_DISMISSED, false)
const redDotDismissed = useStorage(
STORAGE_KEYS.CONFLICT_RED_DOT_DISMISSED,
false
)
const warningBannerDismissed = useStorage(
STORAGE_KEYS.CONFLICT_WARNING_BANNER_DISMISSED,
false
)
/**
* Composable for managing conflict acknowledgment state in localStorage
*
* This handles:
* - Tracking whether conflict modal has been dismissed
* - Tracking whether red dot notification has been cleared
* - Managing per-package conflict acknowledgments
* - Detecting ComfyUI version changes to reset acknowledgment state
*/
export function useConflictAcknowledgment() {
const conflictDetectionStore = useConflictDetectionStore()
// Create computed state object for backward compatibility
const state = computed<ConflictAcknowledgmentState>(() => ({
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
warning_banner_dismissed: warningBannerDismissed.value
}))
/**
* Mark red dot notification as dismissed
*/
function dismissRedDotNotification(): void {
redDotDismissed.value = true
}
/**
* Mark manager warning banner as dismissed
*/
function dismissWarningBanner(): void {
warningBannerDismissed.value = true
redDotDismissed.value = true
}
/**
* Mark conflicts as seen (unified function for help center and manager)
*/
function markConflictsAsSeen(): void {
redDotDismissed.value = true
modalDismissed.value = true
warningBannerDismissed.value = true
}
const hasConflicts = computed(() => conflictDetectionStore.hasConflicts)
const shouldShowConflictModal = computed(() => !modalDismissed.value)
const shouldShowRedDot = computed(() => {
if (!hasConflicts.value) return false
if (redDotDismissed.value) return false
return true
})
const shouldShowManagerBanner = computed(() => {
return hasConflicts.value && !warningBannerDismissed.value
})
return {
// State
acknowledgmentState: state,
shouldShowConflictModal,
shouldShowRedDot,
shouldShowManagerBanner,
// Methods
dismissRedDotNotification,
dismissWarningBanner,
markConflictsAsSeen
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import { type ComputedRef, computed, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
/**
* Extracting import failed conflicts from conflict list
*/
function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
if (!conflicts) return null
const importFailedConflicts = conflicts.filter(
(item): item is ConflictDetail => item.type === 'import_failed'
)
return importFailedConflicts.length > 0 ? importFailedConflicts : null
}
/**
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
return (importFailedInfo: ConflictDetail[] | null) => {
if (importFailedInfo) {
const errorMessage =
importFailedInfo
.map((conflict) => conflict.required_value)
.filter(Boolean)
.join('\n') || t('manager.importFailedGenericError')
const error = new Error(errorMessage)
showErrorDialog(error, {
title: t('manager.failedToInstall'),
reportType: 'importFailedError'
})
}
}
}
/**
* Composable for detecting and handling import failed conflicts
* @param packageId - Package ID string or computed ref
* @returns Object with import failed detection and dialog handler
*/
export function useImportFailedDetection(
packageId?: string | ComputedRef<string> | null
) {
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() =>
packageId ? isPackInstalled(unref(packageId)) : false
)
const conflicts = computed(() => {
const currentPackageId = unref(packageId)
if (!currentPackageId || !isInstalled.value) return null
return getConflictsForPackageByID(currentPackageId) || null
})
const importFailedInfo = computed(() => {
return extractImportFailedConflicts(conflicts.value?.conflicts)
})
const importFailed = computed(() => {
return importFailedInfo.value !== null
})
const showImportFailedDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
isInstalled
}
}

View File

@@ -0,0 +1,118 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'es-toolkit/compat'
import { computed, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
export function useRegistrySearch(
options: {
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
} = {}
) {
const {
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const isLoading = ref(false)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const searchGateway = useRegistrySearchGateway()
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
}
)
let sortedPacks = nodePacks
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
}
const onQueryChange = () => updateSearchResults({ append: false })
const onPageChange = () => updateSearchResults({ append: true })
watch([sortField, searchMode], onQueryChange)
watch(pageNumber, onPageChange)
watchDebounced(searchQuery, onQueryChange, {
debounce: SEARCH_DEBOUNCE_TIME,
immediate: true
})
const sortOptions = computed(() => {
return getSortableFields()
})
return {
isLoading,
pageNumber,
pageSize,
sortField,
searchMode,
searchQuery,
suggestions,
searchResults,
sortOptions,
clearCache: clearSearchCache
}
}

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
export const useConflictDetectionStore = defineStore(
'conflictDetection',
() => {
// State
const conflictedPackages = ref<ConflictDetectionResult[]>([])
const isDetecting = ref(false)
const lastDetectionTime = ref<string | null>(null)
// Getters
const hasConflicts = computed(() =>
conflictedPackages.value.some((pkg) => pkg.has_conflict)
)
const getConflictsForPackageByID = computed(
() => (packageId: string) =>
conflictedPackages.value.find((pkg) => pkg.package_id === packageId)
)
const bannedPackages = computed(() =>
conflictedPackages.value.filter((pkg) =>
pkg.conflicts.some((conflict) => conflict.type === 'banned')
)
)
const securityPendingPackages = computed(() =>
conflictedPackages.value.filter((pkg) =>
pkg.conflicts.some((conflict) => conflict.type === 'pending')
)
)
// Actions
function setConflictedPackages(packages: ConflictDetectionResult[]) {
conflictedPackages.value = [...packages]
}
function clearConflicts() {
conflictedPackages.value = []
}
function setDetecting(detecting: boolean) {
isDetecting.value = detecting
}
function setLastDetectionTime(time: string) {
lastDetectionTime.value = time
}
return {
// State
conflictedPackages,
isDetecting,
lastDetectionTime,
// Getters
hasConflicts,
getConflictsForPackageByID,
bannedPackages,
securityPendingPackages,
// Actions
setConflictedPackages,
clearConflicts,
setDetecting,
setLastDetectionTime
}
}
)