mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
feat(sidebar): move worktree filter out of search bar into header (#1303)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,76 +1,14 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Search, X, Activity, ListFilter, FolderPlus } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
|
||||
const SearchBar = React.memo(function SearchBar() {
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
|
||||
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
|
||||
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
const selectedRepos = repos.filter((r) => filterRepoIds.includes(r.id))
|
||||
|
||||
const handleToggleRepo = useCallback(
|
||||
(repoId: string) => {
|
||||
setFilterRepoIds(
|
||||
filterRepoIds.includes(repoId)
|
||||
? filterRepoIds.filter((id) => id !== repoId)
|
||||
: [...filterRepoIds, repoId]
|
||||
)
|
||||
},
|
||||
[filterRepoIds, setFilterRepoIds]
|
||||
)
|
||||
|
||||
const handleClear = useCallback(() => setSearchQuery(''), [setSearchQuery])
|
||||
const handleToggleActive = useCallback(
|
||||
() => setShowActiveOnly(!showActiveOnly),
|
||||
[showActiveOnly, setShowActiveOnly]
|
||||
)
|
||||
const canFilterRepos = repos.length > 1
|
||||
const hasRepoFilter = canFilterRepos && selectedRepos.length > 0
|
||||
const hasAnyFilter = showActiveOnly || hasRepoFilter
|
||||
const activeFilterCount = (showActiveOnly ? 1 : 0) + (hasRepoFilter ? selectedRepos.length : 0)
|
||||
|
||||
const filterSummary = (() => {
|
||||
if (!hasAnyFilter) {
|
||||
return null
|
||||
}
|
||||
if (showActiveOnly && !hasRepoFilter) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="size-3" />
|
||||
<span>Active</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!showActiveOnly && hasRepoFilter && selectedRepos.length === 1) {
|
||||
return (
|
||||
<RepoDotLabel
|
||||
name={selectedRepos[0].displayName}
|
||||
color={selectedRepos[0].badgeColor}
|
||||
dotClassName="size-1"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <span>{activeFilterCount} filters</span>
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-1">
|
||||
@@ -83,96 +21,18 @@ const SearchBar = React.memo(function SearchBar() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-7 pl-7 pr-20 text-[12px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30 placeholder:text-muted-foreground/70"
|
||||
className="h-7 pl-7 pr-8 text-[12px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30 placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
<div className="absolute right-1 flex items-center gap-0.5">
|
||||
{searchQuery && (
|
||||
<Button variant="ghost" size="icon-xs" onClick={handleClear} className="size-5">
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
aria-label="Filter worktrees"
|
||||
className={cn(
|
||||
'h-5 gap-1 border-none px-1 text-[10px] font-normal shadow-none focus-visible:ring-0',
|
||||
hasAnyFilter
|
||||
? 'w-auto bg-accent text-accent-foreground hover:bg-accent/80'
|
||||
: 'size-5 w-5 bg-transparent hover:bg-accent/60'
|
||||
)}
|
||||
>
|
||||
<ListFilter className="size-3 shrink-0" strokeWidth={2.25} />
|
||||
{filterSummary}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{hasAnyFilter ? 'Edit filters' : 'Filter worktrees'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showActiveOnly}
|
||||
onCheckedChange={handleToggleActive}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
Active only
|
||||
</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)
|
||||
setFilterRepoIds([])
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5 text-muted-foreground" />
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canFilterRepos && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
inset
|
||||
onSelect={() => {
|
||||
addRepo()
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
Add project
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleClear}
|
||||
className="absolute right-1 size-5"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Activity, ListFilter, FolderPlus, 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'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
|
||||
const SidebarFilter = React.memo(function SidebarFilter() {
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
|
||||
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
|
||||
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
|
||||
const handleToggleRepo = useCallback(
|
||||
(repoId: string) => {
|
||||
setFilterRepoIds(
|
||||
filterRepoIds.includes(repoId)
|
||||
? filterRepoIds.filter((id) => id !== repoId)
|
||||
: [...filterRepoIds, repoId]
|
||||
)
|
||||
},
|
||||
[filterRepoIds, setFilterRepoIds]
|
||||
)
|
||||
|
||||
const handleToggleActive = useCallback(
|
||||
() => setShowActiveOnly(!showActiveOnly),
|
||||
[showActiveOnly, setShowActiveOnly]
|
||||
)
|
||||
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
|
||||
const hasAnyFilter = showActiveOnly || hasRepoFilter
|
||||
const activeFilterCount = (showActiveOnly ? 1 : 0) + selectedRepos.length
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
aria-label={
|
||||
hasAnyFilter ? `Edit filters (${activeFilterCount} active)` : 'Filter workspaces'
|
||||
}
|
||||
className="relative text-muted-foreground"
|
||||
>
|
||||
<ListFilter className="size-3.5" strokeWidth={2.25} />
|
||||
{hasAnyFilter && (
|
||||
// Why: the only at-a-glance affordance that filters are
|
||||
// applied — without it the list can silently hide workspaces.
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -top-0.5 -right-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-medium leading-none text-primary-foreground"
|
||||
>
|
||||
{activeFilterCount > 9 ? '9+' : activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{hasAnyFilter ? 'Edit filters' : 'Filter workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showActiveOnly}
|
||||
onCheckedChange={handleToggleActive}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
Active only
|
||||
</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)
|
||||
setFilterRepoIds([])
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5 text-muted-foreground" />
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canFilterRepos && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
inset
|
||||
onSelect={() => {
|
||||
addRepo()
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
Add project
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
export default SidebarFilter
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DropdownMenuRadioItem
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { WorktreeCardProperty } from '../../../../shared/types'
|
||||
import SidebarFilter from './SidebarFilter'
|
||||
|
||||
const GROUP_BY_OPTIONS = [
|
||||
{ id: 'none', label: 'All' },
|
||||
@@ -72,6 +73,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
Workspaces
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<SidebarFilter />
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
Reference in New Issue
Block a user