diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c68eab..44315fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ - **Manual Update Check**: A "Check now" button in the Updates section lets you check your selected channel on demand instead of waiting for the startup check. - **Instant Grid on Launch**: Opening a library now shows its packages immediately from the local index while a fresh scan reconciles with disk in the background — no more waiting on an empty grid. Files deleted since the last scan drop off automatically once the scan completes. - **Cross-Library Dependencies**: In the details panel, a dependency or "Used By" entry that lives in a *different* library is now labelled with that library's name and is clickable — clicking switches to that library and **locates & highlights** the package in the grid (your current selection and open panel stay put). Those rows are coloured by the target's enabled/disabled state, so only the library label is blue. Previously such entries were unclickable and could leave the grid empty. +- **Ratings & Favourites**: You can now rate a package from **1 to 5 stars** and mark it as a **favourite**. Right-click any package to set its rating or toggle the heart; ratings and favourites are then searchable and filterable from the sidebar (the star row, the *Only favourites* toggle) or the search bar (`rating:4`, `rating:>=3`, `favorite:true`). A rating applies to the whole package family, so every version shares it, and your choices persist in the local database. (Desktop for now; the mobile/web entry point arrives with the upcoming card redesign.) ### Changed +- **Redesigned Sidebar Filters**: **Status**, **Creators**, and **Categories** are now compact **dropdowns** that overlay when opened instead of long lists that pushed the panel around, each with its own built-in search and each showing the values you've picked. The freed space adds new filters: a **star rating** control, an **Only favourites** toggle, and a **dependency-relationship** group — **Standalone** (packages that declare no dependencies) and **Removable** (packages nothing depends on, so removing them breaks nothing). "Removable" reports the same whether a package is enabled or disabled, since a disabled package still exists in your library. "All Packages" became a **Clear all** action that appears only when a filter is active, and the library's total package count now sits centred beneath its name. The show/hide animation is smoother on desktop and mobile. The star rating and *Only favourites* controls now filter live (see **Ratings & Favourites** above). - **Local Database (Foundation)**: Package and library metadata is now indexed into a local SQLite database, laying the groundwork for faster startups, persistent ratings/favourites, and upcoming features. Your `config.json` remains the source of truth for configured library paths, so the change is fully backward-compatible. - **Dependency Version Fallback**: Clicking a dependency whose exact version isn't installed no longer dead-ends with "specific version not found". YAVAM now takes you to the newest available copy — locally or in another library — with a heads-up that the exact version wasn't found. - **Deletion**: Improved package deletion behaviour for more reliable and predictable results. diff --git a/CLAUDE.md b/CLAUDE.md index 5f1624e..8596607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,9 +90,16 @@ fatal. ## Code quality & style -- **Self-documenting code.** Prefer readable code over comments. Comments explain - *how a system/architecture works and why* — not what each line does. Delete - comments that merely restate the code. +- **Self-documenting code — comments are the exception, not the default.** Make the + code readable enough that it needs no narration: clear names, small functions, + early returns. Do **not** comment each step, restate what a line does, or label + sections (`// optimistic`, `// rollback`, `// Fetch on mount`, `// Local State`). + A comment must earn its place by explaining *why* — a non-obvious constraint, + trade-off, or gotcha the code cannot express. When you reach for a comment, first + try to make it unnecessary by renaming or restructuring. Prefer a docstring on an + exported symbol (function/type/component) over inline prose, and keep it to the + contract and rationale — not a line-by-line tour. When in doubt, leave it out. + When editing existing code, delete comments that merely restate it. - **Apply design patterns deliberately.** For non-trivial work, evaluate where a pattern fits (and where it doesn't), and say so. - **DRY & SOLID.** Extract repeated rules into one definition (`pkg/utils` or a diff --git a/app.go b/app.go index bc42082..1249a28 100644 --- a/app.go +++ b/app.go @@ -387,6 +387,18 @@ func (a *App) LocateDependencies(ids []string) map[string]models.DependencyLocat return a.manager.LocateDependencies(ids) } +// SetPackageRating stores a 0–5 star rating (0 clears) for a package family +// ("Creator.Name"), version-agnostic — every version of the family shares one +// rating. Persisted in the user_metadata table. +func (a *App) SetPackageRating(family string, rating int) error { + return a.manager.SetPackageRating(family, rating) +} + +// SetPackageFavorite marks a package family ("Creator.Name") as a favourite. +func (a *App) SetPackageFavorite(family string, favorite bool) error { + return a.manager.SetPackageFavorite(family, favorite) +} + func (a *App) ScanPackages(vamPath string) error { if vamPath == "" || vamPath == "." { return nil diff --git a/docs/design/search-syntax.md b/docs/design/search-syntax.md index 224f69d..6278ae9 100644 --- a/docs/design/search-syntax.md +++ b/docs/design/search-syntax.md @@ -17,13 +17,13 @@ token → AND (must match) ## Token types ``` -status:enabled status:disabled status:missing status:corrupt status:standalone -status:hidden status:visible +status:enabled status:disabled status:missing status:corrupt +status:standalone status:removable status:hidden status:visible creator:acidbubbles type:scene tag:dress license:cc-by license:pc license:pc-ea -rating:>=4 +rating:>=4 rating:<=2 rating:3 favorite:true size:>500mb size:10mb..100mb ``` @@ -31,8 +31,15 @@ size:>500mb size:10mb..100mb - `tag:` is available through search only; there is no dedicated tag sidebar section. - `size:` supports a single bound (`size:>100mb`) or a range (`size:10mb..500mb`). -- `status:standalone` narrows to standalone packages even when the dependency - visibility mode is `all`. +- `rating:` supports a single bound (`rating:>=4`, `rating:<3`) or an exact + value (`rating:5`). The sidebar's star control emits the exact form + (`rating:N`) — with no way to also set an upper bound, a minimum could not + express "only the 3-star ones". Ratings are version-agnostic (keyed by family) + and stored in `user_metadata`; an unrated package counts as `rating:0`. +- The two dependency-relationship axes are orthogonal and both backed today: + `status:standalone` = the package declares **no dependencies**; + `status:removable` = **no package depends on it**, so removing it breaks + nothing (enable-agnostic — a disabled package still counts as existing). ## Examples @@ -110,10 +117,13 @@ which itself waits on the scan/validation rework that switching libraries needs. ## Not-yet-backed tokens -`rating:`, `favorite:`, and `license:` are parsed and shown as chips but do not -filter until the ratings/favourites data layer exists; they are inert no-ops -until then. `status:standalone`, `status:hidden`, and `status:visible` likewise -wait on the dependency-visibility mode. +`license:` is parsed and shown as a chip but does not filter until license data +is surfaced; it is an inert no-op until then. `status:hidden` and +`status:visible` likewise wait on the dependency-visibility mode. +`status:standalone`, `status:removable`, `rating:`, and `favorite:` are backed +and filter live. Ratings/favourites are joined onto each package from the +`user_metadata` store (keyed by family) on the cache-first read path; they are +written on desktop through the package right-click menu and the sidebar controls. ## Related: dependency-visibility mode diff --git a/docs/design/theming.md b/docs/design/theming.md index 948d842..f1406c2 100644 --- a/docs/design/theming.md +++ b/docs/design/theming.md @@ -25,7 +25,7 @@ compatible. --yavam-card-enabled --yavam-card-disabled --yavam-card-corrupt --yavam-card-missing-deps --yavam-card-duplicate --yavam-card-obsolete ---yavam-card-standalone /* "Standalone" in UI; internal code: isOrphan */ +--yavam-card-standalone /* root packages nothing depends on; internal code: isRemovable */ /* UI chrome */ --yavam-creator-label-bg --yavam-sidebar-icon-active diff --git a/docs/domain/frontend-architecture.md b/docs/domain/frontend-architecture.md index d5537d3..3cab6b8 100644 --- a/docs/domain/frontend-architecture.md +++ b/docs/domain/frontend-architecture.md @@ -123,7 +123,7 @@ export const Dashboard = () => { ## 6. Centralized Logic Patterns ### Package Status Authority -Package status (Valid, Corrupt, Duplicate, Root) is complex and derived from multiple properties (`isCorrupt`, `missingDeps`, `isOrphan`). +Package status (Valid, Corrupt, Duplicate, Root) is complex and derived from multiple properties (`isCorrupt`, `missingDeps`, `isRemovable`). **Rule:** NEVER implement ad-hoc `if/else` checks for package status in your components (e.g., `PackageCard`, `Sidebar`). **Solution:** Always use the centralized helper: `src/features/library/utils.ts` -> `getPackageStatus(pkg)`. This ensures that "Duplicate", "Obsolete", and "Root" statuses are visualized consistently across the entire application (Grid, List, Dependants). diff --git a/frontend/src/Dashboard.tsx b/frontend/src/Dashboard.tsx index 4ee10d6..f3c7135 100644 --- a/frontend/src/Dashboard.tsx +++ b/frontend/src/Dashboard.tsx @@ -130,7 +130,8 @@ const DashboardContent = () => { const { handleBulkToggle, handleOpenFolder, setInstallModal, handleCopyPath, - handleCopyFiles, handleCutFile, handleDeleteClick, handleInstantMerge, handleSingleResolve + handleCopyFiles, handleCutFile, handleDeleteClick, handleInstantMerge, handleSingleResolve, + setPackageRating, setPackageFavorite } = useActionContext(); // Drag & Drop (Upload) @@ -333,6 +334,8 @@ const DashboardContent = () => { onMerge={(p) => handleInstantMerge(p, false)} onMergeInPlace={(p) => handleInstantMerge(p, true)} onResolve={handleSingleResolve} + onSetRating={setPackageRating} + onSetFavorite={setPackageFavorite} /> )} diff --git a/frontend/src/components/ui/ContextMenu.tsx b/frontend/src/components/ui/ContextMenu.tsx index b581c20..c470632 100644 --- a/frontend/src/components/ui/ContextMenu.tsx +++ b/frontend/src/components/ui/ContextMenu.tsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useState, useLayoutEffect } from 'react'; import { VarPackage } from '../../types'; -import { Power, FolderOpen, Copy, Trash2, FileCode, Scissors, Download, Layers, Sparkles } from 'lucide-react'; +import clsx from 'clsx'; +import { Power, FolderOpen, Copy, Trash2, FileCode, Scissors, Download, Layers, Sparkles, Star, Heart } from 'lucide-react'; interface ContextMenuProps { x: number; @@ -18,14 +19,62 @@ interface ContextMenuProps { onMerge: (pkg: VarPackage) => void; onMergeInPlace: (pkg: VarPackage) => void; onResolve: (pkg: VarPackage) => void; + onSetRating: (pkg: VarPackage, rating: number) => void; + onSetFavorite: (pkg: VarPackage, favorite: boolean) => void; } -const ContextMenu = ({ x, y, pkg, selectedCount = 0, onClose, onToggle, onOpenFolder, onDownload, onCopyPath, onCopyFiles, onCutFile, onDelete, onMerge, onMergeInPlace, onResolve }: ContextMenuProps) => { +// Controlled rating row for the right-click menu: `value` is the live rating, +// `onRate` receives the star clicked (the parent resolves click-active-to-clear). +const MenuRatingStars = ({ value, onRate }: { value: number, onRate: (n: number) => void }) => { + const [hover, setHover] = useState(0); + const shown = hover || value; + return ( +
setHover(0)}> + {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+ ); +}; + +const ContextMenu = ({ x, y, pkg, selectedCount = 0, onClose, onToggle, onOpenFolder, onDownload, onCopyPath, onCopyFiles, onCutFile, onDelete, onMerge, onMergeInPlace, onResolve, onSetRating, onSetFavorite }: ContextMenuProps) => { const ref = useRef(null); const [position, setPosition] = useState({ top: y, left: x }); // @ts-ignore const isWeb = !window.go; + // Live rating/favourite state so the row updates the instant it's clicked, + // independent of the (snapshot) pkg prop. Reseeded when the menu retargets a + // different package. + const [rating, setRating] = useState(pkg?.rating ?? 0); + const [isFavorite, setIsFavorite] = useState(!!pkg?.isFavorite); + useEffect(() => { + setRating(pkg?.rating ?? 0); + setIsFavorite(!!pkg?.isFavorite); + }, [pkg?.filePath]); // eslint-disable-line react-hooks/exhaustive-deps + + const rate = (n: number) => { + if (!pkg) return; + const next = n === rating ? 0 : n; // click the active star to clear + setRating(next); + onSetRating(pkg, next); + }; + + const toggleFavorite = () => { + if (!pkg) return; + const next = !isFavorite; + setIsFavorite(next); + onSetFavorite(pkg, next); + }; + useLayoutEffect(() => { if (ref.current) { const rect = ref.current.getBoundingClientRect(); @@ -75,6 +124,25 @@ const ContextMenu = ({ x, y, pkg, selectedCount = 0, onClose, onToggle, onOpenFo {selectedCount > 1 ? `${selectedCount} items selected` : pkg.fileName} + {/* Rating + favourite (user_metadata, version-agnostic per family). + Desktop-only: web has no persistence endpoint yet. */} + {!isWeb && ( + <> +
+ + +
+ +
+ + )} + + + {open && pos && createPortal( +
+ {searchable && ( +
+
+ + setSearch(e.target.value)} + placeholder={searchPlaceholder} + className="bg-transparent outline-none text-xs text-gray-200 placeholder-gray-600 flex-1 min-w-0" + spellCheck={false} + autoComplete="off" + /> +
+
+ )} + +
+ {visible.length === 0 ? ( +
{emptyHint}
+ ) : ( + visible.map(opt => { + const selected = isSelected(opt.value); + return ( + + ); + }) + )} +
+
, + document.body, + )} + + ); +}; + +export default FilterDropdown; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index f48c4b2..cf13c8d 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -36,5 +36,6 @@ export const STATUS_FILTERS = { VERSION_CONFLICTS: 'version-conflicts', EXACT_DUPLICATES: 'exact-duplicates', CORRUPT: 'corrupt', - UNREFERENCED: 'unreferenced' + REMOVABLE: 'removable', + STANDALONE: 'standalone' } as const; diff --git a/frontend/src/context/ActionContext.tsx b/frontend/src/context/ActionContext.tsx index 1a06bb7..96ae541 100644 --- a/frontend/src/context/ActionContext.tsx +++ b/frontend/src/context/ActionContext.tsx @@ -23,6 +23,8 @@ interface ActionContextType { handleCopyPath: (pkg: any) => Promise; handleCopyFiles: (pkgs: any[]) => Promise; handleCutFile: (pkg: any) => Promise; + setPackageRating: (pkg: any, rating: number) => Promise; + setPackageFavorite: (pkg: any, favorite: boolean) => Promise; // Modal State installModal: { open: boolean; pkgs: any[] }; diff --git a/frontend/src/features/layout/GlobalOverlays.tsx b/frontend/src/features/layout/GlobalOverlays.tsx index df7f95e..f734194 100644 --- a/frontend/src/features/layout/GlobalOverlays.tsx +++ b/frontend/src/features/layout/GlobalOverlays.tsx @@ -36,6 +36,8 @@ interface GlobalOverlaysProps { onMerge: (pkg: VarPackage) => void; onMergeInPlace: (pkg: VarPackage) => void; onResolve: (pkg: VarPackage) => void; + onSetRating: (pkg: VarPackage, rating: number) => void; + onSetFavorite: (pkg: VarPackage, favorite: boolean) => void; } export const GlobalOverlays: React.FC = ({ @@ -44,7 +46,8 @@ export const GlobalOverlays: React.FC = ({ contextMenu, setContextMenu, selectedIds, onToggle, onOpenFolder, onInstall, onCopyPath, onCopyFiles, onCutFile, onDelete, - onMerge, onMergeInPlace, onResolve + onMerge, onMergeInPlace, onResolve, + onSetRating, onSetFavorite }) => { return ( <> @@ -68,6 +71,8 @@ export const GlobalOverlays: React.FC = ({ onMerge={onMerge} onMergeInPlace={onMergeInPlace} onResolve={onResolve} + onSetRating={onSetRating} + onSetFavorite={onSetFavorite} /> )} diff --git a/frontend/src/features/layout/SidebarContainer.tsx b/frontend/src/features/layout/SidebarContainer.tsx index 1f4cb7a..98e2e8b 100644 --- a/frontend/src/features/layout/SidebarContainer.tsx +++ b/frontend/src/features/layout/SidebarContainer.tsx @@ -23,18 +23,19 @@ export const SidebarContainer: React.FC = ({ isOpen, setI )} - {/* Sidebar Wrapper */} + {/* Sidebar Wrapper. + Width stays fixed at w-64; only transform (slide) and margin + (reclaim desktop layout space) animate — both are cheap and, unlike + animating width, never reflow the sidebar's own content mid-transition. */}
-
{/* Inner container to maintain width while parent animates */} - {children} -
+ {children}
); diff --git a/frontend/src/features/library/Sidebar.tsx b/frontend/src/features/library/Sidebar.tsx index 68d7345..defd54a 100644 --- a/frontend/src/features/library/Sidebar.tsx +++ b/frontend/src/features/library/Sidebar.tsx @@ -1,15 +1,16 @@ -import { ChevronDown, ChevronRight, Layers, Package, Settings, CheckCircle2, CircleOff, Power, Sparkles, Trash2, GripVertical, Download, AlertCircle, AlertTriangle, Copy, Unlink, Search } from 'lucide-react'; +import { ChevronDown, ChevronRight, Layers, Package, Settings, CheckCircle2, Trash2, GripVertical, Download, Sparkles, Power, Star, Heart, Boxes, Users, Tags, X } from 'lucide-react'; import { VarPackage } from '../../types'; import clsx from 'clsx'; -import { useMemo, useState, useEffect, useRef } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { Reorder, useDragControls } from "framer-motion"; import { usePackageContext } from '../../context/PackageContext'; import { useFilterContext } from '../../context/FilterContext'; import { useLibraryContext } from '../../context/LibraryContext'; import { useActionContext } from '../../context/ActionContext'; import { STATUS_FILTERS } from '../../constants'; -import { hasToken, hasField, toggleToken, clearField } from '../../utils/search'; - +import { hasToken, toggleToken, getRating, setRating } from '../../utils/search'; +import { FilterDropdown, FilterOption } from '../../components/ui/FilterDropdown'; +import { Toggle } from '../../components/ui/Toggle'; // Simple Library Item Component @@ -47,13 +48,32 @@ const SidebarLibraryItem = ({ lib, isActive, count, onSelect, onRemove }: { lib: ); }; - -const getStatusClasses = (status: 'normal' | 'warning' | 'error' | undefined, isSelected: boolean) => { - if (status === 'warning') return "bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"; - if (status === 'error') return "bg-red-500/20 text-red-400 border border-red-500/30"; - return isSelected - ? "bg-blue-600/20 text-blue-300" // Normal Selected - : "bg-gray-700 text-gray-400 group-hover:bg-gray-600"; // Normal Unselected +/** + * An exact-rating control: clicking star N filters to packages rated exactly N; + * clicking the active star again clears it. Emits `rating:N` into the shared + * query, matched against each package's stored rating (user_metadata). + */ +const RatingStars = ({ value, onChange }: { value: number, onChange: (n: number) => void }) => { + const [hover, setHover] = useState(0); + const shown = hover || value; + return ( +
setHover(0)}> + {[1, 2, 3, 4, 5].map(n => ( + + ))} + {value > 0 && ( + + )} +
+ ); }; type SidebarProps = { @@ -69,9 +89,15 @@ const Sidebar = ({ onOpenSettings }: SidebarProps) => { // The sidebar composes the same tokenised query as the searchbar: a facet is // "active" when its token is present, and clicking it toggles that token. const statusActive = (value: string) => hasToken(searchQuery, 'status', value); - const noStatus = !hasField(searchQuery, 'status'); const toggleStatus = (value: string) => setSearchQuery(toggleToken(searchQuery, 'status', value)); - const showAll = () => setSearchQuery(clearField(searchQuery, 'status')); + const anyFilterActive = searchQuery.trim().length > 0; + const clearAllFilters = () => setSearchQuery(''); + + const rating = getRating(searchQuery); + const changeRating = (n: number) => setSearchQuery(setRating(searchQuery, n === rating ? 0 : n)); + const favoriteActive = hasToken(searchQuery, 'favorite', 'true'); + const toggleFavorite = () => setSearchQuery(toggleToken(searchQuery, 'favorite', 'true')); + const { libraries, activeLibIndex, selectLibrary, removeLibrary, reorderLibraries, browseAndAdd @@ -79,23 +105,10 @@ const Sidebar = ({ onOpenSettings }: SidebarProps) => { const { handleSidebarAction, handleDeleteClick } = useActionContext(); // Local State - const [collapsed, setCollapsed] = useState({ status: false, creators: true, types: false }); const [isLibDropdownOpen, setIsLibDropdownOpen] = useState(false); const [contextMenu, setContextMenu] = useState<{ open: boolean, x: number, y: number, groupType: 'creator' | 'type' | 'status', key: string } | null>(null); const [libraryCounts, setLibraryCounts] = useState>({}); - // Creator Search State - const [creatorSearch, setCreatorSearch] = useState(""); - const [isCreatorSearchOpen, setIsCreatorSearchOpen] = useState(false); - const searchInputRef = useRef(null); - - // Auto-focus search input - useEffect(() => { - if (isCreatorSearchOpen && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [isCreatorSearchOpen]); - // Fetch Library Counts (Unified) useEffect(() => { if (!libraries || libraries.length === 0) return; @@ -128,20 +141,12 @@ const Sidebar = ({ onOpenSettings }: SidebarProps) => { } }; - // Fetch on mount, and whenever library list changes fetchCounts(); - - // Also fetch whenever package list updates (implies scan/action finished) - // This ensures counts stay largely in sync with operations - }, [libraries, packages]); // Added packages dependency to trigger refresh on scan completion + }, [libraries, packages]); const currentLibPath = libraries && libraries[activeLibIndex] ? libraries[activeLibIndex] : "No Library Selected"; const currentLibName = currentLibPath.split(/[/\\]/).pop() || "Library"; - const toggleSection = (section: 'status' | 'creators' | 'types') => { - setCollapsed(prev => ({ ...prev, [section]: !prev[section] })); - }; - const handleContextMenu = (e: React.MouseEvent, groupType: 'creator' | 'type' | 'status', key: string) => { e.preventDefault(); setContextMenu({ open: true, x: e.clientX, y: e.clientY, groupType, key }); @@ -154,8 +159,8 @@ const Sidebar = ({ onOpenSettings }: SidebarProps) => { return () => window.removeEventListener('click', close); }, []); - // Memoized Lists (Moved from Props to Context-Derived) - const types = useMemo(() => { + // Category (type) options for the Categories dropdown. + const typeOptions = useMemo(() => { const counts: Record = {}; packages.forEach(p => { // Corrupt packages have their own Status filter; exclude them here @@ -164,21 +169,26 @@ const Sidebar = ({ onOpenSettings }: SidebarProps) => { const t = p.type || 'Other'; counts[t] = (counts[t] || 0) + 1; }); - return Object.entries(counts).sort((a, b) => b[1] - a[1]); - }, [packages]); + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .map(([value, count]) => ({ value, label: value, count, tone: typeStatus[value] })); + }, [packages, typeStatus]); - const creators = useMemo(() => { + const creatorOptions = useMemo(() => { const counts: Record = {}; packages.forEach(p => { const c = p.meta.creator || "Unknown"; counts[c] = (counts[c] || 0) + 1; }); - return Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])); - }, [packages]); + return Object.entries(counts) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([value, count]) => ({ value, label: value, count, tone: creatorStatus[value] })); + }, [packages, creatorStatus]); const statusCounts = useMemo(() => { const validPkgs = packages.filter(p => !p.isCorrupt); const corruptPkgs = packages.filter(p => p.isCorrupt); + const isStandalone = (p: VarPackage) => !p.meta?.dependencies || Object.keys(p.meta.dependencies).length === 0; return { all: packages.length, @@ -187,11 +197,25 @@ const Sidebar = ({ onOpenSettings }: SidebarProps) => { missingDeps: validPkgs.filter(p => p.missingDeps && p.missingDeps.length > 0).length, versionConflicts: validPkgs.filter(p => p.isDuplicate).length, exactDuplicates: validPkgs.filter(p => p.isExactDuplicate).length, - orphans: validPkgs.filter(p => p.isOrphan).length, + removable: validPkgs.filter(p => p.isRemovable).length, + standalone: validPkgs.filter(isStandalone).length, corrupt: corruptPkgs.length }; }, [packages]); + // Status options, hidden when a bucket is empty (mirrors the old list). + const statusOptions = useMemo(() => { + const defs: { value: string, label: string, count: number, tone?: FilterOption['tone'] }[] = [ + { value: 'enabled', label: 'Enabled', count: statusCounts.enabled }, + { value: 'disabled', label: 'Disabled', count: statusCounts.disabled }, + { value: 'missing-deps', label: 'Missing Deps', count: statusCounts.missingDeps, tone: 'error' }, + { value: 'version-conflicts', label: 'Conflicts', count: statusCounts.versionConflicts, tone: 'warning' }, + { value: 'exact-duplicates', label: 'Duplicates', count: statusCounts.exactDuplicates, tone: 'warning' }, + { value: 'corrupt', label: 'Corrupt', count: statusCounts.corrupt, tone: 'error' }, + ]; + return defs.filter(d => d.count > 0); + }, [statusCounts]); + return (