Files
ComfyUI_frontend/src/platform/settings/components/ExtensionPanel.vue
Jin Yi 7add2c03e9 feat: unify search components by replacing SearchBox/SearchBoxV2 with SearchInput (#9644)
## Summary

Replace legacy `SearchBox` (PrimeVue) and `SearchBoxV2` with the unified
`SearchInput` (reka-ui) component across all consumers.

## Changes

- **What**: Remove `SearchBox.vue`, `SearchBoxV2.vue`, their tests and
stories. Migrate all 14 consumers to `SearchInput`. Move layout classes
to `ComboboxRoot` for proper flex sizing. Extract filter button/chips in
`NodeLibrarySidebarTab`. Standardize modal search width to `flex-1
max-w-lg`.
- **Dependencies**: None new — `SearchInput` already existed using
reka-ui

## Review Focus

- `NodeLibrarySidebarTab.vue`: filter button and `SearchFilterChip`
rendering moved outside the search component
- `SearchInput.vue`: `className` now applied to `ComboboxRoot` instead
of `ComboboxAnchor` for correct flex layout
- Modal dialogs (`WorkflowTemplateSelectorDialog`, `AssetBrowserModal`,
`SampleModelSelector`) unified to `flex-1 max-w-lg`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9644-feat-unify-search-components-by-replacing-SearchBox-SearchBoxV2-with-SearchInput-31e6d73d365081ebac55cb265f33b631)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-10 11:30:25 +09:00

250 lines
6.8 KiB
Vue

<template>
<div class="extension-panel flex flex-col gap-2">
<SearchInput
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.extensions') })"
/>
<Message
v-if="hasChanges"
severity="info"
pt:text="w-full"
class="max-h-96 overflow-y-auto"
>
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button variant="destructive" @click="applyChanges">
{{ $t('g.reloadToApplyChanges') }}
</Button>
</div>
</Message>
<div class="mb-3 flex gap-2">
<SelectButton
v-model="filterType"
:options="filterTypes"
option-label="label"
option-value="value"
/>
</div>
<DataTable
v-model:selection="selectedExtensions"
:value="filteredExtensions"
striped-rows
size="small"
:filters="filters"
selection-mode="multiple"
data-key="name"
>
<Column selection-mode="multiple" :frozen="true" style="width: 3rem" />
<Column :header="$t('g.extensionName')" sortable field="name">
<template #body="slotProps">
{{ slotProps.data.name }}
<Tag
v-if="extensionStore.isCoreExtension(slotProps.data.name)"
:value="$t('g.core')"
/>
<Tag v-else :value="$t('g.custom')" severity="info" />
</template>
</Column>
<Column
:pt="{
headerCell: 'flex items-center justify-end',
bodyCell: 'flex items-center justify-end'
}"
>
<template #header>
<Button
size="icon"
variant="muted-textonly"
@click="menu?.show($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<ContextMenu ref="menu" :model="contextMenuItems" />
</template>
<template #body="slotProps">
<ToggleSwitch
v-model="editingEnabledExtensions[slotProps.data.name]"
:disabled="extensionStore.isExtensionReadOnly(slotProps.data.name)"
@change="updateExtensionStatus"
/>
</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import ContextMenu from 'primevue/contextmenu'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const { t } = useI18n()
const filterTypeKeys = ['all', 'core', 'custom'] as const
type FilterTypeKey = (typeof filterTypeKeys)[number]
const filterTypes = computed(() =>
filterTypeKeys.map((key) => ({
label: t(`g.${key}`),
value: key
}))
)
const filterType = ref<FilterTypeKey>('all')
const selectedExtensions = ref<ComfyExtension[]>([])
const filters = ref({
global: { value: '', matchMode: FilterMatchMode.CONTAINS }
})
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
const editingEnabledExtensions = ref<Record<string, boolean>>({})
const filteredExtensions = computed(() => {
const extensions = extensionStore.extensions
switch (filterType.value) {
case 'core':
return extensions.filter((ext) =>
extensionStore.isCoreExtension(ext.name)
)
case 'custom':
return extensions.filter(
(ext) => !extensionStore.isCoreExtension(ext.name)
)
default:
return extensions
}
})
onMounted(() => {
extensionStore.extensions.forEach((ext) => {
editingEnabledExtensions.value[ext.name] =
extensionStore.isExtensionEnabled(ext.name)
})
})
const changedExtensions = computed(() => {
return extensionStore.extensions.filter(
(ext) =>
editingEnabledExtensions.value[ext.name] !==
extensionStore.isExtensionEnabled(ext.name)
)
})
const hasChanges = computed(() => {
return changedExtensions.value.length > 0
})
const updateExtensionStatus = async () => {
const editingDisabledExtensionNames = Object.entries(
editingEnabledExtensions.value
)
.filter(([_, enabled]) => !enabled)
.map(([name]) => name)
await settingStore.set('Comfy.Extension.Disabled', [
...extensionStore.inactiveDisabledExtensionNames,
...editingDisabledExtensionNames
])
}
const enableAllExtensions = async () => {
extensionStore.extensions.forEach((ext) => {
if (extensionStore.isExtensionReadOnly(ext.name)) return
editingEnabledExtensions.value[ext.name] = true
})
await updateExtensionStatus()
}
const disableAllExtensions = async () => {
extensionStore.extensions.forEach((ext) => {
if (extensionStore.isExtensionReadOnly(ext.name)) return
editingEnabledExtensions.value[ext.name] = false
})
await updateExtensionStatus()
}
const disableThirdPartyExtensions = async () => {
extensionStore.extensions.forEach((ext) => {
if (extensionStore.isCoreExtension(ext.name)) return
editingEnabledExtensions.value[ext.name] = false
})
await updateExtensionStatus()
}
const applyChanges = () => {
// Refresh the page to apply changes
window.location.reload()
}
const menu = ref<InstanceType<typeof ContextMenu>>()
const contextMenuItems = computed(() => [
{
label: t('g.enableSelected'),
icon: 'pi pi-check',
command: async () => {
selectedExtensions.value.forEach((ext) => {
if (!extensionStore.isExtensionReadOnly(ext.name)) {
editingEnabledExtensions.value[ext.name] = true
}
})
await updateExtensionStatus()
}
},
{
label: t('g.disableSelected'),
icon: 'pi pi-times',
command: async () => {
selectedExtensions.value.forEach((ext) => {
if (!extensionStore.isExtensionReadOnly(ext.name)) {
editingEnabledExtensions.value[ext.name] = false
}
})
await updateExtensionStatus()
}
},
{
separator: true
},
{
label: t('g.enableAll'),
icon: 'pi pi-check',
command: enableAllExtensions
},
{
label: t('g.disableAll'),
icon: 'pi pi-times',
command: disableAllExtensions
},
{
label: t('g.disableThirdParty'),
icon: 'pi pi-times',
command: disableThirdPartyExtensions,
disabled: !extensionStore.hasThirdPartyExtensions
}
])
</script>