diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 699477e03d6..da87132fb08 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -864,6 +864,7 @@ }, "modelManager": { "active": "active", + "actions": "Bulk Actions", "addModel": "Add Model", "addModels": "Add Models", "advanced": "Advanced", @@ -899,6 +900,7 @@ "delete": "Delete", "deleteConfig": "Delete Config", "deleteModel": "Delete Model", + "deleteModels": "Delete Models", "deleteModelImage": "Delete Model Image", "deleteMsg1": "Are you sure you want to delete this model from InvokeAI?", "deleteMsg2": "This WILL delete the model from disk if it is in the InvokeAI root folder. If you are using a custom location, then the model WILL NOT be deleted from disk.", @@ -1030,6 +1032,7 @@ "triggerPhrases": "Trigger Phrases", "loraTriggerPhrases": "LoRA Trigger Phrases", "mainModelTriggerPhrases": "Main Model Trigger Phrases", + "selectAll": "Select All", "typePhraseHere": "Type phrase here", "t5Encoder": "T5 Encoder", "qwen3Encoder": "Qwen3 Encoder", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx index 0bf496b583b..2159d538bee 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx @@ -1,7 +1,8 @@ -import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library'; +import { Flex, Text, useToast } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { buildUseDisclosure } from 'common/hooks/useBoolean'; import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models'; import { clearModelSelection, @@ -23,11 +24,12 @@ import type { AnyModelConfig } from 'services/api/types'; import { BulkDeleteModelsModal } from './BulkDeleteModelsModal'; import { FetchingModelsLoader } from './FetchingModelsLoader'; -import { ModelListHeader } from './ModelListHeader'; import { ModelListWrapper } from './ModelListWrapper'; const log = logger('models'); +export const [useBulkDeleteModal] = buildUseDisclosure(false); + const ModelList = () => { const dispatch = useAppDispatch(); const filteredModelType = useAppSelector(selectFilteredModelType); @@ -35,7 +37,7 @@ const ModelList = () => { const selectedModelKeys = useAppSelector(selectSelectedModelKeys); const { t } = useTranslation(); const toast = useToast(); - const { isOpen, onOpen, onClose } = useDisclosure(); + const { isOpen, close } = useBulkDeleteModal(); const [isDeleting, setIsDeleting] = useState(false); const { data, isLoading } = useGetModelConfigsQuery(); @@ -62,10 +64,6 @@ const ModelList = () => { return { total, byCategory }; }, [data, filteredModelType, searchTerm]); - const handleBulkDelete = useCallback(() => { - onOpen(); - }, [onOpen]); - const handleConfirmBulkDelete = useCallback(async () => { setIsDeleting(true); try { @@ -74,7 +72,7 @@ const ModelList = () => { // Clear selection and close modal dispatch(clearModelSelection()); dispatch(setSelectedModelKey(null)); - onClose(); + close(); // Show success/failure toast if (result.failed.length === 0) { @@ -127,12 +125,11 @@ const ModelList = () => { } finally { setIsDeleting(false); } - }, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t]); + }, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]); return ( <> - {isLoading && } @@ -147,9 +144,10 @@ const ModelList = () => { + { + const dispatch = useAppDispatch(); + const filteredModelType = useAppSelector(selectFilteredModelType); + const selectedModelKeys = useAppSelector(selectSelectedModelKeys); + const searchTerm = useAppSelector(selectSearchTerm); + const { data } = useGetModelConfigsQuery(); + const bulkDeleteModal = useBulkDeleteModal(); + + const handleBulkDelete = useCallback(() => { + bulkDeleteModal.open(); + }, [bulkDeleteModal]); + + // Calculate displayed (filtered) model keys + const displayedModelKeys = useMemo(() => { + const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); + const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType); + return filteredModels.map((m) => m.key); + }, [data, searchTerm, filteredModelType]); + + const { allSelected, someSelected } = useMemo(() => { + if (displayedModelKeys.length === 0) { + return { allSelected: false, someSelected: false }; + } + const selectedSet = new Set(selectedModelKeys); + const displayedSelectedCount = displayedModelKeys.filter((key) => selectedSet.has(key)).length; + return { + allSelected: displayedSelectedCount === displayedModelKeys.length, + someSelected: displayedSelectedCount > 0 && displayedSelectedCount < displayedModelKeys.length, + }; + }, [displayedModelKeys, selectedModelKeys]); + + const handleToggleAll = useCallback(() => { + if (allSelected) { + // Deselect all displayed models + const displayedSet = new Set(displayedModelKeys); + const newSelection = selectedModelKeys.filter((key) => !displayedSet.has(key)); + dispatch(modelSelectionChanged(newSelection)); + } else { + // Select all displayed models (merge with existing selection) + const selectedSet = new Set(selectedModelKeys); + displayedModelKeys.forEach((key) => selectedSet.add(key)); + dispatch(modelSelectionChanged(Array.from(selectedSet))); + } + }, [allSelected, displayedModelKeys, selectedModelKeys, dispatch]); + + const selectionCount = selectedModelKeys.length; + + return ( + + + + {t('modelManager.selectAll')} + + + + + + {selectionCount} {t('common.selected')} + + + } + flexShrink={0} + variant="outline" + > + {t('modelManager.actions')} + + + } onClick={handleBulkDelete} color="error.300"> + {t('modelManager.deleteModels', { count: selectionCount })} + + + + + + ); +}); + +ModelListBulkActions.displayName = 'ModelListBulkActions'; + +const modelsFilter = ( + data: T[], + nameFilter: string, + filteredModelType: FilterableModelType | null +): T[] => { + return data.filter((model) => { + const matchesFilter = + model.name.toLowerCase().includes(nameFilter.toLowerCase()) || + model.base.toLowerCase().includes(nameFilter.toLowerCase()) || + model.type.toLowerCase().includes(nameFilter.toLowerCase()) || + model.description?.toLowerCase().includes(nameFilter.toLowerCase()) || + model.format.toLowerCase().includes(nameFilter.toLowerCase()); + + const matchesType = getMatchesType(model, filteredModelType); + + return matchesFilter && matchesType; + }); +}; + +const getMatchesType = (modelConfig: AnyModelConfig, filteredModelType: FilterableModelType | null): boolean => { + if (filteredModelType === 'refiner') { + return modelConfig.base === 'sdxl-refiner'; + } + + if (filteredModelType === 'main' && modelConfig.base === 'sdxl-refiner') { + return false; + } + + return filteredModelType ? modelConfig.type === filteredModelType : true; +}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx deleted file mode 100644 index 3d0d71029b8..00000000000 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - Button, - Flex, - Menu, - MenuButton, - MenuItem, - MenuList, - Tag, - TagCloseButton, - TagLabel, -} from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { clearModelSelection, selectSelectedModelKeys } from 'features/modelManagerV2/store/modelManagerV2Slice'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi'; - -type ModelListHeaderProps = { - onBulkDelete: () => void; -}; - -export const ModelListHeader = memo(({ onBulkDelete }: ModelListHeaderProps) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const selectedModelKeys = useAppSelector(selectSelectedModelKeys); - const selectionCount = selectedModelKeys.length; - - const handleClearSelection = useCallback(() => { - dispatch(clearModelSelection()); - }, [dispatch]); - - if (selectionCount === 0) { - return null; - } - - return ( - - - - {selectionCount} {t('common.selected')} - - - - - } flexShrink={0}> - {t('modelManager.actions')} - - - } onClick={onBulkDelete} color="error.300"> - {t('modelManager.deleteModels', { count: selectionCount })} - - - - - ); -}); - -ModelListHeader.displayName = 'ModelListHeader'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx index d6dda98e80e..5719752ff01 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library'; +import { chakra, Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { @@ -12,18 +12,22 @@ import ModelBaseBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ import ModelFormatBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge'; import { ModelDeleteButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelDeleteButton'; import { filesize } from 'filesize'; -import type { MouseEvent } from 'react'; +import type { ChangeEvent, MouseEvent } from 'react'; import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { AnyModelConfig } from 'services/api/types'; import ModelImage from './ModelImage'; +const StyledLabel = chakra('label'); + type ModelListItemProps = { model: AnyModelConfig; }; const sx: SystemStyleObject = { - paddingInline: 3, + paddingInlineStart: 10, + paddingInlineEnd: 3, paddingBlock: 2, position: 'relative', rounded: 'base', @@ -38,12 +42,6 @@ const sx: SystemStyleObject = { insetInline: 3, bg: 'base.850', }, - '&:before': { - left: 1, - w: 1, - insetBlock: 2, - rounded: 'base', - }, _hover: { bg: 'base.850', '& .delete-button': { opacity: 1 }, @@ -58,6 +56,7 @@ const sx: SystemStyleObject = { }; const ModelListItem = ({ model }: ModelListItemProps) => { + const { t } = useTranslation(); const dispatch = useAppDispatch(); const selectIsSelected = useMemo( () => @@ -71,16 +70,9 @@ const ModelListItem = ({ model }: ModelListItemProps) => { const selectedModelKeys = useAppSelector(selectSelectedModelKeys); const isChecked = selectedModelKeys.includes(model.key); - const handleSelectModel = useCallback( + const handleRowClick = useCallback( (e: MouseEvent) => { - // Check if clicked on checkbox or delete button - if so, don't handle selection - const target = e.target as HTMLElement; - if (target.closest('input[type="checkbox"]') || target.closest('button')) { - return; - } - - // Clicking the row opens detail view (single select) - // Ctrl/Cmd+Click still works as a power user feature for multi-select + // Ctrl/Cmd+Click toggles multi-selection if (e.ctrlKey || e.metaKey) { dispatch(toggleModelSelection(model.key)); } else { @@ -90,55 +82,69 @@ const ModelListItem = ({ model }: ModelListItemProps) => { [model.key, dispatch] ); - const handleCheckboxChange = useCallback(() => { - dispatch(toggleModelSelection(model.key)); - }, [model.key, dispatch]); + const handleCheckboxChange = useCallback( + (e: ChangeEvent) => { + e.stopPropagation(); + dispatch(toggleModelSelection(model.key)); + }, + [model.key, dispatch] + ); - const handleCheckboxClick = useCallback((e: MouseEvent) => { + const stopPropagation = useCallback((e: MouseEvent) => { e.stopPropagation(); }, []); return ( - - - - - - - - {model.name} - - - {filesize(model.file_size)} + + + + + + + + + + + {model.name} + + + {filesize(model.file_size)} + + + + + {model.description || 'No Description'} - - - - {model.description || 'No Description'} - - - - + + + + - - - + + + ); diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx index 23081f68cf3..78bed8ab830 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx @@ -1,48 +1,17 @@ -import { Checkbox, Flex, IconButton, Input, InputGroup, InputRightElement, Text } from '@invoke-ai/ui-library'; +import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { - type FilterableModelType, - modelSelectionChanged, - selectFilteredModelType, - selectSearchTerm, - selectSelectedModelKeys, - setSearchTerm, -} from 'features/modelManagerV2/store/modelManagerV2Slice'; +import { selectSearchTerm, setSearchTerm } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { t } from 'i18next'; import type { ChangeEventHandler } from 'react'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; import { PiXBold } from 'react-icons/pi'; -import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models'; -import type { AnyModelConfig } from 'services/api/types'; +import { ModelListBulkActions } from './ModelListBulkActions'; import { ModelTypeFilter } from './ModelTypeFilter'; export const ModelListNavigation = memo(() => { const dispatch = useAppDispatch(); const searchTerm = useAppSelector(selectSearchTerm); - const filteredModelType = useAppSelector(selectFilteredModelType); - const selectedModelKeys = useAppSelector(selectSelectedModelKeys); - const { data } = useGetModelConfigsQuery(); - - // Calculate displayed (filtered) model keys - const displayedModelKeys = useMemo(() => { - const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} }); - const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType); - return filteredModels.map((m) => m.key); - }, [data, searchTerm, filteredModelType]); - - // Calculate checkbox state - const { allSelected, someSelected } = useMemo(() => { - if (displayedModelKeys.length === 0) { - return { allSelected: false, someSelected: false }; - } - const selectedSet = new Set(selectedModelKeys); - const displayedSelectedCount = displayedModelKeys.filter((key) => selectedSet.has(key)).length; - return { - allSelected: displayedSelectedCount === displayedModelKeys.length, - someSelected: displayedSelectedCount > 0 && displayedSelectedCount < displayedModelKeys.length, - }; - }, [displayedModelKeys, selectedModelKeys]); const handleSearch: ChangeEventHandler = useCallback( (event) => { @@ -55,92 +24,38 @@ export const ModelListNavigation = memo(() => { dispatch(setSearchTerm('')); }, [dispatch]); - const handleToggleAll = useCallback(() => { - if (allSelected) { - // Deselect all displayed models - const displayedSet = new Set(displayedModelKeys); - const newSelection = selectedModelKeys.filter((key) => !displayedSet.has(key)); - dispatch(modelSelectionChanged(newSelection)); - } else { - // Select all displayed models (merge with existing selection) - const selectedSet = new Set(selectedModelKeys); - displayedModelKeys.forEach((key) => selectedSet.add(key)); - dispatch(modelSelectionChanged(Array.from(selectedSet))); - } - }, [allSelected, displayedModelKeys, selectedModelKeys, dispatch]); - return ( - + - - - - {t('modelManager.selectAll')} - + + + + + {!!searchTerm?.length && ( + + } + onClick={clearSearch} + /> + + )} + + + + - - - - {!!searchTerm?.length && ( - - } - onClick={clearSearch} - /> - - )} - - - - + ); }); ModelListNavigation.displayName = 'ModelListNavigation'; - -const modelsFilter = ( - data: T[], - nameFilter: string, - filteredModelType: FilterableModelType | null -): T[] => { - return data.filter((model) => { - const matchesFilter = - model.name.toLowerCase().includes(nameFilter.toLowerCase()) || - model.base.toLowerCase().includes(nameFilter.toLowerCase()) || - model.type.toLowerCase().includes(nameFilter.toLowerCase()) || - model.description?.toLowerCase().includes(nameFilter.toLowerCase()) || - model.format.toLowerCase().includes(nameFilter.toLowerCase()); - - const matchesType = getMatchesType(model, filteredModelType); - - return matchesFilter && matchesType; - }); -}; - -const getMatchesType = (modelConfig: AnyModelConfig, filteredModelType: FilterableModelType | null): boolean => { - if (filteredModelType === 'refiner') { - return modelConfig.base === 'sdxl-refiner'; - } - - if (filteredModelType === 'main' && modelConfig.base === 'sdxl-refiner') { - return false; - } - - return filteredModelType ? modelConfig.type === filteredModelType : true; -}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx index 9783a88b062..74b26dce386 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx @@ -21,6 +21,7 @@ const contentSx = { p: 0, bg: 'base.900', borderRadius: '0', + zIndex: 0, } satisfies SystemStyleObject; export const ModelListWrapper = memo((props: ModelListWrapperProps) => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx index 0ee479e86b2..dcb22071482 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx @@ -17,8 +17,8 @@ export const ModelTypeFilter = memo(() => { }, [dispatch]); return ( - - }> + + }> {filteredModelType ? t(MODEL_CATEGORIES[filteredModelType].i18nKey) : t('modelManager.allModels')}