Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
fd31f9d
additional node search updates
pythongosssss Mar 10, 2026
0f3b2e0
fix test
pythongosssss Mar 10, 2026
3cba424
additional search feedback
pythongosssss Mar 12, 2026
d18243e
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 12, 2026
08666d8
rabbit
pythongosssss Mar 12, 2026
0772f2a
- make test more specific
pythongosssss Mar 12, 2026
12fd098
hide extensions/custom categories when no custom nodes
pythongosssss Mar 12, 2026
8a30211
- dont clear category + allow category searching
pythongosssss Mar 16, 2026
320cd82
rename custom to extensions
pythongosssss Mar 16, 2026
d30bb01
fix: prevent other nodes intercepting ghost node capture
pythongosssss Mar 16, 2026
d49f263
add e2e test for moving ghost
pythongosssss Mar 16, 2026
82e6269
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 16, 2026
6689510
fix
pythongosssss Mar 16, 2026
dbb7032
fix bad merge
pythongosssss Mar 16, 2026
cf4dfce
- fix dialog stealing focus
pythongosssss Mar 16, 2026
2f1615c
fix test
pythongosssss Mar 16, 2026
04c00aa
remove left categories and add as filter buttons
pythongosssss Mar 16, 2026
9100058
fix dialog height
pythongosssss Mar 17, 2026
bbd1e60
cap description size
pythongosssss Mar 17, 2026
307a1c7
ensure canvas gets focus after ghost placement
pythongosssss Mar 17, 2026
98eac41
fix highlighting cross word and remove padding
pythongosssss Mar 17, 2026
bd66617
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 18, 2026
b6234b9
rework expand/collapse, prevent requiring double left arrow to collapse
pythongosssss Mar 18, 2026
2a531ff
fix test
pythongosssss Mar 18, 2026
da2fede
fix incorrectly collapsing parent category to root
pythongosssss Mar 18, 2026
8e5dc15
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 19, 2026
c46316d
feedback
pythongosssss Mar 20, 2026
f82f862
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 20, 2026
92e65aa
remove dead code
pythongosssss Mar 23, 2026
cc3d3f1
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 24, 2026
11b62c4
fix
pythongosssss Mar 24, 2026
8f41bc7
update font size
pythongosssss Mar 24, 2026
c00e285
fix tests
pythongosssss Mar 25, 2026
af77920
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Mar 27, 2026
5c3de00
fix pr comments
pythongosssss Apr 7, 2026
3786a46
Merge branch 'main' into pysssss/node-search-feedback
pythongosssss Apr 7, 2026
4f97a90
update buttons
pythongosssss Apr 7, 2026
833a2f5
fix expand/collapse
pythongosssss Apr 7, 2026
dce8b87
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Apr 13, 2026
5796c6d
[automated] Apply ESLint and Oxfmt fixes
actions-user Apr 14, 2026
e405930
expand and simplify tests
pythongosssss Apr 15, 2026
e181339
fix showing empty categories with trailing or double slashes
pythongosssss Mar 25, 2026
955427b
fix including non-core essential nodes as "comfy"
pythongosssss Mar 25, 2026
9363d31
support adding subgraphs as ghost
pythongosssss Mar 25, 2026
0da4362
fix: show Comfy logo for core nodes even when essentials_category is set
pythongosssss Apr 16, 2026
702f47f
[automated] Apply ESLint and Oxfmt fixes
actions-user Apr 16, 2026
e8c7fc0
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Apr 20, 2026
c300f20
Merge remote-tracking branch 'origin/main' into pysssss/node-search-f…
pythongosssss Apr 23, 2026
225ac3e
fix: extend source badge to essentials/blueprints; restrict highlight…
pythongosssss Apr 23, 2026
63e1faa
[automated] Apply ESLint and Oxfmt fixes
actions-user Apr 23, 2026
76b14dc
Merge branch 'main' into pysssss/node-search-feedback
pythongosssss Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import type { ComfyPage } from '../ComfyPage'
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator

constructor(readonly page: Page) {
this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
}
Expand Down
40 changes: 40 additions & 0 deletions browser_tests/fixtures/helpers/NodeOperationsHelper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Locator } from '@playwright/test'

import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
Expand Down Expand Up @@ -33,6 +34,45 @@ export class NodeOperationsHelper {
})
}

async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}

/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
})
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}

async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}
Expand Down
63 changes: 53 additions & 10 deletions browser_tests/tests/nodeGhostPlacement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()

const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()

return { nodeId, centerX, centerY }
return { nodeId: nodeRef.id, centerX, centerY }
}

function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
Expand Down Expand Up @@ -82,7 +78,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()

expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
Expand Down Expand Up @@ -158,5 +153,53 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})

test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)

// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()

const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()

// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()

// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()

// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)

// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)

// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
})
}
8 changes: 5 additions & 3 deletions browser_tests/tests/nodeSearchBoxV2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})

test.describe('Category navigation', () => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
Expand All @@ -64,7 +66,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()

await searchBoxV2.categoryButton('favorites').click()
await searchBoxV2.filterBarButton('Bookmarked').click()

await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
Expand Down Expand Up @@ -100,7 +102,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.filterOptions.first()).toBeVisible()

// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
Expand Down
2 changes: 1 addition & 1 deletion browser_tests/tests/nodeSearchBoxV2Extended.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()
Expand Down
2 changes: 0 additions & 2 deletions packages/design-system/src/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -619,8 +619,6 @@
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}

@utility scrollbar-hide {
Expand Down
7 changes: 7 additions & 0 deletions packages/shared-frontend-utils/src/formatUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ describe('formatUtil', () => {
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
})

it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
})
})

describe('getFilenameDetails', () => {
Expand Down
12 changes: 8 additions & 4 deletions packages/shared-frontend-utils/src/formatUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,14 @@ export function highlightQuery(
text = DOMPurify.sanitize(text)
}

// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

const regex = new RegExp(`(${escapedQuery})`, 'gi')
// Escape special regex characters, then join with optional
// whitespace so cross-word matches (e.g. "geto" → "imaGE TO") are
// highlighted correctly.
const pattern = Array.from(query)
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s*')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: (non-blocking) \s* between every character means a query like "hi" could match across arbitrary whitespace, e.g. "t h i n g". Would \s? (at most one whitespace char) reduce false positives while still supporting the cross-word case?


const regex = new RegExp(`(${pattern})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

Expand Down
10 changes: 9 additions & 1 deletion packages/tailwind-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { clsx } from 'clsx'
import type { ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { extendTailwindMerge } from 'tailwind-merge'

export type { ClassValue } from 'clsx'

const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': ['text-xxs', 'text-xxxs']
}
}
})

export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}
6 changes: 1 addition & 5 deletions src/components/input/MultiSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,7 @@
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
{{ $t('g.itemSelected', { count: selectedCount }, selectedCount) }}
</span>
<Button
v-if="showClearButton"
Expand Down
28 changes: 28 additions & 0 deletions src/components/node/CreditBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<span
:class="
cn(
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use font size instead of h-full?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was taken directly from src/renderer/extensions/vueNodes/components/NodeHeader.vue, i'd rather not change it in case there is some issue with it rendering there

<span class="truncate" v-text="text" />
</span>
<span
v-if="rest"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
Comment thread
pythongosssss marked this conversation as resolved.
</span>
</template>

<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'

defineProps<{
text: string
rest?: string
}>()
</script>
28 changes: 18 additions & 10 deletions src/components/node/NodePreviewCard.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${scaleFactor})` }"
>
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
Expand All @@ -18,21 +23,21 @@
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="-mt-1 text-xs text-muted-foreground"
class="-mt-1 truncate text-xs text-muted-foreground"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
{{ nodeDef.category.replaceAll('/', ' / ') }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better to use computed!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

</p>

<!-- Badges -->
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>

<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
class="m-0 max-h-[30vh] overflow-y-auto text-[11px] leading-normal font-normal text-muted-foreground"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use text-xs instead of using text-[11px]?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not aware of the specifics of this, but since this was recently redesigned i'd imagine this was a specific part of the design

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We’ve defined all text sizes using size tokens, so for things like color and size we typically rely on what’s already defined in Tailwind. Would it be okay to just use text-sm here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say we should go with what product have said to use, but as stated, I am not aware of the specific for why 11px was chosen and it is a pre-existing styling choice for this unique component.

824f8bb#diff-e708b804b619de1b1aba97d98a71dad92af0d07e7b6b646b8bf5aaf4bb99c013
@Yourz it looks like you updated it to use 11px while implementing the component, was this due to a request from product/design, is it fine to change it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah. this is based on the design feedback from julien

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2026-03-23 at 9 44 09 AM

The description text of the node preview right? To keep things more consistent, I'd push for text-xs, but I'm not aware if Julien had any concerns about the text being too large when it was 12pt.

I tried text-xs just now and it looks alright to me

>
{{ nodeDef.description }}
</p>
Expand Down Expand Up @@ -99,17 +104,20 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

const SCALE_FACTOR = 0.5
const BASE_WIDTH_PX = 200
const BASE_SCALE = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24

const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false
showCategoryPath = false,
scaleFactor = 0.5
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
scaleFactor?: number
}>()

const previewContainerRef = ref<HTMLElement>()
Expand All @@ -118,7 +126,7 @@ const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
const scaledHeight = entry.contentRect.height * scaleFactor
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
Comment thread
pythongosssss marked this conversation as resolved.
})
Expand Down
13 changes: 4 additions & 9 deletions src/components/node/NodePricingBadge.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
<template>
<BadgePill
v-if="nodeDef.api_node"
v-show="priceLabel"
:text="priceLabel"
icon="icon-[comfy--credits]"
border-style="#f59e0b"
filled
/>
<span v-if="nodeDef.api_node && priceLabel">
<CreditBadge :text="priceLabel" />
</span>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'

import BadgePill from '@/components/common/BadgePill.vue'
import CreditBadge from '@/components/node/CreditBadge.vue'
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

Expand Down
Loading
Loading