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 <help@stably.ai>
This commit is contained in:
Jinjing 2026-05-10 21:47:15 -07:00 committed by GitHub
parent f446a8b460
commit 908bc18234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 302 additions and 87 deletions

View File

@ -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.

View File

@ -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<string>()
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 (
<DropdownMenu>
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
@ -79,77 +108,176 @@ const SidebarFilter = React.memo(function SidebarFilter() {
</span>
)}
</Button>
</DropdownMenuTrigger>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{hasAnyFilter ? 'Edit filters' : 'Filter workspaces'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuCheckboxItem
checked={showActiveOnly}
onCheckedChange={handleToggleActive}
onSelect={(event) => event.preventDefault()}
>
<Activity className="size-3.5 text-muted-foreground" />
Active only
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hideDefaultBranchWorkspace}
onCheckedChange={handleToggleHideDefaultBranch}
onSelect={(event) => event.preventDefault()}
>
<GitBranch className="size-3.5 text-muted-foreground" />
Hide default branch
</DropdownMenuCheckboxItem>
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
{repos.map((r) => (
<DropdownMenuCheckboxItem
key={r.id}
checked={filterRepoIds.includes(r.id)}
onCheckedChange={() => handleToggleRepo(r.id)}
onSelect={(event) => event.preventDefault()}
>
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
</DropdownMenuCheckboxItem>
))}
</>
)}
{hasAnyFilter && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setShowActiveOnly(false)
setHideDefaultBranchWorkspace(false)
setFilterRepoIds([])
}}
<PopoverContent align="end" className="w-72 p-0">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-medium text-foreground">Filters</span>
{hasAnyFilter ? (
<button
type="button"
onClick={clearAll}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
>
<X className="size-3.5 text-muted-foreground" />
Clear filters
</DropdownMenuItem>
</>
)}
<X className="size-3" />
Clear all
</button>
) : null}
</div>
<div className="border-t border-border/60">
<ToggleRow
icon={<Activity className="size-3.5" />}
label="Active only"
checked={showActiveOnly}
onClick={() => setShowActiveOnly(!showActiveOnly)}
/>
<ToggleRow
icon={<GitBranch className="size-3.5" />}
label="Hide default branch"
checked={hideDefaultBranchWorkspace}
onClick={() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace)}
/>
</div>
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
inset
onSelect={() => {
addRepo()
}}
<div className="border-t border-border/60">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Repositories
{hasRepoFilter && (
<span className="ml-1.5 text-foreground normal-case tracking-normal">
{selectedCount} selected
</span>
)}
</span>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<button
type="button"
onClick={selectAllRepos}
className="hover:text-foreground disabled:opacity-40"
disabled={allSelected}
>
All
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={clearRepos}
className="hover:text-foreground disabled:opacity-40"
disabled={!hasRepoFilter}
>
None
</button>
</div>
</div>
<Command
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
className="bg-transparent"
>
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
Add project
</DropdownMenuItem>
</>
<CommandInput
autoFocus
placeholder="Search repos..."
value={query}
onValueChange={setQuery}
className="h-8 py-2 text-xs"
wrapperClassName="px-3"
iconClassName="h-3.5 w-3.5"
/>
<CommandList className="max-h-64">
<CommandEmpty className="py-4 text-[11px]">No repos match</CommandEmpty>
{filteredRepos.map((r) => {
const checked = selectedRepoIdSet.has(r.id)
return (
<CommandItem
key={r.id}
value={r.id}
onSelect={() => handleToggleRepo(r.id)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 shrink-0 text-muted-foreground',
checked ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<RepoDotLabel
name={r.displayName}
color={r.badgeColor}
className="max-w-full"
/>
{r.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Server className="size-2.5" />
SSH
</span>
)}
</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* 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. */}
<div className="border-t border-border/60">
<button
type="button"
onClick={() => addRepo()}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<FolderPlus className="size-3.5" />
Add project
</button>
</div>
</PopoverContent>
</Popover>
)
})
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 (
<button
type="button"
onClick={onClick}
aria-pressed={checked}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground',
checked ? 'text-foreground' : 'text-muted-foreground'
)}
>
<Check
className={cn(
'size-3 shrink-0 text-muted-foreground',
checked ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-muted-foreground">{icon}</span>
<span className={cn(checked && 'text-foreground')}>{label}</span>
</button>
)
}
export default SidebarFilter