mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +00:00
[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:
@@ -55,17 +55,4 @@ const dialogStore = useDialogStore()
|
|||||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||||
@apply pt-0;
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
||||||
<Button
|
<Button
|
||||||
v-show="!isRightPanelOpen && hasRightPanel"
|
v-show="!isRightPanelOpen && hasRightPanel"
|
||||||
size="icon"
|
size="lg"
|
||||||
:class="
|
:class="
|
||||||
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
||||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"
|
"
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--panel-right] text-sm" />
|
<i class="icon-[lucide--panel-right]" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-if="isRightPanelOpen && hasRightPanel"
|
v-if="isRightPanelOpen && hasRightPanel"
|
||||||
size="icon"
|
size="lg"
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--panel-right-close]" />
|
<i class="icon-[lucide--panel-right-close]" />
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<aside
|
<aside
|
||||||
v-if="hasRightPanel && isRightPanelOpen"
|
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>
|
<slot name="rightPanel"></slot>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -111,6 +111,10 @@ const { contentTitle } = defineProps<{
|
|||||||
contentTitle: string
|
contentTitle: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
|
||||||
const BREAKPOINTS = { md: 880 }
|
const BREAKPOINTS = { md: 880 }
|
||||||
const PANEL_SIZES = {
|
const PANEL_SIZES = {
|
||||||
width: 'w-1/3',
|
width: 'w-1/3',
|
||||||
@@ -125,7 +129,6 @@ const breakpoints = useBreakpoints(BREAKPOINTS)
|
|||||||
const notMobile = breakpoints.greater('md')
|
const notMobile = breakpoints.greater('md')
|
||||||
|
|
||||||
const isLeftPanelOpen = ref<boolean>(true)
|
const isLeftPanelOpen = ref<boolean>(true)
|
||||||
const isRightPanelOpen = ref<boolean>(false)
|
|
||||||
const mobileMenuOpen = ref<boolean>(false)
|
const mobileMenuOpen = ref<boolean>(false)
|
||||||
|
|
||||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
"noItems": "No items"
|
"noItems": "No items"
|
||||||
},
|
},
|
||||||
"manager": {
|
"manager": {
|
||||||
"title": "Custom Nodes Manager",
|
"title": "Nodes Manager",
|
||||||
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
|
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
|
||||||
"legacyManagerUI": "Use Legacy UI",
|
"legacyManagerUI": "Use Legacy UI",
|
||||||
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
|
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import type {
|
|||||||
ShowDialogOptions
|
ShowDialogOptions
|
||||||
} from '@/stores/dialogStore'
|
} 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 ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
|
||||||
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
|
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
|
||||||
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.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) {
|
function parseError(error: Error) {
|
||||||
const filename =
|
const filename =
|
||||||
'fileName' in error
|
'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: {
|
function showLayoutDialog(options: {
|
||||||
key: string
|
key: string
|
||||||
component: Component
|
component: Component
|
||||||
props: { onClose: () => void }
|
props: { onClose: () => void } & Record<string, unknown>
|
||||||
dialogComponentProps?: DialogComponentProps
|
dialogComponentProps?: DialogComponentProps
|
||||||
}) {
|
}) {
|
||||||
const layoutDefaultProps: DialogComponentProps = {
|
const layoutDefaultProps: DialogComponentProps = {
|
||||||
@@ -563,7 +525,6 @@ export const useDialogService = () => {
|
|||||||
showSettingsDialog,
|
showSettingsDialog,
|
||||||
showAboutDialog,
|
showAboutDialog,
|
||||||
showExecutionErrorDialog,
|
showExecutionErrorDialog,
|
||||||
showManagerDialog,
|
|
||||||
showApiNodesSignInDialog,
|
showApiNodesSignInDialog,
|
||||||
showSignInDialog,
|
showSignInDialog,
|
||||||
showSubscriptionRequiredDialog,
|
showSubscriptionRequiredDialog,
|
||||||
@@ -573,7 +534,6 @@ export const useDialogService = () => {
|
|||||||
prompt,
|
prompt,
|
||||||
showErrorDialog,
|
showErrorDialog,
|
||||||
confirm,
|
confirm,
|
||||||
toggleManagerDialog,
|
|
||||||
showLayoutDialog,
|
showLayoutDialog,
|
||||||
showImportFailedNodeDialog,
|
showImportFailedNodeDialog,
|
||||||
showNodeConflictDialog
|
showNodeConflictDialog
|
||||||
|
|||||||
@@ -1,180 +1,218 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<BaseModalLayout
|
||||||
class="mx-auto flex h-full flex-col overflow-hidden"
|
v-model:right-panel-open="isRightPanelOpen"
|
||||||
:aria-label="$t('manager.title')"
|
:content-title="$t('manager.discoverCommunityContent')"
|
||||||
|
class="manager-dialog"
|
||||||
>
|
>
|
||||||
<ContentDivider :width="0.3" />
|
<template #leftPanel>
|
||||||
<Button
|
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems">
|
||||||
v-if="isSmallScreen"
|
<template #header-icon>
|
||||||
variant="secondary"
|
<i class="icon-[lucide--puzzle]" />
|
||||||
size="icon"
|
</template>
|
||||||
:class="
|
<template #header-title>
|
||||||
cn(
|
<span class="text-neutral text-base">{{ $t('manager.title') }}</span>
|
||||||
'absolute top-1/2 z-10 -translate-y-1/2',
|
</template>
|
||||||
isSideNavOpen ? 'left-[12rem]' : 'left-2'
|
</LeftSidePanel>
|
||||||
)
|
</template>
|
||||||
"
|
|
||||||
@click="toggleSideNav"
|
<template #header>
|
||||||
>
|
<div class="flex items-center gap-2">
|
||||||
<i
|
<SingleSelect
|
||||||
:class="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
v-model="searchMode"
|
||||||
/>
|
class="min-w-34"
|
||||||
</Button>
|
:options="filterOptions"
|
||||||
<div class="relative flex flex-1 overflow-hidden">
|
/>
|
||||||
<ManagerNavSidebar
|
<AutoCompletePlus
|
||||||
v-if="isSideNavOpen"
|
v-model.lazy="searchQuery"
|
||||||
v-model:selected-tab="selectedTab"
|
:suggestions="suggestions"
|
||||||
:tabs="tabs"
|
: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
|
<div
|
||||||
class="flex-1 overflow-auto bg-base-background"
|
v-if="shouldShowManagerBanner"
|
||||||
:class="{
|
class="relative mx-6 mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
|
||||||
'transition-all duration-300': isSmallScreen
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div class="flex h-full flex-col px-6">
|
<i
|
||||||
<!-- Conflict Warning Banner -->
|
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
|
||||||
<div
|
/>
|
||||||
v-if="shouldShowManagerBanner"
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
class="relative mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
|
<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
|
{{ $t('manager.conflicts.warningBanner.button') }}
|
||||||
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
|
</p>
|
||||||
/>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
<Button
|
||||||
<p class="m-0 text-sm font-bold">
|
class="absolute top-0 right-0"
|
||||||
{{ $t('manager.conflicts.warningBanner.title') }}
|
variant="textonly"
|
||||||
</p>
|
size="icon"
|
||||||
<p class="m-0 text-xs">
|
@click="dismissWarningBanner"
|
||||||
{{ $t('manager.conflicts.warningBanner.message') }}
|
>
|
||||||
</p>
|
<i class="pi pi-times text-xs text-base-foreground"></i>
|
||||||
<p
|
</Button>
|
||||||
class="m-0 cursor-pointer text-sm font-bold"
|
</div>
|
||||||
@click="onClickWarningLink"
|
|
||||||
>
|
<!-- Filters Row -->
|
||||||
{{ $t('manager.conflicts.warningBanner.button') }}
|
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<PackInstallButton
|
||||||
<Button
|
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||||
class="absolute top-0 right-0"
|
:disabled="isMissingLoading || !!missingError"
|
||||||
variant="textonly"
|
:node-packs="missingNodePacks"
|
||||||
size="icon"
|
size="lg"
|
||||||
@click="dismissWarningBanner"
|
:label="$t('manager.installAllMissingNodes')"
|
||||||
>
|
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 overflow-auto">
|
<PackUpdateButton
|
||||||
<div
|
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
||||||
v-if="isLoading"
|
:node-packs="enabledUpdateAvailableNodePacks"
|
||||||
class="h-full scrollbar-hide w-full overflow-auto"
|
:has-disabled-update-packs="hasDisabledUpdatePacks"
|
||||||
>
|
size="lg"
|
||||||
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NoResultsPlaceholder
|
|
||||||
v-else-if="searchResults.length === 0"
|
<!-- Sort Options on right -->
|
||||||
:title="
|
<div>
|
||||||
comfyManagerStore.error
|
<SingleSelect
|
||||||
? $t('manager.errorConnecting')
|
v-model="sortField"
|
||||||
: $t('manager.noResultsFound')
|
:label="$t('g.sort')"
|
||||||
"
|
:options="availableSortOptions"
|
||||||
:message="
|
class="w-48"
|
||||||
comfyManagerStore.error
|
>
|
||||||
? $t('manager.tryAgainLater')
|
<template #icon>
|
||||||
: $t('manager.tryDifferentSearch')
|
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||||
"
|
</template>
|
||||||
/>
|
</SingleSelect>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="z-20 flex w-[clamp(250px,33%,306px)] border-l-0">
|
</template>
|
||||||
<ContentDivider orientation="vertical" :width="0.2" />
|
|
||||||
<div class="isolate flex w-full flex-col">
|
<template #content>
|
||||||
<InfoPanel
|
<div v-if="isLoading" class="scrollbar-hide h-full w-full overflow-auto">
|
||||||
v-if="!hasMultipleSelections && selectedNodePack"
|
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
||||||
:node-pack="selectedNodePack"
|
|
||||||
/>
|
|
||||||
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<NoResultsPlaceholder
|
||||||
</div>
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { whenever } from '@vueuse/core'
|
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 {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
|
provide,
|
||||||
ref,
|
ref,
|
||||||
watch,
|
watch,
|
||||||
watchEffect
|
watchEffect
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.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 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 { useExternalLink } from '@/composables/useExternalLink'
|
||||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import type { NavItemData } from '@/types/navTypes'
|
||||||
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
|
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 InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
|
||||||
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||||
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.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 GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
|
||||||
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
|
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 { 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 { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
|
||||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||||
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
|
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
|
||||||
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
||||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
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'
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
|
||||||
const { initialTab } = defineProps<{
|
const { initialTab, onClose } = defineProps<{
|
||||||
initialTab?: ManagerTab
|
initialTab?: ManagerTab
|
||||||
|
onClose: () => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
provide(OnCloseKey, onClose)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { buildDocsUrl } = useExternalLink()
|
const { buildDocsUrl } = useExternalLink()
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
@@ -186,46 +224,56 @@ const initialState = persistedState.loadStoredState()
|
|||||||
const GRID_STYLE = {
|
const GRID_STYLE = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
|
||||||
padding: '0.5rem',
|
gap: '1.5rem',
|
||||||
gap: '1.5rem'
|
padding: '0'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const {
|
|
||||||
isSmallScreen,
|
|
||||||
isOpen: isSideNavOpen,
|
|
||||||
toggle: toggleSideNav
|
|
||||||
} = useResponsiveCollapse()
|
|
||||||
|
|
||||||
// Use conflict acknowledgment state from composable
|
|
||||||
const {
|
const {
|
||||||
shouldShowManagerBanner,
|
shouldShowManagerBanner,
|
||||||
dismissWarningBanner,
|
dismissWarningBanner,
|
||||||
dismissRedDotNotification
|
dismissRedDotNotification
|
||||||
} = conflictAcknowledgment
|
} = conflictAcknowledgment
|
||||||
|
|
||||||
const tabs = ref<TabItem[]>([
|
// Missing nodes composable
|
||||||
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
|
const {
|
||||||
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
|
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,
|
id: ManagerTab.Workflow,
|
||||||
label: t('manager.inWorkflow'),
|
label: t('manager.inWorkflow'),
|
||||||
icon: 'pi-folder'
|
icon: 'pi pi-folder'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ManagerTab.Missing,
|
id: ManagerTab.Missing,
|
||||||
label: t('g.missing'),
|
label: t('g.missing'),
|
||||||
icon: 'pi-exclamation-circle'
|
icon: 'pi pi-exclamation-circle'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ManagerTab.UpdateAvailable,
|
id: ManagerTab.UpdateAvailable,
|
||||||
label: t('g.updateAvailable'),
|
label: t('g.updateAvailable'),
|
||||||
icon: 'pi-sync'
|
icon: 'pi pi-sync'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const initialTabId = initialTab ?? initialState.selectedTabId
|
const initialTabId = initialTab ?? initialState.selectedTabId ?? ManagerTab.All
|
||||||
const selectedTab = ref<TabItem>(
|
const selectedNavId = ref<string | null>(initialTabId)
|
||||||
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
|
|
||||||
|
const selectedTab = computed(() =>
|
||||||
|
navItems.value.find((item) => item.id === selectedNavId.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -243,6 +291,25 @@ const {
|
|||||||
initialSearchQuery: initialState.searchQuery
|
initialSearchQuery: initialState.searchQuery
|
||||||
})
|
})
|
||||||
pageNumber.value = 0
|
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 = () => {
|
const onApproachEnd = () => {
|
||||||
pageNumber.value++
|
pageNumber.value++
|
||||||
}
|
}
|
||||||
@@ -433,6 +500,14 @@ const selectedNodePacks = ref<components['schemas']['Node'][]>([])
|
|||||||
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
|
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
|
||||||
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
|
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
|
||||||
)
|
)
|
||||||
|
const isRightPanelOpen = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedNodePacks.value.length,
|
||||||
|
(length) => {
|
||||||
|
isRightPanelOpen.value = length > 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const getLoadingCount = () => {
|
const getLoadingCount = () => {
|
||||||
switch (selectedTab.value?.id) {
|
switch (selectedTab.value?.id) {
|
||||||
@@ -452,28 +527,26 @@ const getLoadingCount = () => {
|
|||||||
const skeletonCardCount = computed(() => {
|
const skeletonCardCount = computed(() => {
|
||||||
const loadingCount = getLoadingCount()
|
const loadingCount = getLoadingCount()
|
||||||
if (loadingCount) return loadingCount
|
if (loadingCount) return loadingCount
|
||||||
return isSmallScreen.value ? 12 : 16
|
return 16
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectNodePack = (
|
const selectNodePack = (
|
||||||
nodePack: components['schemas']['Node'],
|
nodePack: components['schemas']['Node'],
|
||||||
event: MouseEvent
|
event: MouseEvent
|
||||||
) => {
|
) => {
|
||||||
// Handle multi-select with Shift or Ctrl/Cmd key
|
|
||||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||||
const index = selectedNodePacks.value.findIndex(
|
const index = selectedNodePacks.value.findIndex(
|
||||||
(pack) => pack.id === nodePack.id
|
(pack) => pack.id === nodePack.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
// Add to selection if not already selected
|
selectedNodePacks.value = [...selectedNodePacks.value, nodePack]
|
||||||
selectedNodePacks.value.push(nodePack)
|
|
||||||
} else {
|
} else {
|
||||||
// Remove from selection if already selected
|
selectedNodePacks.value = selectedNodePacks.value.filter(
|
||||||
selectedNodePacks.value.splice(index, 1)
|
(pack) => pack.id !== nodePack.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single select behavior
|
|
||||||
selectedNodePacks.value = [nodePack]
|
selectedNodePacks.value = [nodePack]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,32 +563,24 @@ const handleGridContainerClick = (event: MouseEvent) => {
|
|||||||
|
|
||||||
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
|
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)
|
const lastFetchedPackId = ref<string | null>(null)
|
||||||
|
|
||||||
// Whenever a single pack is selected, fetch its full info once
|
|
||||||
whenever(selectedNodePack, async () => {
|
whenever(selectedNodePack, async () => {
|
||||||
// Cancel any in-flight requests from previously selected node pack
|
|
||||||
getPackById.cancel()
|
getPackById.cancel()
|
||||||
// If only a single node pack is selected, fetch full node pack info from registry
|
|
||||||
const pack = selectedNodePack.value
|
const pack = selectedNodePack.value
|
||||||
if (!pack?.id) return
|
if (!pack?.id) return
|
||||||
if (hasMultipleSelections.value) return
|
if (hasMultipleSelections.value) return
|
||||||
// Only fetch if we haven't already for this pack
|
|
||||||
if (lastFetchedPackId.value === pack.id) return
|
if (lastFetchedPackId.value === pack.id) return
|
||||||
const data = await getPackById.call(pack.id)
|
const data = await getPackById.call(pack.id)
|
||||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
|
||||||
if (data?.id === pack.id) {
|
if (data?.id === pack.id) {
|
||||||
lastFetchedPackId.value = pack.id
|
lastFetchedPackId.value = pack.id
|
||||||
const mergedPack = merge({}, pack, data)
|
const mergedPack = merge({}, pack, data)
|
||||||
// Update the pack in current selection without changing selection state
|
|
||||||
const packIndex = selectedNodePacks.value.findIndex(
|
const packIndex = selectedNodePacks.value.findIndex(
|
||||||
(p) => p.id === mergedPack.id
|
(p) => p.id === mergedPack.id
|
||||||
)
|
)
|
||||||
if (packIndex !== -1) {
|
if (packIndex !== -1) {
|
||||||
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
|
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)
|
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
displayPacks.value.splice(idx, 1, mergedPack)
|
displayPacks.value.splice(idx, 1, mergedPack)
|
||||||
@@ -527,7 +592,7 @@ let gridContainer: HTMLElement | null = null
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
gridContainer = document.getElementById('results-grid')
|
gridContainer = document.getElementById('results-grid')
|
||||||
})
|
})
|
||||||
watch([searchQuery, selectedTab], () => {
|
watch([searchQuery, selectedNavId], () => {
|
||||||
gridContainer ??= document.getElementById('results-grid')
|
gridContainer ??= document.getElementById('results-grid')
|
||||||
if (gridContainer) {
|
if (gridContainer) {
|
||||||
pageNumber.value = 0
|
pageNumber.value = 0
|
||||||
@@ -541,7 +606,7 @@ watchEffect(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
persistedState.persistState({
|
persistedState.persistState({
|
||||||
selectedTabId: selectedTab.value?.id,
|
selectedTabId: (selectedTab.value?.id as ManagerTab) ?? ManagerTab.All,
|
||||||
searchQuery: searchQuery.value,
|
searchQuery: searchQuery.value,
|
||||||
searchMode: searchMode.value,
|
searchMode: searchMode.value,
|
||||||
sortField: sortField.value
|
sortField: sortField.value
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -3,9 +3,8 @@
|
|||||||
v-tooltip.top="
|
v-tooltip.top="
|
||||||
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
||||||
"
|
"
|
||||||
variant="textonly"
|
|
||||||
class="border"
|
class="border"
|
||||||
size="sm"
|
:size
|
||||||
:disabled="isUpdating"
|
:disabled="isUpdating"
|
||||||
@click="updateAllPacks"
|
@click="updateAllPacks"
|
||||||
>
|
>
|
||||||
@@ -19,14 +18,20 @@ import { ref } from 'vue'
|
|||||||
|
|
||||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||||
import Button from '@/components/ui/button/Button.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 type { components } from '@/types/comfyRegistryTypes'
|
||||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
|
|
||||||
type NodePack = components['schemas']['Node']
|
type NodePack = components['schemas']['Node']
|
||||||
|
|
||||||
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
|
const {
|
||||||
|
nodePacks,
|
||||||
|
hasDisabledUpdatePacks,
|
||||||
|
size = 'sm'
|
||||||
|
} = defineProps<{
|
||||||
nodePacks: NodePack[]
|
nodePacks: NodePack[]
|
||||||
hasDisabledUpdatePacks?: boolean
|
hasDisabledUpdatePacks?: boolean
|
||||||
|
size?: ButtonVariants['size']
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isUpdating = ref<boolean>(false)
|
const isUpdating = ref<boolean>(false)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -14,7 +14,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to find missing NodePacks from workflow
|
* Composable to find missing NodePacks from workflow
|
||||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
|
||||||
* Automatically fetches workflow pack data when initialized
|
* Automatically fetches workflow pack data when initialized
|
||||||
* This is a shared singleton composable - all components use the same instance
|
* 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 } =
|
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||||
useWorkflowPacks()
|
useWorkflowPacks()
|
||||||
|
|
||||||
// Same filtering logic as ManagerDialogContent.vue
|
|
||||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to find NodePacks that have updates available
|
* Composable to find NodePacks that have updates available
|
||||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
|
||||||
* Automatically fetches installed pack data when initialized
|
* Automatically fetches installed pack data when initialized
|
||||||
*/
|
*/
|
||||||
export const useUpdateAvailableNodes = () => {
|
export const useUpdateAvailableNodes = () => {
|
||||||
@@ -34,7 +33,6 @@ export const useUpdateAvailableNodes = () => {
|
|||||||
return compare(latestVersion, installedVersion) > 0
|
return compare(latestVersion, installedVersion) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same filtering logic as ManagerDialogContent.vue
|
|
||||||
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
|
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
|
||||||
packs.filter(isOutdatedPack)
|
packs.filter(isOutdatedPack)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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', () => {
|
describe('useManagerState', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { api } from '@/scripts/api'
|
|||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||||
|
import { useManagerDialog } from '@/workbench/extensions/manager/composables/useManagerDialog'
|
||||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
|
||||||
export enum ManagerUIState {
|
export enum ManagerUIState {
|
||||||
@@ -19,6 +20,7 @@ export function useManagerState() {
|
|||||||
const systemStatsStore = useSystemStatsStore()
|
const systemStatsStore = useSystemStatsStore()
|
||||||
const { systemStats, isInitialized: systemInitialized } =
|
const { systemStats, isInitialized: systemInitialized } =
|
||||||
storeToRefs(systemStatsStore)
|
storeToRefs(systemStatsStore)
|
||||||
|
const managerDialog = useManagerDialog()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current manager UI state.
|
* The current manager UI state.
|
||||||
@@ -186,11 +188,9 @@ export function useManagerState() {
|
|||||||
detail: t('manager.legacyMenuNotAvailable'),
|
detail: t('manager.legacyMenuNotAvailable'),
|
||||||
life: 3000
|
life: 3000
|
||||||
})
|
})
|
||||||
dialogService.showManagerDialog({ initialTab: ManagerTab.All })
|
await managerDialog.show(ManagerTab.All)
|
||||||
} else {
|
} else {
|
||||||
dialogService.showManagerDialog(
|
await managerDialog.show(options?.initialTab)
|
||||||
options?.initialTab ? { initialTab: options.initialTab } : undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ export enum ManagerTab {
|
|||||||
UpdateAvailable = 'updateAvailable'
|
UpdateAvailable = 'updateAvailable'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabItem {
|
|
||||||
id: ManagerTab
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskLog = {
|
export type TaskLog = {
|
||||||
taskName: string
|
taskName: string
|
||||||
taskId: string
|
taskId: string
|
||||||
@@ -37,11 +31,6 @@ export interface UseNodePacksOptions {
|
|||||||
maxConcurrent?: number
|
maxConcurrent?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchOption<T> {
|
|
||||||
id: T
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SortableAlgoliaField {
|
export enum SortableAlgoliaField {
|
||||||
Downloads = 'total_install',
|
Downloads = 'total_install',
|
||||||
Created = 'create_time',
|
Created = 'create_time',
|
||||||
|
|||||||
Reference in New Issue
Block a user