mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 08:08:04 +00:00
Compare commits
14 Commits
fix/websit
...
jaeone/ref
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93277e5f13 | ||
|
|
df0fe84343 | ||
|
|
39d7f7bb7f | ||
|
|
2db7b477b9 | ||
|
|
8264fb0603 | ||
|
|
40eac63410 | ||
|
|
ae755a2276 | ||
|
|
35dc4eff83 | ||
|
|
8e90b33fd3 | ||
|
|
95a7e43f61 | ||
|
|
237432ef26 | ||
|
|
b8d643ca82 | ||
|
|
f088382e3d | ||
|
|
49d8dc47f3 |
@@ -110,7 +110,8 @@ export const TestIds = {
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
errorsTab: 'panel-tab-errors',
|
||||
selectionContextStrip: 'selection-context-strip'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
|
||||
@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
test('Selecting a node keeps all errors visible and shows selection context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -301,14 +301,25 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
strip,
|
||||
'The strip count is scoped to the selection, diverging from the global reference badge'
|
||||
).toContainText('1 error')
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(
|
||||
strip,
|
||||
'Deselecting swaps the always-visible strip back to the summary'
|
||||
).toContainText('2 nodes — 1 error')
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
@@ -381,7 +392,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
test('Selecting a node keeps all media rows visible and shows selection context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
@@ -403,13 +414,66 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
// Selection no longer filters the list — rows stay global and the
|
||||
// selection is surfaced via the context strip instead.
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(strip).toContainText('1 error')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Deselecting swaps the always-visible strip back to the summary
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection emphasis', () => {
|
||||
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_and_media'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
const mediaRow = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
|
||||
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
// The node sits near the canvas top where overlays intercept clicks
|
||||
await mediaNode.centerOnNode()
|
||||
await mediaNode.click('title')
|
||||
|
||||
// The unrelated missing-node group auto-collapses while the matched
|
||||
// media row stays visible and is marked as part of the selection
|
||||
await expect(missingNodeCard).toBeHidden()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('1 error')
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Emphasis ends: the collapsed group re-expands and the strip
|
||||
// returns to the workflow summary
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
239
src/components/rightSidePanel/errors/ErrorGroupList.test.ts
Normal file
239
src/components/rightSidePanel/errors/ErrorGroupList.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { TestingPinia } from '@pinia/testing'
|
||||
import { render, screen, waitFor, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
|
||||
import ErrorGroupList from './ErrorGroupList.vue'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
getExecutionIdByNode: vi.fn(),
|
||||
getRootParentNode: vi.fn(() => null),
|
||||
forEachNode: vi.fn(),
|
||||
mapAllNodes: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: vi.fn(() => ({
|
||||
copyToClipboard: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
fileSize: null,
|
||||
gatedRepoUrl: null
|
||||
}),
|
||||
isModelDownloadable: vi.fn(() => true),
|
||||
toBrowsableUrl: vi.fn((url: string) => url)
|
||||
}))
|
||||
|
||||
const SAMPLER_NODE = { id: '1', title: 'SamplerNode' }
|
||||
const LOADER_NODE = { id: '2', title: 'LoaderNode' }
|
||||
|
||||
function seedTwoErrorGroups(pinia: TestingPinia) {
|
||||
const executionErrorStore = useExecutionErrorStore(pinia)
|
||||
executionErrorStore.lastNodeErrors = fromAny<
|
||||
typeof executionErrorStore.lastNodeErrors,
|
||||
unknown
|
||||
>({
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'clip' }
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'weird_error', message: 'Something odd happened', details: '' }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(pinia: TestingPinia) {
|
||||
const user = userEvent.setup()
|
||||
render(ErrorGroupList, {
|
||||
global: {
|
||||
plugins: [PrimeVue, testI18n, pinia],
|
||||
stubs: {
|
||||
AsyncSearchInput: {
|
||||
template: '<input />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
function createPinia() {
|
||||
return createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
}
|
||||
|
||||
function getSectionByTitle(title: string) {
|
||||
const sections = screen.getAllByTestId('error-group-execution')
|
||||
const section = sections.find((s) => within(s).queryByText(title))
|
||||
expect(section).toBeDefined()
|
||||
return section!
|
||||
}
|
||||
|
||||
function isSectionExpanded(section: HTMLElement) {
|
||||
const [header] = within(section).getAllByRole('button', { hidden: true })
|
||||
return header.getAttribute('aria-expanded') === 'true'
|
||||
}
|
||||
|
||||
describe('ErrorGroupList selection emphasis', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>(
|
||||
String(nodeId) === '1' ? SAMPLER_NODE : LOADER_NODE
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('expands matched groups, collapses others, and restores on deselect', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const samplerSection = getSectionByTitle('Missing connection')
|
||||
const loaderSection = getSectionByTitle('Validation failed')
|
||||
expect(isSectionExpanded(samplerSection)).toBe(true)
|
||||
expect(isSectionExpanded(loaderSection)).toBe(true)
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE])
|
||||
await waitFor(() => {
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
})
|
||||
expect(isSectionExpanded(samplerSection)).toBe(true)
|
||||
|
||||
canvasStore.selectedItems = []
|
||||
await waitFor(() => {
|
||||
expect(isSectionExpanded(loaderSection)).toBe(true)
|
||||
})
|
||||
expect(isSectionExpanded(samplerSection)).toBe(true)
|
||||
})
|
||||
|
||||
it('expands only matched groups for a selection that predates mount', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE])
|
||||
|
||||
renderList(pinia)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(isSectionExpanded(getSectionByTitle('Validation failed'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves manual collapse state alone for selections without errors', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
const { user } = renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const loaderSection = getSectionByTitle('Validation failed')
|
||||
const [loaderHeader] = within(loaderSection).getAllByRole('button')
|
||||
await user.click(loaderHeader)
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '99', title: 'Unrelated' }])
|
||||
await waitFor(() => {
|
||||
// No emphasis: the strip falls back to the workflow summary
|
||||
expect(screen.getByTestId('selection-context-strip')).toHaveTextContent(
|
||||
'2 nodes — 2 errors'
|
||||
)
|
||||
})
|
||||
expect(isSectionExpanded(loaderSection)).toBe(false)
|
||||
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('always shows the strip: workflow summary by default, selection while emphasized', async () => {
|
||||
const pinia = createPinia()
|
||||
seedTwoErrorGroups(pinia)
|
||||
renderList(pinia)
|
||||
const canvasStore = useCanvasStore(pinia)
|
||||
|
||||
const strip = screen.getByTestId('selection-context-strip')
|
||||
expect(strip).toHaveTextContent('2 nodes — 2 errors')
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE])
|
||||
await waitFor(() => {
|
||||
expect(strip).toHaveTextContent('SamplerNode — 1 error')
|
||||
})
|
||||
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([SAMPLER_NODE, LOADER_NODE])
|
||||
await waitFor(() => {
|
||||
expect(strip).toHaveTextContent('2 nodes selected — 2 errors')
|
||||
})
|
||||
|
||||
canvasStore.selectedItems = []
|
||||
await waitFor(() => {
|
||||
expect(strip).toHaveTextContent('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
})
|
||||
609
src/components/rightSidePanel/errors/ErrorGroupList.vue
Normal file
609
src/components/rightSidePanel/errors/ErrorGroupList.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<!-- Search bar + collapse toggle -->
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && allErrorGroups.length > 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
role="status"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context strip: workflow summary, or the selection's errors -->
|
||||
<div
|
||||
data-testid="selection-context-strip"
|
||||
role="status"
|
||||
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
|
||||
>
|
||||
<i18n-t
|
||||
:keypath="strip.keypath"
|
||||
:plural="strip.count"
|
||||
tag="span"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0 flex-1 truncate text-xs font-semibold transition-colors duration-200',
|
||||
hasSelectionEmphasis
|
||||
? 'text-primary-background-hover'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #node>{{ selectionStripNodeLabel }}</template>
|
||||
<template #nodes>{{ strip.nodes }}</template>
|
||||
<template #count>{{ strip.count }}</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
:aria-current="
|
||||
isCardInSelection(item.cardId) ? 'true' : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0',
|
||||
selectionEmphasisClass(isCardInSelection(item.cardId))
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.label
|
||||
})
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:aria-current="isCardInSelection(card.id) ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
selectionEmphasisClass(isCardInSelection(card.id)),
|
||||
isCardInSelection(card.id) && '-my-1 py-1'
|
||||
)
|
||||
"
|
||||
@locate-node="handleLocateNode"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { selectionEmphasisClass } from './selectionEmphasis'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
cardId: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
cardId: card.id,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups,
|
||||
hasSelection,
|
||||
selectedNodeCount,
|
||||
selectedNodeTitle,
|
||||
selectionMatchedGroupKeys,
|
||||
selectionMatchedCardIds,
|
||||
selectionMatchedAssetNodeIds,
|
||||
selectionErrorCount,
|
||||
errorNodeCount
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const hasSelectionEmphasis = computed(
|
||||
() => hasSelection.value && selectionErrorCount.value > 0
|
||||
)
|
||||
const selectionStripNodeLabel = computed(
|
||||
() => selectedNodeTitle.value ?? t('g.untitled')
|
||||
)
|
||||
|
||||
// The strip is a status line, not a view of the current filter — summary
|
||||
// numbers are workflow-wide, never search-filtered.
|
||||
const workflowErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const strip = computed(() => {
|
||||
if (hasSelectionEmphasis.value) {
|
||||
return {
|
||||
keypath:
|
||||
selectedNodeCount.value === 1
|
||||
? 'rightSidePanel.selectedNodeErrors'
|
||||
: 'rightSidePanel.selectedNodesErrors',
|
||||
nodes: selectedNodeCount.value,
|
||||
count: selectionErrorCount.value
|
||||
}
|
||||
}
|
||||
return {
|
||||
keypath:
|
||||
errorNodeCount.value === 0
|
||||
? // Node-less errors (e.g. prompt-level) would read as "0 nodes"
|
||||
'rightSidePanel.errorsSummary'
|
||||
: errorNodeCount.value === 1
|
||||
? 'rightSidePanel.errorNodeSummary'
|
||||
: 'rightSidePanel.errorNodesSummary',
|
||||
nodes: errorNodeCount.value,
|
||||
count: workflowErrorCount.value
|
||||
}
|
||||
})
|
||||
|
||||
function isCardInSelection(cardId: string): boolean {
|
||||
return selectionMatchedCardIds.value.has(cardId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes the Set-valued computed (fresh reference per recompute) so the
|
||||
* emphasis watcher below only fires when the matched membership changes.
|
||||
*/
|
||||
const selectionEmphasisSignature = computed(() =>
|
||||
hasSelection.value
|
||||
? Array.from(selectionMatchedGroupKeys.value).sort().join('\n')
|
||||
: ''
|
||||
)
|
||||
|
||||
/**
|
||||
* Selection acts as emphasis, not a filter: expand the groups containing
|
||||
* the selected nodes' errors and collapse the rest. When the emphasis ends
|
||||
* (selection cleared or moved to a node without errors), re-expand all
|
||||
* groups so the tab reads as the workflow overview again.
|
||||
*/
|
||||
watch(
|
||||
selectionEmphasisSignature,
|
||||
(signature, previousSignature) => {
|
||||
if (!signature) {
|
||||
if (!previousSignature) return
|
||||
for (const groupKey of Object.keys(collapseState)) {
|
||||
setSectionCollapsed(groupKey, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchedKeys = selectionMatchedGroupKeys.value
|
||||
for (const group of allErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const group of allErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function isSectionCollapsed(groupKey: string): boolean {
|
||||
// Defaults to expanded when not explicitly set by the user
|
||||
return collapseState[groupKey] ?? false
|
||||
}
|
||||
|
||||
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
|
||||
collapseState[groupKey] = collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.groupKey, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateAssetNode(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
||||
} else {
|
||||
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
replaceGroup(group)
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
>
|
||||
<div v-if="card.nodeId" class="flex min-h-8 flex-wrap items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
@@ -103,7 +100,7 @@
|
||||
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
|
||||
v-if="error.isRuntimeError && runtimeDetailsExpanded"
|
||||
:id="getRuntimeDetailsId(idx)"
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
@@ -186,9 +183,8 @@ import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const { card, compact = false } = defineProps<{
|
||||
const { card } = defineProps<{
|
||||
card: ErrorCardData
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -203,9 +199,6 @@ const runtimeDetailsExpanded = ref(true)
|
||||
const hasRuntimeError = computed(() =>
|
||||
card.errors.some((error) => error.isRuntimeError)
|
||||
)
|
||||
const isRuntimeDisclosureExpanded = computed(
|
||||
() => compact || runtimeDetailsExpanded.value
|
||||
)
|
||||
const runtimeDetailsControlIds = computed(() =>
|
||||
card.errors
|
||||
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
|
||||
|
||||
@@ -56,12 +56,15 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<div class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
:group="group"
|
||||
:show-info-button="showInfoButton"
|
||||
:highlighted="
|
||||
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
|
||||
"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
@open-manager-info="emit('openManagerInfo', $event)"
|
||||
/>
|
||||
@@ -106,10 +109,13 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
|
||||
|
||||
const { showInfoButton, missingPackGroups } = defineProps<{
|
||||
showInfoButton: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
/** Execution node ids to emphasize (current canvas selection). */
|
||||
highlightedNodeIds?: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<div
|
||||
:aria-current="highlighted ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-8 items-center gap-1',
|
||||
selectionEmphasisClass(highlighted)
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="missing-node-pack-expand"
|
||||
@@ -216,6 +224,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { selectionEmphasisClass } from './selectionEmphasis'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
@@ -227,9 +237,11 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group, showInfoButton } = defineProps<{
|
||||
const { group, showInfoButton, highlighted } = defineProps<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
/** Emphasize the header row (pack containing the canvas selection). */
|
||||
highlighted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,275 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-col">
|
||||
<!-- Search bar + collapse toggle -->
|
||||
<div
|
||||
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
||||
>
|
||||
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
||||
<CollapseToggleButton
|
||||
v-model="isAllCollapsed"
|
||||
:show="!isSearching && tabErrorGroups.length > 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
missingNodePacks.length > 0 &&
|
||||
shouldShowInstallButton
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
||||
{{
|
||||
isInstallingAll
|
||||
? t('rightSidePanel.missingNodePacks.installing')
|
||||
: t('rightSidePanel.missingNodePacks.installAll')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-tooltip.top="
|
||||
t(
|
||||
'nodeReplacement.replaceAllWarning',
|
||||
'Replaces all available nodes in this group.'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'missing_model' &&
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorGroupList class="min-h-0 flex-1" />
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
@@ -308,44 +39,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
import ErrorGroupList from './ErrorGroupList.vue'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
|
||||
const ErrorPanelSurveyCta =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
@@ -355,171 +56,5 @@ const ErrorPanelSurveyCta =
|
||||
: undefined
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
filteredMissingModelGroups: missingModelGroups,
|
||||
filteredMissingMediaGroups: missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const group of tabErrorGroups.value) {
|
||||
setSectionCollapsed(group.groupKey, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function isSectionCollapsed(groupKey: string): boolean {
|
||||
// Defaults to expanded when not explicitly set by the user
|
||||
return collapseState[groupKey] ?? false
|
||||
}
|
||||
|
||||
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
|
||||
collapseState[groupKey] = collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type !== 'execution') continue
|
||||
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
setSectionCollapsed(group.groupKey, !hasMatch)
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateAssetNode(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
||||
} else {
|
||||
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
replaceGroup(group)
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
30
src/components/rightSidePanel/errors/selectionEmphasis.ts
Normal file
30
src/components/rightSidePanel/errors/selectionEmphasis.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
// The negative margin and matching padding cancel out, so the background
|
||||
// bleeds 6px past the content without shifting the text.
|
||||
const EMPHASIS_CLASS = 'rounded-sm bg-blue-selection -mx-1.5 px-1.5'
|
||||
|
||||
// Present even when unhighlighted so the emphasis animates both ways.
|
||||
const TRANSITION_CLASS =
|
||||
'transition-[background-color,margin,padding,border-radius] duration-200'
|
||||
|
||||
/** Classes emphasizing rows/cards that belong to the canvas selection. */
|
||||
export function selectionEmphasisClass(highlighted: boolean | undefined) {
|
||||
return cn(TRANSITION_CLASS, highlighted && EMPHASIS_CLASS)
|
||||
}
|
||||
|
||||
/** True when any node type resolves to a node in the given id set. */
|
||||
export function someNodeTypeInSelection(
|
||||
nodeTypes: MissingNodeType[],
|
||||
nodeIds: Set<string> | undefined
|
||||
): boolean {
|
||||
if (!nodeIds?.size) return false
|
||||
return nodeTypes.some(
|
||||
(nodeType) =>
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null &&
|
||||
nodeIds.has(String(nodeType.nodeId))
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -126,6 +127,12 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
getExecutionIdByNode,
|
||||
getNodeByExecutionId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
|
||||
@@ -205,6 +212,7 @@ describe('useErrorGroups', () => {
|
||||
setActivePinia(createPinia())
|
||||
mockIsCloud.value = false
|
||||
vi.mocked(isLGraphNode).mockReturnValue(false)
|
||||
vi.mocked(getNodeByExecutionId).mockReset()
|
||||
})
|
||||
|
||||
describe('missingPackGroups', () => {
|
||||
@@ -986,24 +994,13 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('unfiltered vs selection-filtered model/media groups', () => {
|
||||
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups).toBeDefined()
|
||||
expect(groups.filteredMissingModelGroups).toBeDefined()
|
||||
expect(groups.missingMediaGroups).toBeDefined()
|
||||
expect(groups.filteredMissingMediaGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
|
||||
describe('selection does not shrink displayed groups', () => {
|
||||
it('missingModelGroups returns total candidates regardless of selection', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
||||
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
||||
])
|
||||
// Simulate canvas selection of a single node so the filtered
|
||||
// variant actually narrows. Without this, both sides return the
|
||||
// same value trivially and the test can't prove the contract.
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = fromAny<
|
||||
@@ -1012,23 +1009,18 @@ describe('useErrorGroups', () => {
|
||||
>([{ id: '1' }])
|
||||
await nextTick()
|
||||
|
||||
// Unfiltered total stays at one group of two models regardless of
|
||||
// the selection — ErrorOverlay reads this for the overlay label
|
||||
// and must not shrink with canvas selection.
|
||||
// Displayed groups never shrink with canvas selection — the count
|
||||
// and list always describe the whole workflow.
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
||||
|
||||
// Filtered variant does narrow under the same selection state —
|
||||
// this is how the errors tab scopes cards to the selected node.
|
||||
// Exact filtered output depends on the app.rootGraph lookup
|
||||
// (mocked to return undefined here); what matters is that the
|
||||
// filtered shape is a different reference and does not blindly
|
||||
// mirror the unfiltered one.
|
||||
expect(groups.filteredMissingModelGroups.value).not.toBe(
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
expect(
|
||||
groups.filteredGroups.value.find((g) => g.type === 'missing_model')
|
||||
?.count
|
||||
).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing media counting', () => {
|
||||
it('counts missing media by affected node rows, not grouped filenames', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingMedia([
|
||||
@@ -1051,8 +1043,8 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabErrorGroups', () => {
|
||||
it('filters prompt error when a node is selected', async () => {
|
||||
describe('selection emphasis', () => {
|
||||
it('never marks workflow-level prompt errors as matched by a selection', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
@@ -1067,11 +1059,205 @@ describe('useErrorGroups', () => {
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.tabErrorGroups.value.find(
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeUndefined()
|
||||
expect(promptGroup).toBeDefined()
|
||||
expect(
|
||||
groups.selectionMatchedGroupKeys.value.has(promptGroup!.groupKey)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no selection state when nothing is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.hasSelection.value).toBe(false)
|
||||
expect(groups.selectionMatchedGroupKeys.value.size).toBe(0)
|
||||
expect(groups.selectionMatchedCardIds.value.size).toBe(0)
|
||||
expect(groups.selectionErrorCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('matches groups and cards of the selected error node', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const selectedNode = { id: '1' }
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>(
|
||||
String(nodeId) === '1' ? selectedNode : { id: String(nodeId) }
|
||||
)
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([selectedNode])
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'file_not_found', message: 'File not found', details: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.hasSelection.value).toBe(true)
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-1')).toBe(true)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-2')).toBe(false)
|
||||
expect(groups.selectionMatchedAssetNodeIds.value.size).toBe(0)
|
||||
// Both error groups remain displayed regardless of the selection
|
||||
const executionGroups = groups.filteredGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
const displayedCardIds = executionGroups.flatMap((g) =>
|
||||
g.type === 'execution' ? g.cards.map((c) => c.id) : []
|
||||
)
|
||||
expect(displayedCardIds).toContain('node-1')
|
||||
expect(displayedCardIds).toContain('node-2')
|
||||
})
|
||||
|
||||
it('narrows missing-node emphasis to packs containing the selected node', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '2' }])
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
||||
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
// Emphasis counts only the packs containing the selected node…
|
||||
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
|
||||
true
|
||||
)
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
// …and marks only the selected node for row highlighting.
|
||||
expect(groups.selectionMatchedAssetNodeIds.value.has('2')).toBe(true)
|
||||
expect(groups.selectionMatchedAssetNodeIds.value.has('3')).toBe(false)
|
||||
// Display still shows every pack.
|
||||
const missingNodeGroup = groups.filteredGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingNodeGroup?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('does not emphasize missing-node groups for unrelated selections', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '99' }])
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
|
||||
false
|
||||
)
|
||||
expect(groups.selectionErrorCount.value).toBe(0)
|
||||
// Display is unaffected by the unrelated selection.
|
||||
expect(
|
||||
groups.filteredGroups.value.find((g) => g.type === 'missing_node')
|
||||
?.count
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('matches errors through graph resolution, not raw execution ids', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
// The error is keyed by a subgraph execution id ('2:5') that resolves
|
||||
// to a different graph node id ('7') at the current graph level.
|
||||
const selectedNode = { id: '7' }
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>(
|
||||
String(nodeId) === '2:5' ? selectedNode : undefined
|
||||
)
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([selectedNode])
|
||||
store.lastNodeErrors = {
|
||||
'2:5': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches interior errors when a subgraph container is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
// A container selection matches interior errors by execution-id prefix,
|
||||
// even when the interior node does not resolve at the current level.
|
||||
const containerNode = fromAny<SubgraphNode, unknown>(
|
||||
Object.assign(Object.create(SubgraphNode.prototype), { id: '2' })
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(
|
||||
fromAny<NodeExecutionId, unknown>('2')
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([containerNode])
|
||||
store.lastNodeErrors = {
|
||||
'2:5': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
||||
},
|
||||
'9': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'file_not_found', message: 'File not found', details: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
|
||||
expect(groups.selectionMatchedCardIds.value.has('node-9')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import { shouldRenderExecutionItemList } from './executionItemList'
|
||||
import { someNodeTypeInSelection } from './selectionEmphasis'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
@@ -259,12 +260,25 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerExecutionIds.size === 0
|
||||
const hasSelection = computed(() => selectedNodeInfo.value.nodeIds !== null)
|
||||
|
||||
const selectedNodeCount = computed(
|
||||
() => selectedNodeInfo.value.nodeIds?.size ?? 0
|
||||
)
|
||||
|
||||
const selectedNodeTitle = computed(() => {
|
||||
if (selectedNodeCount.value !== 1) return null
|
||||
const node = canvasStore.selectedItems.find(isLGraphNode)
|
||||
if (!node) return null
|
||||
return (
|
||||
resolveNodeDisplayName(node, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}) || null
|
||||
)
|
||||
})
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionErrorStore.allErrorExecutionIds) {
|
||||
@@ -581,38 +595,50 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
|
||||
})
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
/**
|
||||
* Builds ErrorGroups from missingNodesError. Returns [] when none present.
|
||||
* `includeGroup` narrows which swap/pack groups are counted (used to scope
|
||||
* emphasis to the canvas selection); groups reduced to zero are omitted.
|
||||
*/
|
||||
function buildMissingNodeGroups(
|
||||
includeGroup: (nodeTypes: MissingNodeType[]) => boolean = () => true
|
||||
): ErrorGroup[] {
|
||||
const error = missingNodesStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
const groups: ErrorGroup[] = []
|
||||
const swapCount = swapNodeGroups.value.filter((group) =>
|
||||
includeGroup(group.nodeTypes)
|
||||
).length
|
||||
const packCount = missingPackGroups.value.filter((group) =>
|
||||
includeGroup(group.nodeTypes)
|
||||
).length
|
||||
|
||||
if (swapNodeGroups.value.length > 0) {
|
||||
if (swapCount > 0) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
groupKey: 'swap_nodes',
|
||||
count: swapNodeGroups.value.length,
|
||||
count: swapCount,
|
||||
priority: 0,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
|
||||
count: swapNodeGroups.value.length,
|
||||
nodeTypes: error.nodeTypes,
|
||||
count: swapCount,
|
||||
isCloud
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (missingPackGroups.value.length > 0) {
|
||||
if (packCount > 0) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
groupKey: 'missing_node',
|
||||
count: missingPackGroups.value.length,
|
||||
count: packCount,
|
||||
priority: 1,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: error.nodeTypes,
|
||||
count: missingPackGroups.value.length,
|
||||
count: packCount,
|
||||
isCloud
|
||||
})
|
||||
})
|
||||
@@ -699,31 +725,33 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
/** Model groups narrowed to the selection, for emphasis derivation only. */
|
||||
const missingModelGroupsForSelection = computed(() => {
|
||||
if (!hasSelection.value) return []
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
const matched = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
if (!matched.length) return []
|
||||
return groupMissingModelCandidates(matched, isCloud)
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
|
||||
/** Media groups narrowed to the selection, for emphasis derivation only. */
|
||||
const missingMediaGroupsForSelection = computed(() => {
|
||||
if (!hasSelection.value) return []
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
const matched = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
if (!matched.length) return []
|
||||
return groupCandidatesByMediaType(matched)
|
||||
})
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
const count = countMissingModels(filteredMissingModelGroups.value)
|
||||
function buildMissingModelGroupsForSelection(): ErrorGroup[] {
|
||||
if (!missingModelGroupsForSelection.value.length) return []
|
||||
const count = countMissingModels(missingModelGroupsForSelection.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
@@ -732,7 +760,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: filteredMissingModelGroups.value,
|
||||
groups: missingModelGroupsForSelection.value,
|
||||
count,
|
||||
isCloud
|
||||
})
|
||||
@@ -740,10 +768,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingMediaGroups.value.length) return []
|
||||
function buildMissingMediaGroupsForSelection(): ErrorGroup[] {
|
||||
if (!missingMediaGroupsForSelection.value.length) return []
|
||||
const totalRows = countMissingMediaReferences(
|
||||
filteredMissingMediaGroups.value
|
||||
missingMediaGroupsForSelection.value
|
||||
)
|
||||
return [
|
||||
{
|
||||
@@ -753,7 +781,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
groups: missingMediaGroupsForSelection.value,
|
||||
count: totalRows,
|
||||
isCloud
|
||||
})
|
||||
@@ -776,47 +804,113 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
/**
|
||||
* The subset of error groups whose errors belong to the current canvas
|
||||
* selection. Empty when nothing is selected. Display always shows all
|
||||
* groups; this subset only drives selection emphasis (auto-expand, card
|
||||
* highlight, context strip).
|
||||
*/
|
||||
const selectionScopedGroups = computed<ErrorGroup[]>(() => {
|
||||
if (!hasSelection.value) return []
|
||||
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
processPromptError(groupsMap, true)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
// pack-level problems relevant regardless of which node is selected.
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...(filterByNode
|
||||
? buildMissingModelGroupsFiltered()
|
||||
: buildMissingModelGroups()),
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...buildMissingNodeGroups((nodeTypes) =>
|
||||
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
|
||||
),
|
||||
...buildMissingModelGroupsForSelection(),
|
||||
...buildMissingMediaGroupsForSelection(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* Execution node ids referenced by any missing-asset candidate (models,
|
||||
* media, missing node types).
|
||||
*/
|
||||
const assetNodeIdsWithError = computed<string[]>(() => {
|
||||
const candidateIds = [
|
||||
...(missingModelStore.missingModelCandidates ?? []),
|
||||
...(missingMediaStore.missingMediaCandidates ?? [])
|
||||
].map((candidate) => candidate.nodeId)
|
||||
const missingNodeTypeIds = (
|
||||
missingNodesStore.missingNodesError?.nodeTypes ?? []
|
||||
).map((nodeType) =>
|
||||
typeof nodeType === 'string' ? undefined : nodeType.nodeId
|
||||
)
|
||||
return [...candidateIds, ...missingNodeTypeIds]
|
||||
.filter((nodeId) => nodeId != null)
|
||||
.map(String)
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset node ids that belong to the current selection. Drives row-level
|
||||
* highlighting inside the missing-* cards.
|
||||
*/
|
||||
const selectionMatchedAssetNodeIds = computed<Set<string>>(() => {
|
||||
if (!hasSelection.value) return new Set()
|
||||
return new Set(
|
||||
assetNodeIdsWithError.value.filter(isAssetCandidateInSelection)
|
||||
)
|
||||
})
|
||||
|
||||
const selectionMatchedGroupKeys = computed<Set<string>>(() => {
|
||||
if (!hasSelection.value) return new Set()
|
||||
return new Set(selectionScopedGroups.value.map((group) => group.groupKey))
|
||||
})
|
||||
|
||||
const selectionMatchedCardIds = computed<Set<string>>(() => {
|
||||
if (!hasSelection.value) return new Set()
|
||||
return new Set(
|
||||
selectionScopedGroups.value
|
||||
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
||||
.map((card) => card.id)
|
||||
)
|
||||
})
|
||||
|
||||
const selectionErrorCount = computed(() => {
|
||||
if (!hasSelection.value) return 0
|
||||
return selectionScopedGroups.value.reduce(
|
||||
(sum, group) => sum + group.count,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
/** Distinct nodes affected by any error (workflow-level summary). */
|
||||
const errorNodeCount = computed(() => {
|
||||
const executionNodeIds = allErrorGroups.value
|
||||
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
||||
.map((card) => card.nodeId)
|
||||
.filter((nodeId) => nodeId != null)
|
||||
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = toValue(searchQuery).trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
return searchErrorGroups(allErrorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups,
|
||||
filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
swapNodeGroups,
|
||||
hasSelection,
|
||||
selectedNodeCount,
|
||||
selectedNodeTitle,
|
||||
selectionMatchedGroupKeys,
|
||||
selectionMatchedCardIds,
|
||||
selectionMatchedAssetNodeIds,
|
||||
selectionErrorCount,
|
||||
errorNodeCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3883,6 +3883,11 @@
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"errorsDetected": "Error detected | Errors detected",
|
||||
"selectedNodeErrors": "{node} — {count} error | {node} — {count} errors",
|
||||
"selectedNodesErrors": "{nodes} nodes selected — {count} error | {nodes} nodes selected — {count} errors",
|
||||
"errorNodeSummary": "{nodes} node — {count} error | {nodes} node — {count} errors",
|
||||
"errorNodesSummary": "{nodes} nodes — {count} error | {nodes} nodes — {count} errors",
|
||||
"errorsSummary": "{count} error | {count} errors",
|
||||
"resolveBeforeRun": "Resolve before running the workflow",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
|
||||
@@ -9,9 +9,22 @@
|
||||
v-for="item in missingMediaItems"
|
||||
:key="item.key"
|
||||
data-testid="missing-media-row"
|
||||
:aria-current="
|
||||
highlightedNodeIds?.has(item.nodeId) ? 'true' : undefined
|
||||
"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<!-- Emphasis lives on an inner element: the li is a TransitionGroup
|
||||
child, and the emphasis transition-property would override the
|
||||
list-scale move/enter/leave transitions. -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 items-center gap-2',
|
||||
selectionEmphasisClass(highlightedNodeIds?.has(item.nodeId))
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -44,8 +57,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
|
||||
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
@@ -56,6 +71,8 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
|
||||
const { missingMediaGroups } = defineProps<{
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
/** Execution node ids to emphasize (current canvas selection). */
|
||||
highlightedNodeIds?: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div
|
||||
v-if="importableModelRows.length > 0"
|
||||
data-testid="missing-model-importable-rows"
|
||||
class="flex flex-col gap-1 overflow-hidden"
|
||||
class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5"
|
||||
>
|
||||
<MissingModelRow
|
||||
v-for="row in importableModelRows"
|
||||
@@ -12,6 +12,7 @@
|
||||
:directory="row.directory"
|
||||
:is-asset-supported="row.isAssetSupported"
|
||||
:can-cloud-import="true"
|
||||
:highlighted="isRowHighlighted(row)"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -36,6 +37,7 @@
|
||||
:directory="row.directory"
|
||||
:is-asset-supported="row.isAssetSupported"
|
||||
:can-cloud-import="false"
|
||||
:highlighted="isRowHighlighted(row)"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -86,8 +88,10 @@ const MODEL_TYPE_SORT_ORDER = [
|
||||
'diffusion_models'
|
||||
] as const
|
||||
|
||||
const { missingModelGroups } = defineProps<{
|
||||
const { missingModelGroups, highlightedNodeIds } = defineProps<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
/** Execution node ids to emphasize (current canvas selection). */
|
||||
highlightedNodeIds?: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -172,4 +176,11 @@ function getModelTypeSortIndex(directory: string | null) {
|
||||
function canCloudImport(row: MissingModelRowEntry) {
|
||||
return row.isAssetSupported && row.directory !== null
|
||||
}
|
||||
|
||||
function isRowHighlighted(row: MissingModelRowEntry) {
|
||||
if (!highlightedNodeIds?.size) return false
|
||||
return row.model.referencingNodes.some((ref) =>
|
||||
highlightedNodeIds.has(String(ref.nodeId))
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<div
|
||||
:aria-current="highlighted ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-8 items-center gap-1',
|
||||
selectionEmphasisClass(highlighted)
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="hasMultipleReferences"
|
||||
data-testid="missing-model-expand"
|
||||
@@ -191,6 +199,8 @@ import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import type { MissingModelViewModel } from '@/platform/missingModel/types'
|
||||
@@ -217,12 +227,15 @@ const {
|
||||
model,
|
||||
directory,
|
||||
isAssetSupported,
|
||||
canCloudImport = true
|
||||
canCloudImport = true,
|
||||
highlighted
|
||||
} = defineProps<{
|
||||
model: MissingModelViewModel
|
||||
directory: string | null
|
||||
isAssetSupported: boolean
|
||||
canCloudImport?: boolean
|
||||
/** Emphasize the header row (model referenced by the canvas selection). */
|
||||
highlighted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<div
|
||||
:aria-current="highlighted ? 'true' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-8 items-center gap-1',
|
||||
selectionEmphasisClass(highlighted)
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-expand"
|
||||
@@ -153,14 +161,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { selectionEmphasisClass } from '@/components/rightSidePanel/errors/selectionEmphasis'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { group } = defineProps<{
|
||||
const { group, highlighted } = defineProps<{
|
||||
group: SwapNodeGroup
|
||||
/** Emphasize the header row (group containing the canvas selection). */
|
||||
highlighted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
:group="group"
|
||||
:highlighted="
|
||||
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
|
||||
"
|
||||
@locate-node="emit('locate-node', $event)"
|
||||
@replace="emit('replace', $event)"
|
||||
/>
|
||||
@@ -11,11 +14,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
|
||||
|
||||
const { swapNodeGroups } = defineProps<{
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
/** Execution node ids to emphasize (current canvas selection). */
|
||||
highlightedNodeIds?: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
Reference in New Issue
Block a user