From 908bc1823453793c543ba91bee16ed6ca92ebbe8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 10 May 2026 21:47:15 -0700 Subject: [PATCH] feat(sidebar-filter): replace dropdown with searchable popover for repo filtering (#1684) Replaces the DropdownMenu-based repo filter with a Command/Popover combo that supports live search, All/None bulk actions, and a Clear all footer. Scales to large repo sets without scroll friction. Design doc added at docs/sidebar-filter-redesign.md. Co-authored-by: Orca --- docs/sidebar-filter-redesign.md | 87 +++++ .../src/components/sidebar/SidebarFilter.tsx | 302 +++++++++++++----- 2 files changed, 302 insertions(+), 87 deletions(-) create mode 100644 docs/sidebar-filter-redesign.md diff --git a/docs/sidebar-filter-redesign.md b/docs/sidebar-filter-redesign.md new file mode 100644 index 000000000..4bce607be --- /dev/null +++ b/docs/sidebar-filter-redesign.md @@ -0,0 +1,87 @@ +# Sidebar Repo Filter Redesign + +## Problem + +The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction. + +Constraints from current code: +- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`. +- `searchRepos()` already provides ranking (display name first, path fallback). +- The trigger badge is the primary “filters active” affordance and must stay. + +## Goals + +1. Make repo filtering usable with large repo sets. +2. Keep toggle filters one click. +3. Add fast bulk actions (`All`, `None`, `Clear all`). +4. Preserve existing filter semantics and badge behavior. + +## Non-goals + +- No semantic changes to filtering logic in `visible-worktrees.ts`. +- No sticky-all persistence model like `RepoMultiCombobox`. +- No store schema migration. + +## Correctness Notes (from code audit) + +- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering. +- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path. +- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents. +- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch. + +## Proposed UI + +Use `Popover` and keep trigger button/badge unchanged. + +Popover sections: +1. Header: `Filters` + `Clear all` (shown only when any filter is active). +2. Toggle rows: `Active only`, `Hide default branch`. +3. Repo section (only when `repos.length > 1`): + - Label + selected count. + - `All` and `None` actions. + - Search box (`Search repos...`). + - Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists. + - Secondary path line for disambiguation when names collide. +4. Bottom action: `Add project` pinned below repo list. + +## Implementation Decision: cmdk vs plain input + +Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results. + +Why: +- Better keyboard behavior out of the box (arrow navigation, enter selection). +- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions. +- Consistent behavior with an existing repo selector pattern in the app. + +## Edge Cases / Invalidation + +- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count. +- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render). +- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`. +- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name. +- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states. +- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization. + +## Accessibility / Focus + +- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators. +- Autofocus search on open. +- Esc closes via Radix Popover default. +- Ensure focus returns to trigger on close. + +## Rollout + +1. Replace sidebar filter content with Popover + Command-based repo list. +2. Keep `searchRepos()` as the only ranking source. +3. Keep current filter setters and badge derivation semantics. +4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container). +5. Validate with `pnpm typecheck` and `pnpm lint`. + +## Test Scope + +- Unit coverage remains in `visible-worktrees` for filter semantics. +- Add/adjust component tests for: + - keyboard navigation and selection, + - stale repo id handling, + - `All`/`None` behavior with live repo mutations, + - `Add project` visibility at repo count 0/1. diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index f9068f14f..8c98bfad6 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,18 +1,19 @@ -import React, { useCallback } from 'react' -import { Activity, GitBranch, ListFilter, FolderPlus, X } from 'lucide-react' +import React, { useCallback, useMemo, useState } from 'react' +import { Activity, Check, GitBranch, ListFilter, FolderPlus, Server, X } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoDotLabel from '@/components/repo/RepoDotLabel' +import { searchRepos } from '@/lib/repo-search' +import { cn } from '@/lib/utils' const SidebarFilter = React.memo(function SidebarFilter() { const showActiveOnly = useAppStore((s) => s.showActiveOnly) @@ -24,6 +25,17 @@ const SidebarFilter = React.memo(function SidebarFilter() { const repos = useAppStore((s) => s.repos) const addRepo = useAppStore((s) => s.addRepo) + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [commandValue, setCommandValue] = useState('') + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next) + if (!next) { + setQuery('') + } + }, []) + const handleToggleRepo = useCallback( (repoId: string) => { setFilterRepoIds( @@ -35,29 +47,46 @@ const SidebarFilter = React.memo(function SidebarFilter() { [filterRepoIds, setFilterRepoIds] ) - const handleToggleActive = useCallback( - () => setShowActiveOnly(!showActiveOnly), - [showActiveOnly, setShowActiveOnly] - ) - const handleToggleHideDefaultBranch = useCallback( - () => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace), - [hideDefaultBranchWorkspace, setHideDefaultBranchWorkspace] - ) const canFilterRepos = repos.length > 1 - // Why: derive from the current repos list so stale IDs in filterRepoIds - // (e.g. lingering after a repo is removed) don't inflate the active-filter - // count or falsely signal an applied filter. - const selectedRepos = canFilterRepos ? repos.filter((r) => filterRepoIds.includes(r.id)) : [] - const hasRepoFilter = selectedRepos.length > 0 + // Why: derive from current repos so stale ids (e.g. lingering after a repo + // is removed) don't inflate counts or falsely signal an applied filter. + const selectedRepoIdSet = useMemo(() => { + const set = new Set() + for (const r of repos) { + if (filterRepoIds.includes(r.id)) { + set.add(r.id) + } + } + return set + }, [repos, filterRepoIds]) + const selectedCount = selectedRepoIdSet.size + const hasRepoFilter = selectedCount > 0 const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter const activeFilterCount = - (showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedRepos.length + (showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + + const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) + const allSelected = canFilterRepos && selectedCount === repos.length + + const clearAll = useCallback(() => { + setShowActiveOnly(false) + setHideDefaultBranchWorkspace(false) + setFilterRepoIds([]) + }, [setShowActiveOnly, setHideDefaultBranchWorkspace, setFilterRepoIds]) + + // Why: derive ids from the live repos list at click time so a repo added + // while the popover is open is included immediately. + const selectAllRepos = useCallback(() => { + setFilterRepoIds(repos.map((r) => r.id)) + }, [repos, setFilterRepoIds]) + + const clearRepos = useCallback(() => setFilterRepoIds([]), [setFilterRepoIds]) return ( - + - + - + {hasAnyFilter ? 'Edit filters' : 'Filter workspaces'} - - event.preventDefault()} - > - - Active only - - event.preventDefault()} - > - - Hide default branch - - {canFilterRepos && ( - <> - - Repositories - {repos.map((r) => ( - handleToggleRepo(r.id)} - onSelect={(event) => event.preventDefault()} - > - - - ))} - - )} - {hasAnyFilter && ( - <> - - { - setShowActiveOnly(false) - setHideDefaultBranchWorkspace(false) - setFilterRepoIds([]) - }} + +
+ Filters + {hasAnyFilter ? ( + + ) : null} +
+ +
+ } + label="Active only" + checked={showActiveOnly} + onClick={() => setShowActiveOnly(!showActiveOnly)} + /> + } + label="Hide default branch" + checked={hideDefaultBranchWorkspace} + onClick={() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace)} + /> +
+ {canFilterRepos && ( - <> - - { - addRepo() - }} +
+
+ + Repositories + {hasRepoFilter && ( + + {selectedCount} selected + + )} + +
+ + · + +
+
+ + - - Add project - - + + + No repos match + {filteredRepos.map((r) => { + const checked = selectedRepoIdSet.has(r.id) + return ( + handleToggleRepo(r.id)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + + + + {r.connectionId && ( + + + SSH + + )} + + + ) + })} + + +
)} -
-
+ + {/* Why: per design, "Add project" stays visible regardless of repo + count so users can recover from the 0/1-repo state where the + repo section is hidden. */} +
+ +
+ + ) }) +type ToggleRowProps = { + icon: React.ReactNode + label: string + checked: boolean + onClick: () => void +} + +function ToggleRow({ icon, label, checked, onClick }: ToggleRowProps) { + // Why: the popover is not a true menu, so we use a plain button with + // aria-pressed rather than role="menuitemcheckbox". The visible checkmark + // carries the state for sighted users. + return ( + + ) +} + export default SidebarFilter