Combine sidebar workspace options
This commit is contained in:
parent
e88b4d1a16
commit
4f2e159f64
|
|
@ -1,49 +1,12 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Kanban, Plus, SlidersHorizontal } from 'lucide-react'
|
||||
import { Kanban, Plus } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { WorktreeCardProperty } from '../../../../shared/types'
|
||||
import SidebarFilter from './SidebarFilter'
|
||||
import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu'
|
||||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
|
||||
const GROUP_BY_OPTIONS = [
|
||||
{ id: 'none', label: 'None' },
|
||||
{ id: 'workspace-status', label: 'Status' },
|
||||
{ id: 'pr-status', label: 'PR' },
|
||||
{ id: 'repo', label: 'Repo' }
|
||||
] as const
|
||||
|
||||
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
|
||||
// Why: toggles the inline "Agent activity" list rendered below each
|
||||
// workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
|
||||
// the list; there is no alternate surface.
|
||||
{ id: 'inline-agents', label: 'Agent activity' }
|
||||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ id: 'name', label: 'Name', description: null },
|
||||
{
|
||||
id: 'smart',
|
||||
label: 'Smart',
|
||||
description: 'Agents that need attention, then most recent activity.'
|
||||
},
|
||||
{ id: 'recent', label: 'Recent', description: null },
|
||||
{ id: 'repo', label: 'Repo', description: null }
|
||||
] as const
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
|
||||
|
||||
|
|
@ -54,13 +17,6 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
const repos = useAppStore((s) => s.repos)
|
||||
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
|
||||
|
||||
const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
|
||||
const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
const setSortBy = useAppStore((s) => s.setSortBy)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const setGroupBy = useAppStore((s) => s.setGroupBy)
|
||||
|
||||
const handleWorkspaceBoardOpenChange = useCallback((open: boolean) => {
|
||||
setWorkspaceBoardOpen(open)
|
||||
if (!open) {
|
||||
|
|
@ -116,105 +72,10 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<SidebarFilter preserveWorkspaceBoardOpen onMenuOpenChange={setWorkspaceBoardMenuOpen} />
|
||||
<DropdownMenu modal={false} onOpenChange={setWorkspaceBoardMenuOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label="View options"
|
||||
data-workspace-board-preserve-open=""
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" strokeWidth={2.25} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
View options
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-56 pb-2"
|
||||
data-workspace-board-preserve-open=""
|
||||
>
|
||||
<DropdownMenuLabel>Group by</DropdownMenuLabel>
|
||||
<div className="px-2 pt-0.5 pb-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={groupBy}
|
||||
onValueChange={(v) => {
|
||||
if (v) {
|
||||
setGroupBy(v as typeof groupBy)
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-full justify-start"
|
||||
>
|
||||
{GROUP_BY_OPTIONS.map((opt) => (
|
||||
<ToggleGroupItem
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
className="h-6 px-2 text-[10px] data-[state=on]:bg-foreground/10 data-[state=on]:font-semibold data-[state=on]:text-foreground"
|
||||
>
|
||||
{opt.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy}
|
||||
onValueChange={(v) => setSortBy(v as typeof sortBy)}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const radioItem = (
|
||||
<DropdownMenuRadioItem
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
// Keep the menu open so people can compare sort modes and
|
||||
// toggle card properties without reopening the same panel.
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
if (!opt.description) {
|
||||
return radioItem
|
||||
}
|
||||
return (
|
||||
<Tooltip key={opt.id}>
|
||||
<TooltipTrigger asChild>{radioItem}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{opt.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Show properties</DropdownMenuLabel>
|
||||
{PROPERTY_OPTIONS.map((opt) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={opt.id}
|
||||
checked={worktreeCardProperties.includes(opt.id)}
|
||||
onCheckedChange={() => toggleWorktreeCardProperty(opt.id)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SidebarWorkspaceOptionsMenu
|
||||
preserveWorkspaceBoardOpen
|
||||
onMenuOpenChange={setWorkspaceBoardMenuOpen}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Server, X } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
import { searchRepos } from '@/lib/repo-search'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
|
||||
function projectCommandFilter(_value: string, search: string, keywords?: string[]): number {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const [displayName = '', path = ''] = keywords ?? []
|
||||
const displayNameIndex = displayName.toLowerCase().indexOf(query)
|
||||
if (displayNameIndex !== -1) {
|
||||
return 2 + 1 / (displayNameIndex + 1)
|
||||
}
|
||||
|
||||
const pathIndex = path.toLowerCase().indexOf(query)
|
||||
if (pathIndex !== -1) {
|
||||
return 1 + 1 / (pathIndex + 1)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilterSection() {
|
||||
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
|
||||
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightedRepoId, setHighlightedRepoId] = useState('')
|
||||
|
||||
const canFilterRepos = repos.length > 1
|
||||
// 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 repo of repos) {
|
||||
if (filterRepoIds.includes(repo.id)) {
|
||||
set.add(repo.id)
|
||||
}
|
||||
}
|
||||
return set
|
||||
}, [repos, filterRepoIds])
|
||||
const selectedCount = selectedRepoIdSet.size
|
||||
const hasRepoFilter = selectedCount > 0
|
||||
const selectedRepos = useMemo(
|
||||
() => repos.filter((repo) => selectedRepoIdSet.has(repo.id)),
|
||||
[repos, selectedRepoIdSet]
|
||||
)
|
||||
const availableRepos = useMemo(
|
||||
() => repos.filter((repo) => !selectedRepoIdSet.has(repo.id)),
|
||||
[repos, selectedRepoIdSet]
|
||||
)
|
||||
const matchingAvailableRepos = useMemo(
|
||||
() => searchRepos(availableRepos, query),
|
||||
[availableRepos, query]
|
||||
)
|
||||
|
||||
const handleSelectRepo = useCallback(
|
||||
(repoId: string) => {
|
||||
if (!filterRepoIds.includes(repoId)) {
|
||||
setFilterRepoIds([...filterRepoIds, repoId])
|
||||
}
|
||||
setQuery('')
|
||||
},
|
||||
[filterRepoIds, setFilterRepoIds]
|
||||
)
|
||||
|
||||
const handleRemoveRepo = useCallback(
|
||||
(repoId: string) => {
|
||||
setFilterRepoIds(filterRepoIds.filter((id) => id !== repoId))
|
||||
},
|
||||
[filterRepoIds, setFilterRepoIds]
|
||||
)
|
||||
|
||||
const clearRepos = useCallback(() => setFilterRepoIds([]), [setFilterRepoIds])
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Why: this command is embedded in a Radix dropdown; text keys should
|
||||
// stay in the search field instead of triggering menu typeahead.
|
||||
if (event.key === 'Backspace' && query === '' && selectedRepos.length > 0) {
|
||||
const lastRepo = selectedRepos.at(-1)
|
||||
if (lastRepo) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleRemoveRepo(lastRepo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const highlightedRepo = availableRepos.find((repo) => repo.id === highlightedRepoId)
|
||||
const repo = highlightedRepo ?? matchingAvailableRepos[0]
|
||||
if (repo) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleSelectRepo(repo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
[
|
||||
availableRepos,
|
||||
handleRemoveRepo,
|
||||
handleSelectRepo,
|
||||
highlightedRepoId,
|
||||
matchingAvailableRepos,
|
||||
query,
|
||||
selectedRepos
|
||||
]
|
||||
)
|
||||
|
||||
if (!canFilterRepos) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectFilterHeader
|
||||
hasRepoFilter={hasRepoFilter}
|
||||
selectedCount={selectedCount}
|
||||
onClear={clearRepos}
|
||||
/>
|
||||
|
||||
<Command
|
||||
filter={projectCommandFilter}
|
||||
onValueChange={setHighlightedRepoId}
|
||||
className="bg-transparent"
|
||||
>
|
||||
<SelectedProjectPills selectedRepos={selectedRepos} onRemoveRepo={handleRemoveRepo} />
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder={selectedRepos.length > 0 ? 'Add project...' : 'Filter projects...'}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
className="h-8 py-2 text-xs"
|
||||
wrapperClassName="mx-1 rounded-[7px] border border-border/70 px-2"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
/>
|
||||
<CommandList className="max-h-40 py-1">
|
||||
<CommandEmpty className="py-4 text-[11px]">
|
||||
{hasRepoFilter ? 'No unselected projects match' : 'No projects match'}
|
||||
</CommandEmpty>
|
||||
{availableRepos.map((repo) => (
|
||||
<CommandItem
|
||||
key={repo.id}
|
||||
value={repo.id}
|
||||
keywords={[repo.displayName, repo.path]}
|
||||
onSelect={() => handleSelectRepo(repo.id)}
|
||||
className="mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1 text-[12px] leading-5 font-medium data-[selected=true]:bg-black/8 dark:data-[selected=true]:bg-white/14"
|
||||
>
|
||||
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<RepoDotLabel
|
||||
name={repo.displayName}
|
||||
color={repo.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
{repo.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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function SelectedProjectPills({
|
||||
selectedRepos,
|
||||
onRemoveRepo
|
||||
}: {
|
||||
selectedRepos: Repo[]
|
||||
onRemoveRepo: (repoId: string) => void
|
||||
}) {
|
||||
if (selectedRepos.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scrollbar-sleek mx-1 mb-1 flex max-h-16 flex-wrap gap-1 overflow-y-auto rounded-[7px] border border-border/70 bg-muted/25 p-1">
|
||||
{selectedRepos.map((repo) => (
|
||||
<Badge
|
||||
key={repo.id}
|
||||
variant="outline"
|
||||
className="h-5 max-w-full gap-1 border-border/70 bg-background px-1.5 py-0 text-[11px] font-medium"
|
||||
>
|
||||
<RepoDotLabel
|
||||
name={repo.displayName}
|
||||
color={repo.badgeColor}
|
||||
className="max-w-[8rem]"
|
||||
dotClassName="size-1.5"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Remove ${repo.displayName} filter`}
|
||||
className="-mr-1 size-4 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onRemoveRepo(repo.id)}
|
||||
>
|
||||
<X className="size-2.5" strokeWidth={2.5} />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectFilterHeader({
|
||||
hasRepoFilter,
|
||||
selectedCount,
|
||||
onClear
|
||||
}: {
|
||||
hasRepoFilter: boolean
|
||||
selectedCount: number
|
||||
onClear: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground">
|
||||
Projects
|
||||
{hasRepoFilter && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 min-w-4 px-1 py-0 text-[10px] font-semibold leading-none text-foreground"
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-full px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
disabled={!hasRepoFilter}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarRepositoryFilterSection
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react'
|
||||
import { Activity, GitBranch } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() {
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground">Filters</span>
|
||||
</div>
|
||||
<FilterToggleRow
|
||||
icon={<Activity className="size-3.5" />}
|
||||
label="Active only"
|
||||
checked={showActiveOnly}
|
||||
onChange={setShowActiveOnly}
|
||||
/>
|
||||
<FilterToggleRow
|
||||
icon={<GitBranch className="size-3.5" />}
|
||||
label="Hide default branch"
|
||||
checked={hideDefaultBranchWorkspace}
|
||||
onChange={setHideDefaultBranchWorkspace}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function FilterToggleRow({
|
||||
icon,
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12px] font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-foreground">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
|
||||
checked ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
|
||||
checked && 'translate-x-2.5'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarWorkspaceFilterSection
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { WorktreeCardProperty } from '../../../../shared/types'
|
||||
import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection'
|
||||
import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection'
|
||||
|
||||
type SidebarWorkspaceOptionsMenuProps = {
|
||||
preserveWorkspaceBoardOpen?: boolean
|
||||
onMenuOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const GROUP_BY_OPTIONS = [
|
||||
{ id: 'none', label: 'None' },
|
||||
{ id: 'workspace-status', label: 'Status' },
|
||||
{ id: 'pr-status', label: 'PR' },
|
||||
{ id: 'repo', label: 'Repo' }
|
||||
] as const
|
||||
|
||||
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
|
||||
// Why: toggles the inline "Agent activity" list rendered below each
|
||||
// workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
|
||||
// the list; there is no alternate surface.
|
||||
{ id: 'inline-agents', label: 'Agent activity' }
|
||||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ id: 'name', label: 'Name', description: null },
|
||||
{
|
||||
id: 'smart',
|
||||
label: 'Smart',
|
||||
description: 'Agents that need attention, then most recent activity.'
|
||||
},
|
||||
{ id: 'recent', label: 'Recent', description: null },
|
||||
{ id: 'repo', label: 'Repo', description: null }
|
||||
] as const
|
||||
|
||||
const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({
|
||||
preserveWorkspaceBoardOpen = false,
|
||||
onMenuOpenChange
|
||||
}: SidebarWorkspaceOptionsMenuProps) {
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
|
||||
const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
const setSortBy = useAppStore((s) => s.setSortBy)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const setGroupBy = useAppStore((s) => s.setGroupBy)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setOpen(next)
|
||||
onMenuOpenChange?.(next)
|
||||
},
|
||||
[onMenuOpenChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onMenuOpenChange?.(false)
|
||||
}
|
||||
}, [onMenuOpenChange])
|
||||
|
||||
// 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 selectedCount = useMemo(() => {
|
||||
let count = 0
|
||||
for (const repo of repos) {
|
||||
if (filterRepoIds.includes(repo.id)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, [repos, filterRepoIds])
|
||||
const hasRepoFilter = selectedCount > 0
|
||||
const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter
|
||||
const activeFilterCount =
|
||||
(showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
|
||||
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
|
||||
const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort'
|
||||
const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) =>
|
||||
worktreeCardProperties.includes(opt.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
className="relative text-muted-foreground"
|
||||
aria-label={
|
||||
hasAnyFilter
|
||||
? `Workspace options (${activeFilterLabel} active)`
|
||||
: 'Workspace options'
|
||||
}
|
||||
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" strokeWidth={2.25} />
|
||||
{hasAnyFilter && (
|
||||
// Why: this combined options button now owns filtering, so it
|
||||
// needs the same at-a-glance signal that the old filter button had.
|
||||
<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 ? `Workspace options (${activeFilterLabel})` : 'Workspace options'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-72 pb-2"
|
||||
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
|
||||
>
|
||||
<DropdownMenuLabel>Group by</DropdownMenuLabel>
|
||||
<div className="px-2 pt-0.5 pb-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={groupBy}
|
||||
onValueChange={(v) => {
|
||||
if (v) {
|
||||
setGroupBy(v as typeof groupBy)
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-full justify-stretch"
|
||||
>
|
||||
{GROUP_BY_OPTIONS.map((opt) => (
|
||||
<ToggleGroupItem
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
className="h-6 grow basis-0 px-1 text-[10px] data-[state=on]:bg-foreground/10 data-[state=on]:font-semibold data-[state=on]:text-foreground"
|
||||
>
|
||||
{opt.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex flex-1 items-center justify-between">
|
||||
<span>Sort by</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">{sortLabel}</span>
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="w-44"
|
||||
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy}
|
||||
onValueChange={(v) => setSortBy(v as typeof sortBy)}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const radioItem = (
|
||||
<DropdownMenuRadioItem
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
// Keep the menu open so people can compare sort modes and
|
||||
// toggle card properties without reopening the same panel.
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
if (!opt.description) {
|
||||
return radioItem
|
||||
}
|
||||
return (
|
||||
<Tooltip key={opt.id}>
|
||||
<TooltipTrigger asChild>{radioItem}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{opt.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<SidebarWorkspaceFilterSection />
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex flex-1 items-center justify-between">
|
||||
<span>Show properties</span>
|
||||
{visiblePropertyCount > 0 && (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{visiblePropertyCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="w-48"
|
||||
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
|
||||
>
|
||||
{PROPERTY_OPTIONS.map((opt) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={opt.id}
|
||||
checked={worktreeCardProperties.includes(opt.id)}
|
||||
onCheckedChange={() => toggleWorktreeCardProperty(opt.id)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<SidebarRepositoryFilterSection />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
export default SidebarWorkspaceOptionsMenu
|
||||
Loading…
Reference in New Issue