mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
[refactor] Manager dialog design improvements (#8247)
## Summary Refactored Manager dialog components for cleaner button rendering logic and improved visibility. ## Changes - **InfoPanelHeader**: Centralized button rendering logic (try update, uninstall, install) instead of using slot overrides - **PackTryUpdateButton**: Changed variant from `textonly` to `inverted` for better visibility - **InfoPanel**: Removed slot override and unused imports, simplified component - **ManagerDialog**: Design modifications for improved UX - **PackCard/PackBanner**: Component refinements - **useManagerDisplayPacks**: New composable for display pack management ## Review Focus - Button visibility and styling with `inverted` variant - Centralized button logic in InfoPanelHeader ## Before [manager-before.webm](https://github.com/user-attachments/assets/4cef5f10-9cb2-4a31-a095-b170643e481d) ## After [manager-after.webm](https://github.com/user-attachments/assets/9b693b32-59ca-4c88-b7e7-9a78aba19df7) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8247-refactor-Manager-dialog-design-improvements-2f06d73d365081bf9f07efa043caa378) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
v-tooltip.right="{
|
||||
value: tooltipText,
|
||||
disabled: !isOverflowing,
|
||||
pt: { text: { class: 'whitespace-nowrap' } }
|
||||
pt: { text: { class: 'w-max whitespace-nowrap' } }
|
||||
}"
|
||||
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
:class="
|
||||
|
||||
@@ -282,6 +282,10 @@
|
||||
},
|
||||
"manager": {
|
||||
"title": "Nodes Manager",
|
||||
"nodePackInfo": "Node Pack Info",
|
||||
"basicInfo": "Basic Info",
|
||||
"actions": "Actions",
|
||||
"selected": "Selected",
|
||||
"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",
|
||||
@@ -295,6 +299,17 @@
|
||||
"changingVersion": "Changing version from {from} to {to}",
|
||||
"dependencies": "Dependencies",
|
||||
"inWorkflow": "In Workflow",
|
||||
"nav": {
|
||||
"allExtensions": "All Extensions",
|
||||
"notInstalled": "Not Installed",
|
||||
"installedSection": "INSTALLED",
|
||||
"allInstalled": "All installed",
|
||||
"updatesAvailable": "Updates Available",
|
||||
"conflicting": "Conflicting",
|
||||
"inWorkflowSection": "IN WORKFLOW",
|
||||
"allInWorkflow": "All in: {workflowName}",
|
||||
"missingNodes": "Missing Nodes"
|
||||
},
|
||||
"infoPanelEmpty": "Click an item to see the info",
|
||||
"applyChanges": "Apply Changes",
|
||||
"restartToApplyChanges": "To apply changes, please restart ComfyUI",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<BaseModalLayout
|
||||
v-model:right-panel-open="isRightPanelOpen"
|
||||
:content-title="$t('manager.discoverCommunityContent')"
|
||||
:right-panel-title="$t('manager.nodePackInfo')"
|
||||
class="manager-dialog"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
@@ -13,32 +14,64 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<SingleSelect
|
||||
v-model="searchMode"
|
||||
class="min-w-34"
|
||||
:options="filterOptions"
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<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="{
|
||||
root: { class: 'relative' },
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class:
|
||||
'w-full h-10 rounded-lg bg-comfy-input text-comfy-input-foreground border-none outline-none text-sm'
|
||||
}
|
||||
},
|
||||
overlay: {
|
||||
class:
|
||||
'bg-comfy-input rounded-lg mt-1 shadow-lg border border-border-default'
|
||||
},
|
||||
list: { class: 'p-1' },
|
||||
option: {
|
||||
class:
|
||||
'px-3 py-2 rounded hover:bg-button-hover-surface cursor-pointer text-sm'
|
||||
},
|
||||
loader: { style: 'display: none' }
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
>
|
||||
<template #dropdownicon>
|
||||
<i
|
||||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
</template>
|
||||
</AutoCompletePlus>
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
:disabled="isMissingLoading || !!missingError"
|
||||
:node-packs="missingNodePacks"
|
||||
size="lg"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
<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"
|
||||
<PackUpdateButton
|
||||
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
||||
:node-packs="enabledUpdateAvailableNodePacks"
|
||||
:has-disabled-update-packs="hasDisabledUpdatePacks"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -76,37 +109,18 @@
|
||||
</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')"
|
||||
/>
|
||||
<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>
|
||||
<!-- Sort Options -->
|
||||
<div class="flex justify-end px-6 pb-4">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -115,7 +129,7 @@
|
||||
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
||||
</div>
|
||||
<NoResultsPlaceholder
|
||||
v-else-if="searchResults.length === 0"
|
||||
v-else-if="displayPacks.length === 0"
|
||||
:title="
|
||||
comfyManagerStore.error
|
||||
? $t('manager.errorConnecting')
|
||||
@@ -127,7 +141,7 @@
|
||||
: $t('manager.tryDifferentSearch')
|
||||
"
|
||||
/>
|
||||
<div v-else class="h-full" @click="handleGridContainerClick">
|
||||
<div v-else class="h-full w-full" @click="handleGridContainerClick">
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
@@ -182,9 +196,10 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { NavItemData } from '@/types/navTypes'
|
||||
import type { NavGroupData, 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'
|
||||
@@ -192,14 +207,13 @@ import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPan
|
||||
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.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 { useManagerDisplayPacks } from '@/workbench/extensions/manager/composables/useManagerDisplayPacks'
|
||||
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
|
||||
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
||||
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
@@ -215,14 +229,16 @@ const { buildDocsUrl } = useExternalLink()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { getPackById } = useComfyRegistryStore()
|
||||
const conflictAcknowledgment = useConflictAcknowledgment()
|
||||
const conflictDetectionStore = useConflictDetectionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const persistedState = useManagerStatePersistence()
|
||||
const initialState = persistedState.loadStoredState()
|
||||
|
||||
const GRID_STYLE = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
|
||||
gap: '1.5rem',
|
||||
padding: '0'
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))',
|
||||
gap: '1rem',
|
||||
padding: '0.5rem'
|
||||
} as const
|
||||
|
||||
const {
|
||||
@@ -245,32 +261,84 @@ const {
|
||||
hasDisabledUpdatePacks
|
||||
} = useUpdateAvailableNodes()
|
||||
|
||||
// Get the current workflow name for the nav item
|
||||
const workflowName = computed(
|
||||
() => workflowStore.activeWorkflow?.filename ?? t('manager.inWorkflow')
|
||||
)
|
||||
|
||||
// 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' },
|
||||
const navItems = computed<(NavItemData | NavGroupData)[]>(() => [
|
||||
{
|
||||
id: ManagerTab.Workflow,
|
||||
label: t('manager.inWorkflow'),
|
||||
icon: 'pi pi-folder'
|
||||
id: ManagerTab.All,
|
||||
label: t('manager.nav.allExtensions'),
|
||||
icon: 'icon-[lucide--list]'
|
||||
},
|
||||
{
|
||||
id: ManagerTab.Missing,
|
||||
label: t('g.missing'),
|
||||
icon: 'pi pi-exclamation-circle'
|
||||
id: ManagerTab.NotInstalled,
|
||||
label: t('manager.nav.notInstalled'),
|
||||
icon: 'icon-[lucide--globe]'
|
||||
},
|
||||
{
|
||||
id: ManagerTab.UpdateAvailable,
|
||||
label: t('g.updateAvailable'),
|
||||
icon: 'pi pi-sync'
|
||||
title: t('manager.nav.installedSection'),
|
||||
items: [
|
||||
{
|
||||
id: ManagerTab.AllInstalled,
|
||||
label: t('manager.nav.allInstalled'),
|
||||
icon: 'icon-[lucide--download]'
|
||||
},
|
||||
{
|
||||
id: ManagerTab.UpdateAvailable,
|
||||
label: t('manager.nav.updatesAvailable'),
|
||||
icon: 'icon-[lucide--refresh-cw]'
|
||||
},
|
||||
{
|
||||
id: ManagerTab.Conflicting,
|
||||
label: t('manager.nav.conflicting'),
|
||||
icon: 'icon-[lucide--triangle-alert]',
|
||||
badge: conflictDetectionStore.conflictedPackages.length || undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('manager.nav.inWorkflowSection'),
|
||||
items: [
|
||||
{
|
||||
id: ManagerTab.Workflow,
|
||||
label: t('manager.nav.allInWorkflow', {
|
||||
workflowName: workflowName.value
|
||||
}),
|
||||
icon: 'icon-[lucide--share-2]'
|
||||
},
|
||||
{
|
||||
id: ManagerTab.Missing,
|
||||
label: t('manager.nav.missingNodes'),
|
||||
icon: 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const initialTabId = initialTab ?? initialState.selectedTabId ?? ManagerTab.All
|
||||
const selectedNavId = ref<string | null>(initialTabId)
|
||||
|
||||
// Helper to find a nav item by id in the nested structure
|
||||
const findNavItemById = (
|
||||
items: (NavItemData | NavGroupData)[],
|
||||
id: string | null
|
||||
): NavItemData | undefined => {
|
||||
for (const item of items) {
|
||||
if ('items' in item) {
|
||||
const found = item.items.find((subItem) => subItem.id === id)
|
||||
if (found) return found
|
||||
} else if (item.id === id) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const selectedTab = computed(() =>
|
||||
navItems.value.find((item) => item.id === selectedNavId.value)
|
||||
findNavItemById(navItems.value, selectedNavId.value)
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -315,120 +383,20 @@ const isInitialLoad = computed(
|
||||
() => searchResults.value.length === 0 && searchQuery.value === ''
|
||||
)
|
||||
|
||||
const isEmptySearch = computed(() => searchQuery.value === '')
|
||||
const displayPacks = ref<components['schemas']['Node'][]>([])
|
||||
|
||||
// Use the new composable for tab-based display packs
|
||||
const {
|
||||
startFetchInstalled,
|
||||
filterInstalledPack,
|
||||
installedPacks,
|
||||
isLoading: isLoadingInstalled,
|
||||
isReady: installedPacksReady
|
||||
} = useInstalledPacks()
|
||||
|
||||
const {
|
||||
startFetchWorkflowPacks,
|
||||
filterWorkflowPack,
|
||||
workflowPacks,
|
||||
isLoading: isLoadingWorkflow,
|
||||
isReady: workflowPacksReady
|
||||
} = useWorkflowPacks()
|
||||
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
displayPacks,
|
||||
isLoading: isTabLoading,
|
||||
workflowPacks
|
||||
} = useManagerDisplayPacks(selectedNavId, searchResults, searchQuery, sortField)
|
||||
|
||||
// Tab helpers for template
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
const isInstalledTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Installed
|
||||
)
|
||||
const isMissingTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Missing
|
||||
)
|
||||
const isWorkflowTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Workflow
|
||||
)
|
||||
const isAllTab = computed(() => selectedTab.value?.id === ManagerTab.All)
|
||||
|
||||
const isOutdatedPack = (pack: components['schemas']['Node']) => {
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(pack)
|
||||
return isUpdateAvailable.value === true
|
||||
}
|
||||
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter(isOutdatedPack)
|
||||
|
||||
watch(
|
||||
[isUpdateAvailableTab, installedPacks],
|
||||
async () => {
|
||||
if (!isUpdateAvailableTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
} else if (
|
||||
!installedPacks.value.length &&
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value
|
||||
) {
|
||||
await startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[isInstalledTab, installedPacks],
|
||||
async () => {
|
||||
if (!isInstalledTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterInstalledPack(searchResults.value)
|
||||
} else if (
|
||||
!installedPacks.value.length &&
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value
|
||||
) {
|
||||
await startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = installedPacks.value
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[isMissingTab, isWorkflowTab, workflowPacks, installedPacks],
|
||||
async () => {
|
||||
if (!isWorkflowTab.value && !isMissingTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = isMissingTab.value
|
||||
? filterMissingPacks(filterWorkflowPack(searchResults.value))
|
||||
: filterWorkflowPack(searchResults.value)
|
||||
} else if (
|
||||
!workflowPacks.value.length &&
|
||||
!isLoadingWorkflow.value &&
|
||||
!workflowPacksReady.value
|
||||
) {
|
||||
await startFetchWorkflowPacks()
|
||||
if (isMissingTab.value) {
|
||||
await startFetchInstalled()
|
||||
}
|
||||
} else {
|
||||
displayPacks.value = isMissingTab.value
|
||||
? filterMissingPacks(workflowPacks.value)
|
||||
: workflowPacks.value
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([isAllTab, searchResults], () => {
|
||||
if (!isAllTab.value) return
|
||||
displayPacks.value = searchResults.value
|
||||
})
|
||||
|
||||
const onClickWarningLink = () => {
|
||||
window.open(
|
||||
@@ -439,49 +407,9 @@ const onClickWarningLink = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const onResultsChange = () => {
|
||||
switch (selectedTab.value?.id) {
|
||||
case ManagerTab.Installed:
|
||||
displayPacks.value = isEmptySearch.value
|
||||
? installedPacks.value
|
||||
: filterInstalledPack(searchResults.value)
|
||||
break
|
||||
case ManagerTab.Workflow:
|
||||
displayPacks.value = isEmptySearch.value
|
||||
? workflowPacks.value
|
||||
: filterWorkflowPack(searchResults.value)
|
||||
break
|
||||
case ManagerTab.Missing:
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterMissingPacks(
|
||||
filterWorkflowPack(searchResults.value)
|
||||
)
|
||||
}
|
||||
break
|
||||
case ManagerTab.UpdateAvailable:
|
||||
displayPacks.value = isEmptySearch.value
|
||||
? filterOutdatedPacks(installedPacks.value)
|
||||
: filterOutdatedPacks(searchResults.value)
|
||||
break
|
||||
default:
|
||||
displayPacks.value = searchResults.value
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchResults, onResultsChange, { flush: 'post' })
|
||||
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)
|
||||
|
||||
const isLoading = computed(() => {
|
||||
if (isSearchLoading.value) return searchResults.value.length === 0
|
||||
if (selectedTab.value?.id === ManagerTab.Installed) {
|
||||
return isLoadingInstalled.value
|
||||
}
|
||||
if (
|
||||
selectedTab.value?.id === ManagerTab.Workflow ||
|
||||
selectedTab.value?.id === ManagerTab.Missing
|
||||
) {
|
||||
return isLoadingWorkflow.value
|
||||
}
|
||||
if (isTabLoading.value) return true
|
||||
return isInitialLoad.value
|
||||
})
|
||||
|
||||
@@ -501,14 +429,16 @@ const isRightPanelOpen = ref(false)
|
||||
|
||||
watch(
|
||||
() => selectedNodePacks.value.length,
|
||||
(length) => {
|
||||
isRightPanelOpen.value = length > 0
|
||||
(length, oldLength) => {
|
||||
if (length > 0 && oldLength === 0) {
|
||||
isRightPanelOpen.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getLoadingCount = () => {
|
||||
switch (selectedTab.value?.id) {
|
||||
case ManagerTab.Installed:
|
||||
case ManagerTab.AllInstalled:
|
||||
return comfyManagerStore.installedPacksIds?.size
|
||||
case ManagerTab.Workflow:
|
||||
return workflowPacks.value?.length
|
||||
@@ -578,10 +508,6 @@ whenever(selectedNodePack, async () => {
|
||||
if (packIndex !== -1) {
|
||||
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
|
||||
}
|
||||
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||
if (idx !== -1) {
|
||||
displayPacks.value.splice(idx, 1, mergedPack)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -595,6 +521,7 @@ watch([searchQuery, selectedNavId], () => {
|
||||
pageNumber.value = 0
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
unSelectItems()
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
:size
|
||||
:disabled="isLoading || isInstalling"
|
||||
@click="installAllPacks"
|
||||
@@ -14,6 +14,7 @@
|
||||
duration="1s"
|
||||
:size="size === 'sm' ? 12 : 16"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--download]" />
|
||||
<span>{{ computedLabel }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="$t('manager.tryUpdateTooltip')"
|
||||
variant="textonly"
|
||||
variant="primary"
|
||||
:size
|
||||
:disabled="isUpdating"
|
||||
@click="tryUpdate"
|
||||
@@ -11,6 +11,7 @@
|
||||
duration="1s"
|
||||
:size="size === 'sm' ? 12 : 16"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--refresh-cw]" />
|
||||
<span>{{ isUpdating ? t('g.updating') : t('manager.tryUpdate') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:size
|
||||
class="border border-red-500"
|
||||
@click="uninstallItems"
|
||||
>
|
||||
<Button variant="destructive" :size @click="uninstallItems">
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
{{
|
||||
nodePacks.length > 1
|
||||
? t('manager.uninstallSelected')
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
v-tooltip.top="
|
||||
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
||||
"
|
||||
class="border"
|
||||
variant="primary"
|
||||
:size
|
||||
:disabled="isUpdating"
|
||||
@click="updateAllPacks"
|
||||
>
|
||||
<DotSpinner v-if="isUpdating" duration="1s" :size="12" />
|
||||
<span>{{ $t('manager.updateAll') }}</span>
|
||||
<i v-else class="icon-[lucide--refresh-cw]" />
|
||||
<span>{{
|
||||
nodePacks.length > 1 ? $t('manager.updateAll') : $t('manager.update')
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,62 +1,110 @@
|
||||
<template>
|
||||
<template v-if="nodePack">
|
||||
<div class="relative z-40 flex h-full flex-col overflow-hidden">
|
||||
<div class="top-0 z-10 w-full px-6 pt-6">
|
||||
<InfoPanelHeader
|
||||
:node-packs="[nodePack]"
|
||||
:has-conflict="hasCompatibilityIssues"
|
||||
>
|
||||
<template v-if="canTryNightlyUpdate" #install-button>
|
||||
<div class="flex w-full justify-center gap-2">
|
||||
<PackTryUpdateButton :node-pack="nodePack" size="md" />
|
||||
<PackUninstallButton :node-packs="[nodePack]" size="md" />
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="flex h-full flex-col overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<PropertiesAccordionItem v-if="!importFailed" :class="accordionClass">
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
{{ t('manager.actions') }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-1 px-4">
|
||||
<template v-if="canTryNightlyUpdate">
|
||||
<PackTryUpdateButton :node-pack="nodePack" size="md" />
|
||||
<PackUninstallButton :node-packs="[nodePack]" size="md" />
|
||||
</template>
|
||||
</InfoPanelHeader>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="scrollbar-hide flex-1 overflow-y-auto p-6 pt-2 text-sm"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<MetadataRow
|
||||
v-if="!importFailed && isPackInstalled(nodePack.id)"
|
||||
:label="t('manager.filter.enabled')"
|
||||
class="flex"
|
||||
style="align-items: center"
|
||||
>
|
||||
<PackEnableToggle
|
||||
:node-pack="nodePack"
|
||||
:has-conflict="hasCompatibilityIssues"
|
||||
<template v-else-if="isUpdateAvailable">
|
||||
<PackUpdateButton :node-packs="[nodePack]" size="md" />
|
||||
<PackUninstallButton :node-packs="[nodePack]" size="md" />
|
||||
</template>
|
||||
<template v-else-if="isAllInstalled">
|
||||
<PackUninstallButton :node-packs="[nodePack]" size="md" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<PackInstallButton
|
||||
:node-packs="[nodePack]"
|
||||
size="md"
|
||||
:has-conflict="hasCompatibilityIssues || hasConflictInfo"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
v-for="item in infoItems"
|
||||
v-show="item.value !== undefined && item.value !== null"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
<MetadataRow :label="t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="
|
||||
nodePack.status as components['schemas']['NodeVersionStatus']
|
||||
"
|
||||
:has-compatibility-issues="hasCompatibilityIssues"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
|
||||
</MetadataRow>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
<InfoTabs
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<PropertiesAccordionItem :class="accordionClass">
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
{{ t('manager.basicInfo') }}
|
||||
</span>
|
||||
</template>
|
||||
<ModelInfoField :label="t('g.name')">
|
||||
<span class="text-muted-foreground">{{ nodePack.name }}</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField
|
||||
v-if="!importFailed && isPackInstalled(nodePack.id)"
|
||||
:label="t('manager.filter.enabled')"
|
||||
>
|
||||
<PackEnableToggle
|
||||
:node-pack="nodePack"
|
||||
:has-compatibility-issues="hasCompatibilityIssues"
|
||||
:conflict-result="conflictResult"
|
||||
:has-conflict="hasCompatibilityIssues"
|
||||
/>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField
|
||||
v-for="item in infoItems"
|
||||
v-show="item.value !== undefined && item.value !== null"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ item.value }}</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="
|
||||
nodePack.status as components['schemas']['NodeVersionStatus']
|
||||
"
|
||||
:has-compatibility-issues="hasCompatibilityIssues"
|
||||
/>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
|
||||
</ModelInfoField>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<PropertiesAccordionItem :class="accordionClass">
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
{{ t('g.description') }}
|
||||
</span>
|
||||
</template>
|
||||
<DescriptionTabPanel :node-pack="nodePack" />
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<PropertiesAccordionItem
|
||||
v-if="hasCompatibilityIssues"
|
||||
:class="accordionClass"
|
||||
>
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
⚠️ {{ importFailed ? t('g.error') : t('g.warning') }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="px-4 py-2">
|
||||
<WarningTabPanel :conflict-result="conflictResult" />
|
||||
</div>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<PropertiesAccordionItem :class="accordionClass">
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
{{ t('g.nodes') }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="px-4 py-2">
|
||||
<NodesTabPanel :node-pack="nodePack" :node-names="nodeNames" />
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -67,26 +115,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll, whenever } from '@vueuse/core'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import ModelInfoField from '@/platform/assets/components/modelInfo/ModelInfoField.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
|
||||
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
|
||||
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import PackTryUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackTryUpdateButton.vue'
|
||||
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
|
||||
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 PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
|
||||
import DescriptionTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/DescriptionTabPanel.vue'
|
||||
import NodesTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/NodesTabPanel.vue'
|
||||
import WarningTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue'
|
||||
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
|
||||
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'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import type {
|
||||
ConflictDetail,
|
||||
ConflictDetectionResult
|
||||
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
|
||||
|
||||
interface InfoItem {
|
||||
@@ -101,7 +157,12 @@ const { nodePack } = defineProps<{
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const accordionClass = cn(
|
||||
'bg-modal-panel-background border-t border-border-default'
|
||||
)
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
const { isPackInstalled } = managerStore
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack.id))
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
@@ -109,9 +170,20 @@ whenever(isInstalled, () => {
|
||||
isInstalling.value = false
|
||||
})
|
||||
|
||||
const { canTryNightlyUpdate } = usePackUpdateStatus(() => nodePack)
|
||||
const { canTryNightlyUpdate, isUpdateAvailable } = usePackUpdateStatus(
|
||||
() => nodePack
|
||||
)
|
||||
|
||||
const isAllInstalled = computed(() => isPackInstalled(nodePack.id))
|
||||
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
const conflictInfo = computed<ConflictDetail[]>(() => {
|
||||
const compatibility = checkNodeCompatibility(nodePack)
|
||||
return compatibility.conflicts ?? []
|
||||
})
|
||||
|
||||
const hasConflictInfo = computed(() => conflictInfo.value.length > 0)
|
||||
const { getConflictsForPackageByID } = useConflictDetectionStore()
|
||||
|
||||
const { t, d, n } = useI18n()
|
||||
@@ -152,6 +224,12 @@ provide(ImportFailedKey, {
|
||||
showImportFailedDialog
|
||||
})
|
||||
|
||||
const nodeNames = computed(() => {
|
||||
// @ts-expect-error comfy_nodes is an Algolia-specific field
|
||||
const { comfy_nodes } = nodePack
|
||||
return comfy_nodes ?? []
|
||||
})
|
||||
|
||||
const infoItems = computed<InfoItem[]>(() => [
|
||||
{
|
||||
key: 'publisher',
|
||||
@@ -174,20 +252,11 @@ const infoItems = computed<InfoItem[]>(() => [
|
||||
}
|
||||
])
|
||||
|
||||
const { y } = useScroll(scrollContainer, {
|
||||
eventListenerOptions: {
|
||||
passive: true
|
||||
}
|
||||
})
|
||||
const onNodePackChange = () => {
|
||||
y.value = 0
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
onNodePackChange()
|
||||
if (nodePackId !== oldNodePackId && scrollContainer.value) {
|
||||
scrollContainer.value.scrollTop = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<div v-if="nodePacks?.length" class="flex flex-col items-center">
|
||||
<slot name="thumbnail">
|
||||
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
|
||||
</slot>
|
||||
<h2
|
||||
class="mt-4 mb-2 text-center text-2xl font-bold"
|
||||
style="word-break: break-all"
|
||||
>
|
||||
<slot name="title">
|
||||
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
|
||||
</slot>
|
||||
</h2>
|
||||
<div
|
||||
v-if="!importFailed"
|
||||
class="mt-2 mb-4 flex w-full max-w-xs justify-center"
|
||||
>
|
||||
<slot name="install-button">
|
||||
<PackUninstallButton
|
||||
v-if="isAllInstalled"
|
||||
v-bind="$attrs"
|
||||
size="md"
|
||||
:node-packs="nodePacks"
|
||||
/>
|
||||
<PackInstallButton
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
size="md"
|
||||
:node-packs="nodePacks"
|
||||
:has-conflict="hasConflict || computedHasConflict"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center">
|
||||
<NoResultsPlaceholder
|
||||
:message="$t('manager.status.unknown')"
|
||||
:title="$t('manager.tryAgainLater')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
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'
|
||||
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
|
||||
|
||||
const { nodePacks, hasConflict } = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
hasConflict?: boolean
|
||||
}>()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
// Inject import failed context from parent
|
||||
const importFailedContext = inject(ImportFailedKey)
|
||||
const importFailed = importFailedContext?.importFailed
|
||||
|
||||
const isAllInstalled = ref(false)
|
||||
watch(
|
||||
[() => nodePacks, () => managerStore.installedPacks],
|
||||
() => {
|
||||
isAllInstalled.value = nodePacks.every((nodePack) =>
|
||||
managerStore.isPackInstalled(nodePack.id)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Add conflict detection for install button dialog
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
// Compute conflict info for all node packs
|
||||
const conflictInfo = computed<ConflictDetail[]>(() => {
|
||||
if (!nodePacks?.length) return []
|
||||
|
||||
const allConflicts: ConflictDetail[] = []
|
||||
for (const nodePack of nodePacks) {
|
||||
const compatibilityCheck = checkNodeCompatibility(nodePack)
|
||||
if (compatibilityCheck.conflicts) {
|
||||
allConflicts.push(...compatibilityCheck.conflicts)
|
||||
}
|
||||
}
|
||||
return allConflicts
|
||||
})
|
||||
|
||||
const computedHasConflict = computed(() => conflictInfo.value.length > 0)
|
||||
</script>
|
||||
@@ -1,64 +1,67 @@
|
||||
<template>
|
||||
<div v-if="nodePacks?.length" class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<InfoPanelHeader :node-packs>
|
||||
<template #thumbnail>
|
||||
<PackIconStacked :node-packs="nodePacks" />
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="mt-5">
|
||||
<span class="mr-2 inline-block text-base text-blue-500">{{
|
||||
nodePacks.length
|
||||
}}</span>
|
||||
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #install-button>
|
||||
<!-- Mixed: Don't show any button -->
|
||||
<div v-if="isMixed" class="text-sm text-neutral-500">
|
||||
{{ $t('manager.mixedSelectionMessage') }}
|
||||
</div>
|
||||
<!-- All installed: Show update (if nightly) and uninstall buttons -->
|
||||
<div
|
||||
v-else-if="isAllInstalled"
|
||||
class="flex w-full justify-center gap-2"
|
||||
>
|
||||
<Button
|
||||
v-if="hasNightlyPacks"
|
||||
v-tooltip.top="$t('manager.tryUpdateTooltip')"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
:disabled="isUpdatingSelected"
|
||||
@click="updateSelectedNightlyPacks"
|
||||
>
|
||||
<DotSpinner v-if="isUpdatingSelected" duration="1s" :size="16" />
|
||||
<span>{{ updateSelectedLabel }}</span>
|
||||
</Button>
|
||||
<PackUninstallButton size="md" :node-packs="installedPacks" />
|
||||
</div>
|
||||
<!-- None installed: Show install button -->
|
||||
<PackInstallButton
|
||||
v-else-if="isNoneInstalled"
|
||||
<div
|
||||
v-if="nodePacks?.length"
|
||||
class="flex h-full flex-col overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<PropertiesAccordionItem :class="accordionClass">
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
{{ t('manager.actions') }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-1 px-4">
|
||||
<!-- Mixed: Don't show any button -->
|
||||
<div v-if="isMixed" class="text-sm text-neutral-500">
|
||||
{{ $t('manager.mixedSelectionMessage') }}
|
||||
</div>
|
||||
<!-- All installed: Show update (if nightly) and uninstall buttons -->
|
||||
<template v-else-if="isAllInstalled">
|
||||
<Button
|
||||
v-if="hasNightlyPacks"
|
||||
v-tooltip.top="$t('manager.tryUpdateTooltip')"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
:node-packs="notInstalledPacks"
|
||||
:has-conflict="hasConflicts"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
:disabled="isUpdatingSelected"
|
||||
@click="updateSelectedNightlyPacks"
|
||||
>
|
||||
<DotSpinner v-if="isUpdatingSelected" duration="1s" :size="16" />
|
||||
<span>{{ updateSelectedLabel }}</span>
|
||||
</Button>
|
||||
<PackUninstallButton size="md" :node-packs="installedPacks" />
|
||||
</template>
|
||||
</InfoPanelHeader>
|
||||
<div class="mb-6">
|
||||
<MetadataRow :label="$t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="overallStatus"
|
||||
:has-compatibility-issues="hasConflicts"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
:label="$t('manager.totalNodes')"
|
||||
:value="totalNodesCount"
|
||||
<!-- None installed: Show install button -->
|
||||
<PackInstallButton
|
||||
v-else-if="isNoneInstalled"
|
||||
size="md"
|
||||
:node-packs="notInstalledPacks"
|
||||
:has-conflict="hasConflicts"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<PropertiesAccordionItem :class="accordionClass">
|
||||
<template #label>
|
||||
<span class="text-xs uppercase font-inter">
|
||||
{{ t('manager.basicInfo') }}
|
||||
</span>
|
||||
</template>
|
||||
<ModelInfoField :label="t('manager.selected')">
|
||||
<span>
|
||||
<span class="font-bold text-blue-500">{{ nodePacks.length }}</span>
|
||||
{{ t('manager.packsSelected') }}
|
||||
</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="overallStatus"
|
||||
:has-compatibility-issues="hasConflicts"
|
||||
/>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('manager.totalNodes')">
|
||||
<span class="text-muted-foreground">{{ totalNodesCount }}</span>
|
||||
</ModelInfoField>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
<div v-else class="mx-8 mt-4 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
@@ -71,15 +74,15 @@ import { computed, onUnmounted, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ModelInfoField from '@/platform/assets/components/modelInfo/ModelInfoField.vue'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
|
||||
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'
|
||||
@@ -92,6 +95,11 @@ const { nodePacks } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const accordionClass = cn(
|
||||
'bg-modal-panel-background border-t border-border-default'
|
||||
)
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
const nodePacksRef = toRef(() => nodePacks)
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<Tabs :value="activeTab">
|
||||
<TabList class="scrollbar-hide overflow-x-auto">
|
||||
<Tab
|
||||
v-if="hasCompatibilityIssues"
|
||||
value="warning"
|
||||
class="mr-6 p-2 font-inter"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>⚠️</span>
|
||||
{{ importFailed ? $t('g.error') : $t('g.warning') }}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="description" class="mr-6 p-2 font-inter">
|
||||
{{ $t('g.description') }}
|
||||
</Tab>
|
||||
<Tab value="nodes" class="p-2 font-inter">
|
||||
{{ $t('g.nodes') }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels class="overflow-auto px-2 py-4">
|
||||
<TabPanel
|
||||
v-if="hasCompatibilityIssues"
|
||||
value="warning"
|
||||
class="bg-transparent"
|
||||
>
|
||||
<WarningTabPanel
|
||||
:node-pack="nodePack"
|
||||
:conflict-result="conflictResult"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="description">
|
||||
<DescriptionTabPanel :node-pack="nodePack" />
|
||||
</TabPanel>
|
||||
<TabPanel value="nodes">
|
||||
<NodesTabPanel :node-pack="nodePack" :node-names="nodeNames" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, inject, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import DescriptionTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/DescriptionTabPanel.vue'
|
||||
import NodesTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/NodesTabPanel.vue'
|
||||
import WarningTabPanel from '@/workbench/extensions/manager/components/manager/infoPanel/tabs/WarningTabPanel.vue'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
|
||||
|
||||
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
hasCompatibilityIssues?: boolean
|
||||
conflictResult?: ConflictDetectionResult | null
|
||||
}>()
|
||||
|
||||
// Inject import failed context from parent
|
||||
const importFailedContext = inject(ImportFailedKey)
|
||||
const importFailed = importFailedContext?.importFailed
|
||||
|
||||
const nodeNames = computed(() => {
|
||||
// @ts-expect-error comfy_nodes is an Algolia-specific field
|
||||
const { comfy_nodes } = nodePack
|
||||
return comfy_nodes ?? []
|
||||
})
|
||||
|
||||
const activeTab = ref('description')
|
||||
|
||||
// Watch for compatibility issues and automatically switch to warning tab
|
||||
watchEffect(
|
||||
() => {
|
||||
if (hasCompatibilityIssues) {
|
||||
activeTab.value = 'warning'
|
||||
} else if (activeTab.value === 'warning') {
|
||||
// If currently on warning tab but no issues, switch to description
|
||||
activeTab.value = 'description'
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-sm">
|
||||
<div v-for="(section, index) in sections" :key="index" class="mb-4">
|
||||
<div class="mb-3">
|
||||
{{ section.title }}
|
||||
</div>
|
||||
<div class="break-words text-muted">
|
||||
<a
|
||||
v-if="section.isUrl"
|
||||
:href="section.text"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<i v-if="isGitHubLink(section.text)" class="pi pi-github text-base" />
|
||||
<span class="break-all">{{ section.text }}</span>
|
||||
</a>
|
||||
<MarkdownText v-else :text="section.text" class="text-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MarkdownText from '@/workbench/extensions/manager/components/manager/infoPanel/MarkdownText.vue'
|
||||
|
||||
export interface TextSection {
|
||||
title: string
|
||||
text: string
|
||||
isUrl?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
sections: TextSection[]
|
||||
}>()
|
||||
|
||||
const isGitHubLink = (url: string): boolean => url.includes('github.com')
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="flex py-1.5 text-xs">
|
||||
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
|
||||
<div class="w-2/3">
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { value = 'N/A', label } = defineProps<{
|
||||
label: string
|
||||
value?: string | number
|
||||
}>()
|
||||
</script>
|
||||
@@ -15,13 +15,6 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
const TRANSLATIONS = {
|
||||
description: 'Description',
|
||||
repository: 'Repository',
|
||||
license: 'License',
|
||||
noDescription: 'No description available'
|
||||
}
|
||||
|
||||
describe('DescriptionTabPanel', () => {
|
||||
const mountComponent = (props: {
|
||||
nodePack: Partial<components['schemas']['Node']>
|
||||
@@ -34,16 +27,6 @@ describe('DescriptionTabPanel', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const getSectionByTitle = (
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
title: string
|
||||
) => {
|
||||
const sections = wrapper
|
||||
.findComponent({ name: 'InfoTextSection' })
|
||||
.props('sections')
|
||||
return sections.find((s: any) => s.title === title)
|
||||
}
|
||||
|
||||
const createNodePack = (
|
||||
overrides: Partial<components['schemas']['Node']> = {}
|
||||
) => ({
|
||||
@@ -134,37 +117,36 @@ describe('DescriptionTabPanel', () => {
|
||||
licenseTests.forEach((test) => {
|
||||
it(test.name, () => {
|
||||
const wrapper = mountComponent({ nodePack: test.nodePack })
|
||||
const licenseSection = getSectionByTitle(wrapper, TRANSLATIONS.license)
|
||||
expect(licenseSection).toBeDefined()
|
||||
expect(licenseSection.text).toBe(test.expected.text)
|
||||
expect(licenseSection.isUrl).toBe(test.expected.isUrl)
|
||||
if (test.expected.isUrl) {
|
||||
const link = wrapper
|
||||
.findAll('a')
|
||||
.find((a) => a.text().includes(test.expected.text))
|
||||
expect(link).toBeDefined()
|
||||
expect(link!.attributes('href')).toBe(test.expected.text)
|
||||
} else {
|
||||
expect(wrapper.text()).toContain(test.expected.text)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('description sections', () => {
|
||||
it('shows description section', () => {
|
||||
it('shows description text', () => {
|
||||
const wrapper = mountComponent({
|
||||
nodePack: createNodePack()
|
||||
})
|
||||
const descriptionSection = getSectionByTitle(
|
||||
wrapper,
|
||||
TRANSLATIONS.description
|
||||
)
|
||||
expect(descriptionSection).toBeDefined()
|
||||
expect(descriptionSection.text).toBe('Test description')
|
||||
expect(wrapper.text()).toContain('Test description')
|
||||
})
|
||||
|
||||
it('shows repository section when available', () => {
|
||||
it('shows repository link when available', () => {
|
||||
const wrapper = mountComponent({
|
||||
nodePack: createNodePack({
|
||||
repository: 'https://github.com/user/repo'
|
||||
})
|
||||
})
|
||||
const repoSection = getSectionByTitle(wrapper, TRANSLATIONS.repository)
|
||||
expect(repoSection).toBeDefined()
|
||||
expect(repoSection.text).toBe('https://github.com/user/repo')
|
||||
expect(repoSection.isUrl).toBe(true)
|
||||
const repoLink = wrapper.find('a[href="https://github.com/user/repo"]')
|
||||
expect(repoLink.exists()).toBe(true)
|
||||
expect(repoLink.attributes('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('shows fallback text when description is missing', () => {
|
||||
@@ -173,7 +155,7 @@ describe('DescriptionTabPanel', () => {
|
||||
description: undefined
|
||||
}
|
||||
})
|
||||
expect(wrapper.find('p').text()).toBe(TRANSLATIONS.noDescription)
|
||||
expect(wrapper.text()).toContain('No description available')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<InfoTextSection
|
||||
v-if="nodePack?.description"
|
||||
:sections="descriptionSections"
|
||||
/>
|
||||
<p v-else class="text-sm text-muted italic">
|
||||
{{ $t('manager.noDescription') }}
|
||||
</p>
|
||||
<div v-if="nodePack?.latest_version?.dependencies?.length">
|
||||
<p class="mb-1">
|
||||
{{ $t('manager.dependencies') }}
|
||||
</p>
|
||||
<div>
|
||||
<ModelInfoField :label="t('g.description')">
|
||||
<MarkdownText
|
||||
v-if="nodePack.description"
|
||||
:text="nodePack.description"
|
||||
class="text-muted-foreground"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground italic">
|
||||
{{ t('manager.noDescription') }}
|
||||
</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField v-if="nodePack.repository" :label="t('manager.repository')">
|
||||
<a
|
||||
:href="nodePack.repository"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-muted-foreground no-underline transition-colors hover:text-foreground"
|
||||
>
|
||||
<i
|
||||
v-if="isGitHubLink(nodePack.repository)"
|
||||
class="pi pi-github text-base"
|
||||
/>
|
||||
<span class="break-all">{{ nodePack.repository }}</span>
|
||||
<i class="icon-[lucide--external-link] size-4 shrink-0" />
|
||||
</a>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField v-if="licenseInfo" :label="t('manager.license')">
|
||||
<a
|
||||
v-if="licenseInfo.isUrl"
|
||||
:href="licenseInfo.text"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-muted-foreground no-underline transition-colors hover:text-foreground"
|
||||
>
|
||||
<span class="break-all">{{ licenseInfo.text }}</span>
|
||||
<i class="icon-[lucide--external-link] size-4 shrink-0" />
|
||||
</a>
|
||||
<span v-else class="text-muted-foreground break-all">
|
||||
{{ licenseInfo.text }}
|
||||
</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField
|
||||
v-if="nodePack.latest_version?.dependencies?.length"
|
||||
:label="t('manager.dependencies')"
|
||||
>
|
||||
<div
|
||||
v-for="(dep, index) in nodePack.latest_version.dependencies"
|
||||
:key="index"
|
||||
class="break-words text-muted"
|
||||
class="break-words text-muted-foreground"
|
||||
>
|
||||
{{ dep }}
|
||||
</div>
|
||||
</div>
|
||||
</ModelInfoField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,10 +59,10 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ModelInfoField from '@/platform/assets/components/modelInfo/ModelInfoField.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { isValidUrl } from '@/utils/formatUtil'
|
||||
import InfoTextSection from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTextSection.vue'
|
||||
import type { TextSection } from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTextSection.vue'
|
||||
import MarkdownText from '@/workbench/extensions/manager/components/manager/infoPanel/MarkdownText.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -37,6 +70,8 @@ const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const isGitHubLink = (url: string): boolean => url.includes('github.com')
|
||||
|
||||
const isLicenseFile = (filename: string): boolean => {
|
||||
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
|
||||
const licensePattern = /^license(\.md|\.txt)?$/i
|
||||
@@ -118,33 +153,8 @@ const formatLicense = (
|
||||
}
|
||||
}
|
||||
|
||||
const descriptionSections = computed<TextSection[]>(() => {
|
||||
const sections: TextSection[] = [
|
||||
{
|
||||
title: t('g.description'),
|
||||
text: nodePack.description || t('manager.noDescription')
|
||||
}
|
||||
]
|
||||
|
||||
if (nodePack.repository) {
|
||||
sections.push({
|
||||
title: t('manager.repository'),
|
||||
text: nodePack.repository,
|
||||
isUrl: isValidUrl(nodePack.repository)
|
||||
})
|
||||
}
|
||||
|
||||
if (nodePack.license) {
|
||||
const licenseInfo = formatLicense(nodePack.license)
|
||||
if (licenseInfo && licenseInfo.text) {
|
||||
sections.push({
|
||||
title: t('manager.license'),
|
||||
text: licenseInfo.text,
|
||||
isUrl: licenseInfo.isUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
const licenseInfo = computed(() => {
|
||||
if (!nodePack.license) return null
|
||||
return formatLicense(nodePack.license)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-sm">
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<template v-if="mappedNodeDefs?.length">
|
||||
<div
|
||||
v-for="nodeDef in mappedNodeDefs"
|
||||
:key="createNodeDefKey(nodeDef)"
|
||||
class="rounded-lg border p-4"
|
||||
>
|
||||
<div class="[zoom:0.6]">
|
||||
<NodePreview
|
||||
:node-def="nodeDef"
|
||||
position="relative"
|
||||
class="min-w-full! text-[.625rem]!"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="nodeDef in mappedNodeDefs" :key="createNodeDefKey(nodeDef)">
|
||||
<NodePreview :node-def="nodeDef" class="min-w-full!" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isLoading">
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
<div
|
||||
v-for="(conflict, index) in conflictResult?.conflicts || []"
|
||||
:key="index"
|
||||
class="rounded-md bg-secondary-background/60 px-2 py-1"
|
||||
class="rounded-md bg-secondary-background/60"
|
||||
>
|
||||
<!-- Import failed conflicts show detailed error message -->
|
||||
<template v-if="conflict.type === 'import_failed'">
|
||||
<div
|
||||
v-if="conflict.required_value"
|
||||
class="max-h-64 overflow-x-hidden scrollbar-custom overflow-y-auto rounded px-2"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground break-all font-mono">
|
||||
<div v-if="conflict.required_value" class="overflow-x-hidden rounded">
|
||||
<p class="m-0 text-xs text-muted-foreground break-all font-mono">
|
||||
{{ conflict.required_value }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="aspect-7/3 w-full overflow-hidden">
|
||||
<div class="aspect-7/3 w-full overflow-hidden z-0">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="h-full w-full">
|
||||
<img
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -90,7 +89,6 @@ describe('PackCard', () => {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
components: {
|
||||
Card,
|
||||
ProgressSpinner
|
||||
},
|
||||
stubs: {
|
||||
@@ -121,7 +119,7 @@ describe('PackCard', () => {
|
||||
it('should render package card with basic information', () => {
|
||||
const wrapper = createWrapper({ nodePack: mockNodePack })
|
||||
|
||||
expect(wrapper.find('.p-card').exists()).toBe(true)
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Test Package')
|
||||
expect(wrapper.text()).toContain('Test package description')
|
||||
expect(wrapper.text()).toContain('Test Author')
|
||||
@@ -133,22 +131,22 @@ describe('PackCard', () => {
|
||||
expect(wrapper.text()).toContain('2024. 1. 1.')
|
||||
})
|
||||
|
||||
it('should apply selected class when isSelected is true', () => {
|
||||
it('should apply selected ring when isSelected is true', () => {
|
||||
const wrapper = createWrapper({
|
||||
nodePack: mockNodePack,
|
||||
isSelected: true
|
||||
})
|
||||
|
||||
expect(wrapper.find('.selected-card').exists()).toBe(true)
|
||||
expect(wrapper.find('.ring-3').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not apply selected class when isSelected is false', () => {
|
||||
it('should not apply selected ring when isSelected is false', () => {
|
||||
const wrapper = createWrapper({
|
||||
nodePack: mockNodePack,
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
expect(wrapper.find('.selected-card').exists()).toBe(false)
|
||||
expect(wrapper.find('.ring-3').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -157,7 +155,7 @@ describe('PackCard', () => {
|
||||
const wrapper = createWrapper({ nodePack: mockNodePack })
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('.p-card').exists()).toBe(true)
|
||||
expect(wrapper.find('.rounded-lg').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
<template>
|
||||
<Card
|
||||
class="shadow-elevation-3 inline-flex size-full flex-col items-start justify-between overflow-hidden rounded-lg transition-all duration-200"
|
||||
:class="{
|
||||
'selected-card': isSelected,
|
||||
'opacity-60': isDisabled
|
||||
}"
|
||||
:pt="{
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: {
|
||||
class: 'p-0 m-0 flex flex-col gap-0',
|
||||
style: {
|
||||
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
|
||||
}
|
||||
}
|
||||
}"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-full flex-col overflow-hidden rounded-lg bg-modal-card-background transition-colors duration-200 cursor-pointer select-none',
|
||||
isSelected
|
||||
? 'ring-3 ring-modal-card-border-highlighted'
|
||||
: 'hover:bg-modal-card-background-hovered',
|
||||
isDisabled && 'opacity-60'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<!-- Banner -->
|
||||
<div class="w-full rounded-t-lg">
|
||||
<PackBanner :node-pack="nodePack" />
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="h-full w-full px-4 pt-4 pb-3">
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col rounded-lg min-h-0">
|
||||
<div class="h-full w-full py-2 px-3">
|
||||
<div class="flex h-full w-full flex-col gap-y-1">
|
||||
<span
|
||||
class="truncate overflow-hidden text-sm font-bold text-ellipsis"
|
||||
class="truncate overflow-hidden text-xs font-bold text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
@@ -63,19 +59,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-border-default">
|
||||
<PackCardFooter :node-pack="nodePack" :is-installing="isInstalling" />
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from 'primevue/card'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/workbench/extensions/manager/components/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/workbench/extensions/manager/components/manager/packCard/PackCardFooter.vue'
|
||||
@@ -96,11 +93,6 @@ const { nodePack, isSelected = false } = defineProps<{
|
||||
|
||||
const { d, t } = useI18n()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const { isPackInstalled, isPackEnabled, isPackInstalling } =
|
||||
useComfyManagerStore()
|
||||
|
||||
@@ -133,22 +125,3 @@ const formattedLatestVersionDate = computed(() => {
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selected-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 4px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<PackInstallButton
|
||||
v-if="!isInstalled"
|
||||
:node-packs="[nodePack]"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="aspect-[2/1] w-full max-w-[204] overflow-hidden rounded-lg">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="h-full w-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="$t('g.defaultBanner')"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner_url or icon show -->
|
||||
<div v-else class="relative h-full w-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
filter: 'blur(10px)'
|
||||
}"
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative w-full h-full object-cover z-10'
|
||||
: 'relative w-full h-full object-contain z-10'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
</script>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div class="relative h-[104px] w-[224px] shadow-xl">
|
||||
<div
|
||||
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
|
||||
:key="pack.id"
|
||||
class="absolute h-[90px] w-[210px]"
|
||||
:style="{
|
||||
bottom: `${index * offset}px`,
|
||||
right: `${index * offset}px`,
|
||||
zIndex: maxVisible - index
|
||||
}"
|
||||
>
|
||||
<div class="rounded-lg border p-0.5 shadow-lg">
|
||||
<PackIcon :node-pack="pack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import PackIcon from '@/workbench/extensions/manager/components/manager/packIcon/PackIcon.vue'
|
||||
|
||||
const {
|
||||
nodePacks,
|
||||
maxVisible = 3,
|
||||
offset = 8
|
||||
} = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
maxVisible?: number
|
||||
offset?: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,217 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { orderBy } from 'es-toolkit/compat'
|
||||
import { compare, valid } from 'semver'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
|
||||
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
export function useManagerDisplayPacks(
|
||||
selectedTabId: Ref<string | null>,
|
||||
searchResults: Ref<NodePack[]>,
|
||||
searchQuery: Ref<string>,
|
||||
sortField: Ref<string>
|
||||
) {
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const conflictDetectionStore = useConflictDetectionStore()
|
||||
const { getSortValue, getSortableFields } = useRegistrySearchGateway()
|
||||
|
||||
const {
|
||||
startFetchInstalled,
|
||||
filterInstalledPack,
|
||||
installedPacks,
|
||||
isLoading: isLoadingInstalled,
|
||||
isReady: installedPacksReady
|
||||
} = useInstalledPacks()
|
||||
|
||||
const {
|
||||
startFetchWorkflowPacks,
|
||||
filterWorkflowPack,
|
||||
workflowPacks,
|
||||
isLoading: isLoadingWorkflow,
|
||||
isReady: workflowPacksReady
|
||||
} = useWorkflowPacks()
|
||||
|
||||
const tabType = computed(() => selectedTabId.value as ManagerTab | null)
|
||||
const isEmptySearch = computed(() => searchQuery.value === '')
|
||||
|
||||
// Sorting function for packs not from searchResults
|
||||
const sortPacks = (packs: NodePack[]) => {
|
||||
if (!sortField.value || packs.length === 0) return packs
|
||||
|
||||
const sortableFields = getSortableFields()
|
||||
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||
const direction = fieldConfig?.direction || 'desc'
|
||||
|
||||
return orderBy(
|
||||
packs,
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
)
|
||||
}
|
||||
|
||||
// Filter functions
|
||||
const filterNotInstalled = (packs: NodePack[]) =>
|
||||
packs.filter((p) => !comfyManagerStore.isPackInstalled(p.id))
|
||||
|
||||
const filterConflicting = (packs: NodePack[]) =>
|
||||
packs.filter(
|
||||
(p) =>
|
||||
!!p.id &&
|
||||
conflictDetectionStore.conflictedPackages.some(
|
||||
(c) => c.package_id === p.id
|
||||
)
|
||||
)
|
||||
|
||||
const filterOutdated = (packs: NodePack[]) =>
|
||||
packs.filter((p) => {
|
||||
const installedVersion = comfyManagerStore.getInstalledPackVersion(
|
||||
p.id ?? ''
|
||||
)
|
||||
const latestVersion = p.latest_version?.version
|
||||
if (
|
||||
!comfyManagerStore.isPackInstalled(p.id) ||
|
||||
!installedVersion ||
|
||||
!latestVersion ||
|
||||
!valid(installedVersion) // nightly builds
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return compare(latestVersion, installedVersion) > 0
|
||||
})
|
||||
|
||||
// Data fetching triggers using whenever
|
||||
const needsInstalledPacks = computed(() =>
|
||||
[
|
||||
ManagerTab.AllInstalled,
|
||||
ManagerTab.UpdateAvailable,
|
||||
ManagerTab.Conflicting
|
||||
].includes(tabType.value as ManagerTab)
|
||||
)
|
||||
|
||||
const needsWorkflowPacks = computed(() =>
|
||||
[ManagerTab.Workflow, ManagerTab.Missing].includes(
|
||||
tabType.value as ManagerTab
|
||||
)
|
||||
)
|
||||
|
||||
whenever(
|
||||
() =>
|
||||
needsInstalledPacks.value &&
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value,
|
||||
() => startFetchInstalled()
|
||||
)
|
||||
|
||||
whenever(
|
||||
() =>
|
||||
needsWorkflowPacks.value &&
|
||||
!workflowPacksReady.value &&
|
||||
!isLoadingWorkflow.value,
|
||||
() => startFetchWorkflowPacks()
|
||||
)
|
||||
|
||||
// For Missing tab, also need installed packs to determine what's missing
|
||||
whenever(
|
||||
() =>
|
||||
tabType.value === ManagerTab.Missing &&
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value,
|
||||
() => startFetchInstalled()
|
||||
)
|
||||
|
||||
// Single computed for display packs - replaces 7 watches
|
||||
const displayPacks = computed(() => {
|
||||
const tab = tabType.value
|
||||
const hasSearch = !isEmptySearch.value
|
||||
|
||||
switch (tab) {
|
||||
case ManagerTab.All:
|
||||
return searchResults.value
|
||||
|
||||
case ManagerTab.NotInstalled:
|
||||
return filterNotInstalled(searchResults.value)
|
||||
|
||||
case ManagerTab.AllInstalled:
|
||||
return hasSearch
|
||||
? filterInstalledPack(searchResults.value)
|
||||
: sortPacks(installedPacks.value)
|
||||
|
||||
case ManagerTab.UpdateAvailable:
|
||||
return sortPacks(
|
||||
filterOutdated(
|
||||
hasSearch
|
||||
? filterInstalledPack(searchResults.value)
|
||||
: installedPacks.value
|
||||
)
|
||||
)
|
||||
|
||||
case ManagerTab.Conflicting:
|
||||
return sortPacks(
|
||||
filterConflicting(
|
||||
hasSearch
|
||||
? filterInstalledPack(searchResults.value)
|
||||
: installedPacks.value
|
||||
)
|
||||
)
|
||||
|
||||
case ManagerTab.Workflow: {
|
||||
return hasSearch
|
||||
? filterWorkflowPack(searchResults.value)
|
||||
: sortPacks(workflowPacks.value)
|
||||
}
|
||||
|
||||
case ManagerTab.Missing: {
|
||||
const base = hasSearch
|
||||
? filterWorkflowPack(searchResults.value)
|
||||
: workflowPacks.value
|
||||
return sortPacks(filterNotInstalled(base))
|
||||
}
|
||||
|
||||
default:
|
||||
return searchResults.value
|
||||
}
|
||||
})
|
||||
|
||||
// Loading state - single computed
|
||||
const isLoading = computed(() => {
|
||||
const tab = tabType.value
|
||||
if (
|
||||
[
|
||||
ManagerTab.AllInstalled,
|
||||
ManagerTab.UpdateAvailable,
|
||||
ManagerTab.Conflicting
|
||||
].includes(tab as ManagerTab)
|
||||
) {
|
||||
return isLoadingInstalled.value
|
||||
}
|
||||
if ([ManagerTab.Workflow, ManagerTab.Missing].includes(tab as ManagerTab)) {
|
||||
return isLoadingWorkflow.value
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const missingNodePacks = computed(() =>
|
||||
filterNotInstalled(workflowPacks.value)
|
||||
)
|
||||
|
||||
return {
|
||||
displayPacks,
|
||||
isLoading,
|
||||
isLoadingInstalled,
|
||||
isLoadingWorkflow,
|
||||
installedPacks,
|
||||
workflowPacks,
|
||||
filterInstalledPack,
|
||||
filterWorkflowPack,
|
||||
missingNodePacks
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,11 @@ export function useRegistrySearch(
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const onQueryChange = () => updateSearchResults({ append: false })
|
||||
const onPageChange = () => updateSearchResults({ append: true })
|
||||
const onQueryChange = () => void updateSearchResults({ append: false })
|
||||
const onPageChange = () => {
|
||||
if (pageNumber.value === 0) return
|
||||
void updateSearchResults({ append: true })
|
||||
}
|
||||
|
||||
watch([sortField, searchMode], onQueryChange)
|
||||
watch(pageNumber, onPageChange)
|
||||
|
||||
@@ -14,10 +14,12 @@ export const IsInstallingKey: InjectionKey<Ref<boolean>> =
|
||||
|
||||
export enum ManagerTab {
|
||||
All = 'all',
|
||||
Installed = 'installed',
|
||||
NotInstalled = 'notInstalled',
|
||||
AllInstalled = 'allInstalled',
|
||||
UpdateAvailable = 'updateAvailable',
|
||||
Conflicting = 'conflicting',
|
||||
Workflow = 'workflow',
|
||||
Missing = 'missing',
|
||||
UpdateAvailable = 'updateAvailable'
|
||||
Missing = 'missing'
|
||||
}
|
||||
|
||||
export type TaskLog = {
|
||||
|
||||
Reference in New Issue
Block a user