[refactor] Manager dialog simplification (#8041)

## Summary
Simplifies the Manager dialog by consolidating components and using
BaseModalLayout with v-model support for right panel state.

## Changes
- **Consolidation**: Merged ManagerDialogContent, ManagerHeader,
ManagerNavSidebar, RegistrySearchBar, and SearchFilterDropdown into
single ManagerDialog component
- **Right panel**: Added v-model:rightPanelOpen to BaseModalLayout for
external panel state control; clicking a node card now auto-opens the
info panel
- **Cleanup**: Removed unused useResponsiveCollapse composable, TabItem
and SearchOption types
- **UI tweaks**: Moved action buttons (Install All/Update All) from
header-right-area to contentFilter area


[manager-capture.webm](https://github.com/user-attachments/assets/2dd6092a-965d-4885-8ba6-6a2cc51f024a)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8041-refactor-Manager-dialog-simplification-2e86d73d3650815ba699e49a2748b682)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Jin Yi
2026-01-16 16:32:02 +09:00
committed by GitHub
parent ddac3dca1d
commit 0288b02113
18 changed files with 296 additions and 553 deletions

View File

@@ -55,17 +55,4 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
<Button
v-show="!isRightPanelOpen && hasRightPanel"
size="icon"
size="lg"
:class="
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
@@ -10,7 +10,7 @@
"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right] text-sm" />
<i class="icon-[lucide--panel-right]" />
</Button>
<Button
size="lg"
@@ -64,7 +64,7 @@
>
<Button
v-if="isRightPanelOpen && hasRightPanel"
size="icon"
size="lg"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right-close]" />
@@ -90,7 +90,7 @@
</div>
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
>
<slot name="rightPanel"></slot>
</aside>
@@ -111,6 +111,10 @@ const { contentTitle } = defineProps<{
contentTitle: string
}>()
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
const BREAKPOINTS = { md: 880 }
const PANEL_SIZES = {
width: 'w-1/3',
@@ -125,7 +129,6 @@ const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('md')
const isLeftPanelOpen = ref<boolean>(true)
const isRightPanelOpen = ref<boolean>(false)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)

View File

@@ -1,41 +0,0 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { ref, watch } from 'vue'
type BreakpointKey = keyof typeof breakpointsTailwind
/**
* Composable for element with responsive collapsed state
* @param breakpointThreshold - Breakpoint at which the element should become collapsible
*/
export const useResponsiveCollapse = (
breakpointThreshold: BreakpointKey = 'lg'
) => {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isSmallScreen = breakpoints.smallerOrEqual(breakpointThreshold)
const isOpen = ref(!isSmallScreen.value)
/**
* Handles screen size changes to automatically open/close the element
* when crossing the breakpoint threshold
*/
const onIsSmallScreenChange = () => {
if (isSmallScreen.value && isOpen.value) {
isOpen.value = false
} else if (!isSmallScreen.value && !isOpen.value) {
isOpen.value = true
}
}
watch(isSmallScreen, onIsSmallScreenChange)
return {
breakpoints,
isOpen,
isSmallScreen,
open: () => (isOpen.value = true),
close: () => (isOpen.value = false),
toggle: () => (isOpen.value = !isOpen.value)
}
}

View File

@@ -273,7 +273,7 @@
"noItems": "No items"
},
"manager": {
"title": "Custom Nodes Manager",
"title": "Nodes Manager",
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
"legacyManagerUI": "Use Legacy UI",
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",

View File

@@ -25,8 +25,6 @@ import type {
ShowDialogOptions
} from '@/stores/dialogStore'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue'
@@ -152,32 +150,6 @@ export const useDialogService = () => {
})
}
function showManagerDialog(
props: ComponentAttrs<typeof ManagerDialogContent> = {}
) {
dialogStore.showDialog({
key: 'global-manager',
component: ManagerDialogContent,
headerComponent: ManagerHeader,
dialogComponentProps: {
closable: true,
pt: {
pcCloseButton: {
root: {
class: 'bg-dialog-surface w-9 h-9 p-1.5 rounded-full text-white'
}
},
header: { class: 'py-0! px-6 m-0! h-[68px]' },
content: {
class: 'p-0! h-full w-[90vw] max-w-full flex-1 overflow-hidden'
},
root: { class: 'manager-dialog' }
}
},
props
})
}
function parseError(error: Error) {
const filename =
'fileName' in error
@@ -419,20 +391,10 @@ export const useDialogService = () => {
}
}
function toggleManagerDialog(
props?: ComponentAttrs<typeof ManagerDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' })
} else {
showManagerDialog(props)
}
}
function showLayoutDialog(options: {
key: string
component: Component
props: { onClose: () => void }
props: { onClose: () => void } & Record<string, unknown>
dialogComponentProps?: DialogComponentProps
}) {
const layoutDefaultProps: DialogComponentProps = {
@@ -563,7 +525,6 @@ export const useDialogService = () => {
showSettingsDialog,
showAboutDialog,
showExecutionErrorDialog,
showManagerDialog,
showApiNodesSignInDialog,
showSignInDialog,
showSubscriptionRequiredDialog,
@@ -573,7 +534,6 @@ export const useDialogService = () => {
prompt,
showErrorDialog,
confirm,
toggleManagerDialog,
showLayoutDialog,
showImportFailedNodeDialog,
showNodeConflictDialog

View File

@@ -1,180 +1,218 @@
<template>
<div
class="mx-auto flex h-full flex-col overflow-hidden"
:aria-label="$t('manager.title')"
<BaseModalLayout
v-model:right-panel-open="isRightPanelOpen"
:content-title="$t('manager.discoverCommunityContent')"
class="manager-dialog"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
variant="secondary"
size="icon"
:class="
cn(
'absolute top-1/2 z-10 -translate-y-1/2',
isSideNavOpen ? 'left-[12rem]' : 'left-2'
)
"
@click="toggleSideNav"
>
<i
:class="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
/>
</Button>
<div class="relative flex flex-1 overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
v-model:selected-tab="selectedTab"
:tabs="tabs"
/>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{ $t('manager.title') }}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<div class="flex items-center gap-2">
<SingleSelect
v-model="searchMode"
class="min-w-34"
:options="filterOptions"
/>
<AutoCompletePlus
v-model.lazy="searchQuery"
:suggestions="suggestions"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full min-w-md max-w-lg"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-full rounded-lg h-10'
}
},
loader: { style: 'display: none' }
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
</template>
<template #contentFilter>
<!-- Conflict Warning Banner -->
<div
class="flex-1 overflow-auto bg-base-background"
:class="{
'transition-all duration-300': isSmallScreen
}"
v-if="shouldShowManagerBanner"
class="relative mx-6 mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
>
<div class="flex h-full flex-col px-6">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="relative mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
<i
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
/>
<div class="flex flex-1 flex-col gap-2">
<p class="m-0 text-sm font-bold">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="m-0 text-xs">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="m-0 cursor-pointer text-sm font-bold"
@click="onClickWarningLink"
>
<i
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
/>
<div class="flex flex-1 flex-col gap-2">
<p class="m-0 text-sm font-bold">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="m-0 text-xs">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="m-0 cursor-pointer text-sm font-bold"
@click="onClickWarningLink"
>
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<Button
class="absolute top-0 right-0"
variant="textonly"
size="icon"
@click="dismissWarningBanner"
>
<i class="pi pi-times text-xs text-base-foreground"></i>
</Button>
</div>
<RegistrySearchBar
v-model:search-query="searchQuery"
v-model:search-mode="searchMode"
v-model:sort-field="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<Button
class="absolute top-0 right-0"
variant="textonly"
size="icon"
@click="dismissWarningBanner"
>
<i class="pi pi-times text-xs text-base-foreground"></i>
</Button>
</div>
<!-- Filters Row -->
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
:disabled="isMissingLoading || !!missingError"
:node-packs="missingNodePacks"
size="lg"
:label="$t('manager.installAllMissingNodes')"
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading"
class="h-full scrollbar-hide w-full overflow-auto"
>
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
:title="
comfyManagerStore.error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
comfyManagerStore.error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="4"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
</div>
</div>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
size="lg"
/>
</div>
<!-- Sort Options on right -->
<div>
<SingleSelect
v-model="sortField"
:label="$t('g.sort')"
:options="availableSortOptions"
class="w-48"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
</div>
</div>
<div class="z-20 flex w-[clamp(250px,33%,306px)] border-l-0">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="isolate flex w-full flex-col">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</template>
<template #content>
<div v-if="isLoading" class="scrollbar-hide h-full w-full overflow-auto">
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
</div>
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
:title="
comfyManagerStore.error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
comfyManagerStore.error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="4"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="(event: MouseEvent) => selectNodePack(item, event)"
/>
</template>
</VirtualGrid>
</div>
</template>
<template #rightPanel>
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'es-toolkit/compat'
import { merge, stubTrue } from 'es-toolkit/compat'
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
provide,
ref,
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import Button from '@/components/ui/button/Button.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import { cn } from '@/utils/tailwindUtil'
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
import type { NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
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 { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
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'
const { initialTab } = defineProps<{
const { initialTab, onClose } = defineProps<{
initialTab?: ManagerTab
onClose: () => void
}>()
provide(OnCloseKey, onClose)
const { t } = useI18n()
const { buildDocsUrl } = useExternalLink()
const comfyManagerStore = useComfyManagerStore()
@@ -186,46 +224,56 @@ const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
gap: '1.5rem',
padding: '0'
} as const
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict acknowledgment state from composable
const {
shouldShowManagerBanner,
dismissWarningBanner,
dismissRedDotNotification
} = conflictAcknowledgment
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
// Missing nodes composable
const {
missingNodePacks,
isLoading: isMissingLoading,
error: missingError
} = useMissingNodes()
// Update available nodes composable
const {
hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
// Navigation items for LeftSidePanel
const navItems = computed<NavItemData[]>(() => [
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi pi-box' },
{
id: ManagerTab.Workflow,
label: t('manager.inWorkflow'),
icon: 'pi-folder'
icon: 'pi pi-folder'
},
{
id: ManagerTab.Missing,
label: t('g.missing'),
icon: 'pi-exclamation-circle'
icon: 'pi pi-exclamation-circle'
},
{
id: ManagerTab.UpdateAvailable,
label: t('g.updateAvailable'),
icon: 'pi-sync'
icon: 'pi pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
const initialTabId = initialTab ?? initialState.selectedTabId ?? ManagerTab.All
const selectedNavId = ref<string | null>(initialTabId)
const selectedTab = computed(() =>
navItems.value.find((item) => item.id === selectedNavId.value)
)
const {
@@ -243,6 +291,25 @@ const {
initialSearchQuery: initialState.searchQuery
})
pageNumber.value = 0
// Filter and sort options for SingleSelect
const filterOptions = computed(() => [
{ name: t('manager.filter.nodePack'), value: 'packs' },
{ name: t('g.nodes'), value: 'nodes' }
])
const availableSortOptions = computed(() => {
if (!sortOptions.value) return []
return sortOptions.value.map((field) => ({
name: field.label,
value: field.id
}))
})
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}
const onApproachEnd = () => {
pageNumber.value++
}
@@ -433,6 +500,14 @@ const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const isRightPanelOpen = ref(false)
watch(
() => selectedNodePacks.value.length,
(length) => {
isRightPanelOpen.value = length > 0
}
)
const getLoadingCount = () => {
switch (selectedTab.value?.id) {
@@ -452,28 +527,26 @@ const getLoadingCount = () => {
const skeletonCardCount = computed(() => {
const loadingCount = getLoadingCount()
if (loadingCount) return loadingCount
return isSmallScreen.value ? 12 : 16
return 16
})
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
selectedNodePacks.value = [...selectedNodePacks.value, nodePack]
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
selectedNodePacks.value = selectedNodePacks.value.filter(
(pack) => pack.id !== nodePack.id
)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
@@ -490,32 +563,24 @@ const handleGridContainerClick = (event: MouseEvent) => {
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
// Track the last pack ID for which we've fetched full registry data
const lastFetchedPackId = ref<string | null>(null)
// Whenever a single pack is selected, fetch its full info once
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
// If only a single node pack is selected, fetch full node pack info from registry
const pack = selectedNodePack.value
if (!pack?.id) return
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
// Update the pack in current selection without changing selection state
const packIndex = selectedNodePacks.value.findIndex(
(p) => p.id === mergedPack.id
)
if (packIndex !== -1) {
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
}
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
displayPacks.value.splice(idx, 1, mergedPack)
@@ -527,7 +592,7 @@ let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch([searchQuery, selectedTab], () => {
watch([searchQuery, selectedNavId], () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
pageNumber.value = 0
@@ -541,7 +606,7 @@ watchEffect(() => {
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,
selectedTabId: (selectedTab.value?.id as ManagerTab) ?? ManagerTab.All,
searchQuery: searchQuery.value,
searchMode: searchMode.value,
sortField: sortField.value

View File

@@ -1,45 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n]
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.items-center')
expect(flexContainer.exists()).toBe(true)
const title = flexContainer.find('h2')
expect(title.exists()).toBe(true)
})
})

View File

@@ -1,11 +0,0 @@
<template>
<div class="w-full">
<div class="flex items-center">
<h2 class="text-left text-lg font-normal">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -1,42 +0,0 @@
<template>
<aside
class="z-5 flex w-3/12 max-w-[250px] translate-x-0 transition-transform duration-300 ease-in-out"
>
<ScrollPanel class="flex-1">
<Listbox
v-model="selectedTab"
:options="tabs"
option-label="label"
list-style="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="flex items-center text-left">
<i :class="['pi', slotProps.option.icon, 'mr-2 text-sm']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" :width="0.3" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
}>()
const selectedTab = defineModel<TabItem>('selectedTab')
</script>

View File

@@ -3,9 +3,8 @@
v-tooltip.top="
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
"
variant="textonly"
class="border"
size="sm"
:size
:disabled="isUpdating"
@click="updateAllPacks"
>
@@ -19,14 +18,20 @@ import { ref } from 'vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
const {
nodePacks,
hasDisabledUpdatePacks,
size = 'sm'
} = defineProps<{
nodePacks: NodePack[]
hasDisabledUpdatePacks?: boolean
size?: ButtonVariants['size']
}>()
const isUpdating = ref<boolean>(false)

View File

@@ -1,130 +0,0 @@
<template>
<div class="relative w-full p-6">
<div class="flex h-12 items-center justify-between gap-1">
<div class="flex w-5/12 items-center">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
/>
</div>
<div class="mt-3 flex text-sm">
<div class="ml-1 flex gap-6">
<SearchFilterDropdown
v-model:model-value="searchMode"
:options="filterOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:model-value="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
</div>
<div class="ml-6 flex items-center gap-4">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults?.length || 0 }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { stubTrue } from 'es-toolkit/compat'
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
import AutoComplete from 'primevue/autocomplete'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
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 { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { SearchOption } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
default: SortableAlgoliaField.Downloads
})
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const {
hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}
</script>

View File

@@ -1,36 +0,0 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
:model-value="modelValue"
:options="options"
option-label="label"
option-value="id"
class="min-w-[6rem] border-none bg-transparent shadow-none"
:pt="{
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
@update:model-value="$emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup lang="ts" generic="T">
// oxlint-disable-next-line no-restricted-imports -- TODO: Migrate to Select component
import Dropdown from 'primevue/dropdown'
import type { SearchOption } from '@/workbench/extensions/manager/types/comfyManagerTypes'
defineProps<{
options: SearchOption<T>[]
label: string
modelValue: T
}>()
defineEmits<{
'update:modelValue': [value: T]
}>()
</script>

View File

@@ -14,7 +14,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
/**
* Composable to find missing NodePacks from workflow
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches workflow pack data when initialized
* This is a shared singleton composable - all components use the same instance
*/
@@ -25,7 +24,6 @@ export const useMissingNodes = createSharedComposable(() => {
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))

View File

@@ -7,7 +7,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
/**
* 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 = () => {
@@ -34,7 +33,6 @@ export const useUpdateAvailableNodes = () => {
return compare(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)

View File

@@ -0,0 +1,36 @@
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import ManagerDialog from '@/workbench/extensions/manager/components/manager/ManagerDialog.vue'
const DIALOG_KEY = 'global-manager'
export function useManagerDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(initialTab?: ManagerTab) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ManagerDialog,
props: {
onClose: hide,
initialTab
},
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-hidden h-full !py-0' }
}
}
})
}
return {
show,
hide
}
}

View File

@@ -53,6 +53,13 @@ vi.mock('@/stores/toastStore', () => ({
}))
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => ({
useManagerDialog: vi.fn(() => ({
show: vi.fn(),
hide: vi.fn()
}))
}))
describe('useManagerState', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -7,6 +7,7 @@ import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useManagerDialog } from '@/workbench/extensions/manager/composables/useManagerDialog'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
export enum ManagerUIState {
@@ -19,6 +20,7 @@ export function useManagerState() {
const systemStatsStore = useSystemStatsStore()
const { systemStats, isInitialized: systemInitialized } =
storeToRefs(systemStatsStore)
const managerDialog = useManagerDialog()
/**
* The current manager UI state.
@@ -186,11 +188,9 @@ export function useManagerState() {
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.showManagerDialog({ initialTab: ManagerTab.All })
await managerDialog.show(ManagerTab.All)
} else {
dialogService.showManagerDialog(
options?.initialTab ? { initialTab: options.initialTab } : undefined
)
await managerDialog.show(options?.initialTab)
}
break
}

View File

@@ -20,12 +20,6 @@ export enum ManagerTab {
UpdateAvailable = 'updateAvailable'
}
export interface TabItem {
id: ManagerTab
label: string
icon: string
}
export type TaskLog = {
taskName: string
taskId: string
@@ -37,11 +31,6 @@ export interface UseNodePacksOptions {
maxConcurrent?: number
}
export interface SearchOption<T> {
id: T
label: string
}
export enum SortableAlgoliaField {
Downloads = 'total_install',
Created = 'create_time',