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:
parent
f446a8b460
commit
908bc18234
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue