V2 Node Search (+ hidden Node Library changes) (#8987)

## Summary

Redesigned node search with categories

## Changes

- **What**: Adds a v2 search component, leaving the existing
implementation untouched
- It also brings onboard the incomplete node library & preview changes,
disabled and behind a hidden setting
- **Breaking**: Changes the 'default' value of the node search setting
to v2, adding v1 (legacy) as an option

## Screenshots (if applicable)




https://github.com/user-attachments/assets/2ab797df-58f0-48e8-8b20-2a1809e3735f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8987-V2-Node-Search-hidden-Node-Library-changes-30c6d73d36508160902bcb92553f147c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
pythongosssss
2026-02-20 09:10:03 +00:00
committed by GitHub
parent 8f5cdead73
commit 6902e38e6a
183 changed files with 7972 additions and 127 deletions

View File

@@ -0,0 +1,77 @@
<template>
<TabsContent value="all" class="flex-1 overflow-y-auto h-full">
<!-- Favorites section -->
<template v-if="hasFavorites">
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</template>
<!-- Node sections -->
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="section.root"
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
const { fillNodeInfo } = defineProps<{
sections: NodeLibrarySection[]
fillNodeInfo: (node: TreeNode) => RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
const emit = defineEmits<{
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const nodeBookmarkStore = useNodeBookmarkStore()
const hasFavorites = computed(
() => (nodeBookmarkStore.bookmarkedRoot.children?.length ?? 0) > 0
)
const favoritesRoot = computed(() =>
fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
)
function handleAddToFavorites(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) {
if (node.data) {
nodeBookmarkStore.toggleBookmark(node.data)
}
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<TabsContent value="custom" class="flex-1 flex flex-col h-full">
<div
v-for="(section, index) in sections"
:key="section.title ?? index"
class="flex-1 overflow-y-auto h-full"
>
<!-- Section header -->
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
</h3>
<!-- Section tree -->
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="section.root"
:show-context-menu="false"
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
<div class="flex-none py-3 border-t border-border-default text-center">
<Button
variant="secondary"
class="justify-start gap-3"
@click="handleOpenManager"
>
<i class="icon-[lucide--blocks] size-5 text-muted-foreground" />
{{ $t('g.manageExtensions') }}
</Button>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
defineProps<{
sections: NodeLibrarySection[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
const emit = defineEmits<{
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const managerState = useManagerState()
async function handleOpenManager() {
await managerState.openManager()
}
</script>

View File

@@ -0,0 +1,204 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import EssentialNodeCard from './EssentialNodeCard.vue'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
const { mockStartDrag, mockHandleNativeDrop } = vi.hoisted(() => ({
mockStartDrag: vi.fn(),
mockHandleNativeDrop: vi.fn()
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: mockStartDrag,
handleNativeDrop: mockHandleNativeDrop
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div class="mock-preview" />' }
}))
describe('EssentialNodeCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function createMockNode(
overrides: Partial<ComfyNodeDefImpl> = {}
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
const data = {
name: 'TestNode',
display_name: 'Test Node',
...overrides
} as ComfyNodeDefImpl
return {
key: 'test-key',
label: 'Test Node',
icon: 'icon-[comfy--node]',
type: 'node',
totalLeaves: 1,
data
}
}
function mountComponent(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = createMockNode()
) {
return mount(EssentialNodeCard, {
props: { node },
global: {
stubs: {
Teleport: true
}
}
})
}
describe('rendering', () => {
it('should display the node display_name', () => {
const wrapper = mountComponent(
createMockNode({ display_name: 'Load Image' })
)
expect(wrapper.text()).toContain('Load Image')
})
it('should set data-node-name attribute', () => {
const wrapper = mountComponent(
createMockNode({ display_name: 'Save Image' })
)
const card = wrapper.find('[data-node-name]')
expect(card.attributes('data-node-name')).toBe('Save Image')
})
it('should be draggable', () => {
const wrapper = mountComponent()
const card = wrapper.find('[draggable]')
expect(card.attributes('draggable')).toBe('true')
})
})
describe('icon generation', () => {
it('should use kebab-case of node name for icon', () => {
const wrapper = mountComponent(createMockNode({ name: 'LoadImage' }))
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-[comfy--load-image]')
})
it('should use kebab-case for SaveImage', () => {
const wrapper = mountComponent(createMockNode({ name: 'SaveImage' }))
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-[comfy--save-image]')
})
it('should use kebab-case for ImageCrop', () => {
const wrapper = mountComponent(createMockNode({ name: 'ImageCrop' }))
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-[comfy--image-crop]')
})
it('should use kebab-case for complex node names', () => {
const wrapper = mountComponent(
createMockNode({ name: 'RecraftRemoveBackgroundNode' })
)
const icon = wrapper.find('i')
expect(icon.classes()).toContain(
'icon-[comfy--recraft-remove-background-node]'
)
})
it('should use default node icon when nodeDef has no name', () => {
const node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = {
key: 'test-key',
label: 'Test',
icon: 'icon',
type: 'node',
totalLeaves: 1,
data: undefined
}
const wrapper = mountComponent(node)
const icon = wrapper.find('i')
expect(icon.classes()).toContain('icon-[comfy--node]')
})
})
describe('events', () => {
it('should emit click event when clicked', async () => {
const node = createMockNode()
const wrapper = mountComponent(node)
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')?.[0]).toEqual([node])
})
it('should not emit click when nodeDef is undefined', async () => {
const node: RenderedTreeExplorerNode<ComfyNodeDefImpl> = {
key: 'test-key',
label: 'Test',
icon: 'icon',
type: 'node',
totalLeaves: 1,
data: undefined
}
const wrapper = mountComponent(node)
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
})
describe('drag and drop', () => {
it('should call startDrag on dragstart', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
await card.trigger('dragstart')
expect(mockStartDrag).toHaveBeenCalled()
})
it('should call handleNativeDrop on dragend', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
await card.trigger('dragend')
expect(mockHandleNativeDrop).toHaveBeenCalled()
})
})
describe('hover preview', () => {
it('should show preview on mouseenter', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
await card.trigger('mouseenter')
expect(wrapper.find('teleport-stub').exists()).toBe(true)
})
it('should hide preview after mouseleave', async () => {
const wrapper = mountComponent()
const card = wrapper.find('div')
await card.trigger('mouseenter')
expect(wrapper.find('teleport-stub').exists()).toBe(true)
await card.trigger('mouseleave')
expect(wrapper.find('teleport-stub').exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,81 @@
<template>
<div
:class="
cn(
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
'aspect-square'
)
"
:data-node-name="nodeDef?.display_name"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="flex flex-1 items-center justify-center">
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
</div>
<span
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
>
{{ nodeDef?.display_name }}
</span>
</div>
<Teleport v-if="showPreview" to="body">
<div
:ref="(el) => (previewRef = el as HTMLElement)"
:style="nodePreviewStyle"
>
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
</div>
</Teleport>
</template>
<script setup lang="ts">
import { kebabCase } from 'es-toolkit/string'
import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const { node } = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const emit = defineEmits<{
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const nodeDef = computed(() => node.data)
const panelRef = inject(SidebarContainerKey, undefined)
const {
previewRef,
showPreview,
nodePreviewStyle,
handleMouseEnter,
handleMouseLeave,
handleDragStart,
handleDragEnd
} = useNodePreviewAndDrag(nodeDef, { panelRef })
const nodeIcon = computed(() => {
const nodeName = nodeDef.value?.name
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})
function handleClick() {
if (!nodeDef.value) return
emit('click', node)
}
</script>

View File

@@ -0,0 +1,207 @@
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import EssentialNodesPanel from './EssentialNodesPanel.vue'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: vi.fn(),
handleNativeDrop: vi.fn(),
cancelDrag: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
describe('EssentialNodesPanel', () => {
function createMockNode(
name: string
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
return {
key: `node-${name}`,
label: name,
icon: 'icon-[comfy--node]',
type: 'node',
totalLeaves: 1,
data: {
name,
display_name: name
} as ComfyNodeDefImpl
}
}
function createMockFolder(
name: string,
children: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
return {
key: `folder-${name}`,
label: name,
icon: 'icon-[lucide--folder]',
type: 'folder',
totalLeaves: children.length,
children
}
}
function createMockRoot(): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
return {
key: 'root',
label: 'Root',
icon: '',
type: 'folder',
totalLeaves: 6,
children: [
createMockFolder('images', [
createMockNode('LoadImage'),
createMockNode('SaveImage')
]),
createMockFolder('video', [
createMockNode('LoadVideo'),
createMockNode('SaveVideo')
]),
createMockFolder('audio', [
createMockNode('LoadAudio'),
createMockNode('SaveAudio')
])
]
}
}
function mountComponent(
root = createMockRoot(),
expandedKeys: string[] = []
) {
const WrapperComponent = {
template: `<EssentialNodesPanel :root="root" v-model:expandedKeys="keys" />`,
components: { EssentialNodesPanel },
setup() {
const keys = ref(expandedKeys)
return { root, keys }
}
}
return mount(WrapperComponent, {
global: {
stubs: {
Teleport: true,
TabsContent: {
template: '<div class="tabs-content"><slot /></div>'
},
CollapsibleRoot: {
template:
'<div class="collapsible-root" :data-state="open ? \'open\' : \'closed\'"><slot /></div>',
props: ['open'],
emits: ['update:open']
},
CollapsibleTrigger: {
template:
'<button class="collapsible-trigger" @click="$emit(\'click\')"><slot /></button>'
},
CollapsibleContent: {
template: '<div class="collapsible-content"><slot /></div>'
}
}
}
})
}
describe('folder rendering', () => {
it('should render all top-level folders', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAll('.collapsible-trigger')
expect(triggers).toHaveLength(3)
})
it('should display folder labels', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('images')
expect(wrapper.text()).toContain('video')
expect(wrapper.text()).toContain('audio')
})
})
describe('default expansion', () => {
it('should expand all folders by default when expandedKeys is empty', async () => {
const wrapper = mountComponent(createMockRoot(), [])
await nextTick()
await flushPromises()
await nextTick()
const roots = wrapper.findAll('.collapsible-root')
expect(roots[0].attributes('data-state')).toBe('open')
expect(roots[1].attributes('data-state')).toBe('open')
expect(roots[2].attributes('data-state')).toBe('open')
})
it('should respect provided expandedKeys', async () => {
const wrapper = mountComponent(createMockRoot(), ['folder-audio'])
await nextTick()
const roots = wrapper.findAll('.collapsible-root')
expect(roots[0].attributes('data-state')).toBe('closed')
expect(roots[1].attributes('data-state')).toBe('closed')
expect(roots[2].attributes('data-state')).toBe('open')
})
it('should expand all provided keys', async () => {
const wrapper = mountComponent(createMockRoot(), [
'folder-images',
'folder-video',
'folder-audio'
])
await nextTick()
const roots = wrapper.findAll('.collapsible-root')
expect(roots[0].attributes('data-state')).toBe('open')
expect(roots[1].attributes('data-state')).toBe('open')
expect(roots[2].attributes('data-state')).toBe('open')
})
})
describe('with single folder', () => {
it('should expand only one folder when there is only one', async () => {
const root: RenderedTreeExplorerNode<ComfyNodeDefImpl> = {
key: 'root',
label: 'Root',
icon: '',
type: 'folder',
totalLeaves: 2,
children: [
createMockFolder('images', [
createMockNode('LoadImage'),
createMockNode('SaveImage')
])
]
}
const wrapper = mountComponent(root, [])
await nextTick()
await flushPromises()
await nextTick()
const roots = wrapper.findAll('.collapsible-root')
expect(roots).toHaveLength(1)
expect(roots[0].attributes('data-state')).toBe('open')
})
})
describe('node cards', () => {
it('should render node cards for each node in expanded folders', () => {
const wrapper = mountComponent(createMockRoot(), ['folder-images'])
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
expect(cards.length).toBeGreaterThanOrEqual(2)
})
})
})

View File

@@ -0,0 +1,109 @@
<template>
<TabsContent value="essentials" class="flex-1 overflow-y-auto px-3 h-full">
<div class="flex flex-col gap-2 pb-6">
<CollapsibleRoot
v-for="folder in folders"
:key="folder.key"
class="rounded-lg"
:open="expandedKeys.includes(folder.key)"
@update:open="toggleFolder(folder.key, $event)"
>
<CollapsibleTrigger
class="group flex w-full cursor-pointer items-center justify-between border-0 bg-transparent py-3 px-1 text-xs font-medium tracking-wide text-muted-foreground h-8 box-content"
>
<span class="uppercase">{{ folder.label }}</span>
<i
:class="
cn(
'icon-[lucide--chevron-up] size-4 transition-transform duration-200',
!expandedKeys.includes(folder.key) && '-rotate-180'
)
"
/>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3"
>
<EssentialNodeCard
v-for="node in folder.children"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
TabsContent
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
import EssentialNodeCard from './EssentialNodeCard.vue'
const props = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
const emit = defineEmits<{
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
function flattenLeaves(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl>[] {
if (node.type === 'node') return [node]
return node.children?.flatMap(flattenLeaves) ?? []
}
const folders = computed(() => {
const topFolders =
(props.root.children?.filter(
(child) => child.type === 'folder'
) as RenderedTreeExplorerNode<ComfyNodeDefImpl>[]) ?? []
return topFolders.map((folder) => ({
...folder,
children: flattenLeaves(folder)
}))
})
function toggleFolder(key: string, open: boolean) {
if (open) {
expandedKeys.value = [...expandedKeys.value, key]
} else {
expandedKeys.value = expandedKeys.value.filter((k) => k !== key)
}
}
const hasAutoExpanded = ref(false)
watch(
folders,
(value) => {
if (!hasAutoExpanded.value && value.length > 0) {
hasAutoExpanded.value = true
if (expandedKeys.value.length === 0) {
expandedKeys.value = value.map((folder) => folder.key)
}
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,69 @@
<template>
<Teleport to="body">
<div
v-if="isDragging && draggedNode && showPreview"
class="pointer-events-none fixed z-[10000]"
:style="{
left: `${previewPosition.x + 12}px`,
top: `${previewPosition.y + 12}px`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview :node-def="draggedNode" position="relative" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const {
isDragging,
draggedNode,
cursorPosition,
dragMode,
setupGlobalListeners,
cleanupGlobalListeners
} = useNodeDragToCanvas()
const nativeDragPosition = ref({ x: 0, y: 0 })
const previewPosition = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value
}
return cursorPosition.value
})
const showPreview = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
}
return true
})
function handleDrag(e: DragEvent) {
if (e.clientX === 0 && e.clientY === 0) return
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function handleDragEnd() {
nativeDragPosition.value = { x: 0, y: 0 }
}
onMounted(() => {
setupGlobalListeners()
document.addEventListener('drag', handleDrag)
document.addEventListener('dragend', handleDragEnd)
})
onUnmounted(() => {
cleanupGlobalListeners()
document.removeEventListener('drag', handleDrag)
document.removeEventListener('dragend', handleDragEnd)
})
</script>