diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 0154701b0..d3c4d5157 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -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() {
- - - - - - - - - - View options - - - - Group by -
- { - if (v) { - setGroupBy(v as typeof groupBy) - } - }} - variant="outline" - size="sm" - className="h-6 w-full justify-start" - > - {GROUP_BY_OPTIONS.map((opt) => ( - - {opt.label} - - ))} - -
- - - Sort by - setSortBy(v as typeof sortBy)} - > - {SORT_OPTIONS.map((opt) => { - const radioItem = ( - e.preventDefault()} - > - {opt.label} - - ) - if (!opt.description) { - return radioItem - } - return ( - - {radioItem} - - {opt.description} - - - ) - })} - - - - Show properties - {PROPERTY_OPTIONS.map((opt) => ( - toggleWorktreeCardProperty(opt.id)} - onSelect={(e) => e.preventDefault()} - > - {opt.label} - - ))} -
-
+ diff --git a/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx new file mode 100644 index 000000000..63cba1a8a --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx @@ -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() + 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) => { + // 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 ( + <> + + + + + 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" + /> + + + {hasRepoFilter ? 'No unselected projects match' : 'No projects match'} + + {availableRepos.map((repo) => ( + 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" + > + + + {repo.connectionId && ( + + + SSH + + )} + + + ))} + + + + ) +}) + +function SelectedProjectPills({ + selectedRepos, + onRemoveRepo +}: { + selectedRepos: Repo[] + onRemoveRepo: (repoId: string) => void +}) { + if (selectedRepos.length === 0) { + return null + } + + return ( +
+ {selectedRepos.map((repo) => ( + + + + + ))} +
+ ) +} + +function ProjectFilterHeader({ + hasRepoFilter, + selectedCount, + onClear +}: { + hasRepoFilter: boolean + selectedCount: number + onClear: () => void +}) { + return ( +
+ + Projects + {hasRepoFilter && ( + + {selectedCount} + + )} + + +
+ ) +} + +export default SidebarRepositoryFilterSection diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx new file mode 100644 index 000000000..ffe0a610a --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -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 ( + <> +
+ Filters +
+ } + label="Active only" + checked={showActiveOnly} + onChange={setShowActiveOnly} + /> + } + 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 ( + + ) +} + +export default SidebarWorkspaceFilterSection diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx new file mode 100644 index 000000000..31893d0e0 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -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 ( + + + + + + + + + {hasAnyFilter ? `Workspace options (${activeFilterLabel})` : 'Workspace options'} + + + + Group by +
+ { + if (v) { + setGroupBy(v as typeof groupBy) + } + }} + variant="outline" + size="sm" + className="h-6 w-full justify-stretch" + > + {GROUP_BY_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + +
+ + + + + + Sort by + {sortLabel} + + + + setSortBy(v as typeof sortBy)} + > + {SORT_OPTIONS.map((opt) => { + const radioItem = ( + e.preventDefault()} + > + {opt.label} + + ) + if (!opt.description) { + return radioItem + } + return ( + + {radioItem} + + {opt.description} + + + ) + })} + + + + + + + + + + + + Show properties + {visiblePropertyCount > 0 && ( + + {visiblePropertyCount} + + )} + + + + {PROPERTY_OPTIONS.map((opt) => ( + toggleWorktreeCardProperty(opt.id)} + onSelect={(e) => e.preventDefault()} + > + {opt.label} + + ))} + + + + + +
+
+ ) +}) + +export default SidebarWorkspaceOptionsMenu