From 15fea4b359ec12905b042e2ef03c43e52ba792bc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:57:44 -0700 Subject: [PATCH] groups --- .../components/sidebar/AddWorktreeDialog.tsx | 14 +- .../components/sidebar/StatusIndicator.tsx | 2 +- .../src/components/sidebar/WorktreeList.tsx | 238 +++++++++++++++--- 3 files changed, 219 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx b/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx index 6a5b9a91749..8b89d9bd554 100644 --- a/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/AddWorktreeDialog.tsx @@ -22,6 +22,7 @@ import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' const AddWorktreeDialog = React.memo(function AddWorktreeDialog() { const activeModal = useAppStore((s) => s.activeModal) + const modalData = useAppStore((s) => s.modalData) const closeModal = useAppStore((s) => s.closeModal) const repos = useAppStore((s) => s.repos) const createWorktree = useAppStore((s) => s.createWorktree) @@ -34,6 +35,8 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() { const [creating, setCreating] = useState(false) const isOpen = activeModal === 'create-worktree' + const preselectedRepoId = + typeof modalData.preselectedRepoId === 'string' ? modalData.preselectedRepoId : '' const selectedRepo = repos.find((r) => r.id === repoId) const handleOpenChange = useCallback( @@ -77,10 +80,17 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() { // Auto-select first repo when opening React.useEffect(() => { - if (isOpen && repos.length > 0 && !repoId) { + if (!isOpen || repos.length === 0) return + + if (preselectedRepoId && repos.some((repo) => repo.id === preselectedRepoId)) { + setRepoId(preselectedRepoId) + return + } + + if (!repoId) { setRepoId(repos[0].id) } - }, [isOpen, repos, repoId]) + }, [isOpen, repos, repoId, preselectedRepoId]) // Safety guard: creating a worktree requires at least one repo. React.useEffect(() => { diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 0d67d31d55c..92ed1ac1f3e 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -77,7 +77,7 @@ const StatusIndicator = React.memo(function StatusIndicator({ return ( diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 789619bc740..32421a30eb1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,7 +1,19 @@ import React, { useMemo, useCallback, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' +import { + ChevronDown, + CircleCheckBig, + CircleDot, + CircleX, + FolderGit2, + GitPullRequest, + Plus +} from 'lucide-react' import { useAppStore } from '@/store' import WorktreeCard from './WorktreeCard' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import type { Worktree, Repo } from '../../../../shared/types' function branchName(branch: string): string { @@ -9,10 +21,73 @@ function branchName(branch: string): string { } // ── Row types for the virtualizer ─────────────────────────────── -type GroupHeaderRow = { type: 'header'; label: string; count: number } +type GroupHeaderRow = { + type: 'header' + key: string + label: string + count: number + tone: string + icon: React.ComponentType<{ className?: string }> + repo?: Repo +} type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined } type Row = GroupHeaderRow | WorktreeRow +type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' + +const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed'] + +const PR_GROUP_META: Record< + PRGroupKey, + { + label: string + icon: React.ComponentType<{ className?: string }> + tone: string + } +> = { + done: { + label: 'Done', + icon: CircleCheckBig, + tone: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200' + }, + 'in-review': { + label: 'In review', + icon: GitPullRequest, + tone: 'border-sky-500/20 bg-sky-500/10 text-sky-200' + }, + 'in-progress': { + label: 'In progress', + icon: CircleDot, + tone: 'border-amber-500/20 bg-amber-500/10 text-amber-200' + }, + closed: { + label: 'Closed', + icon: CircleX, + tone: 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300' + } +} + +function getPRGroupKey( + worktree: Worktree, + repoMap: Map, + prCache: Record | null +): PRGroupKey { + const repo = repoMap.get(worktree.repoId) + const branch = branchName(worktree.branch) + const cacheKey = repo ? `${repo.path}::${branch}` : '' + const prEntry = + cacheKey && prCache + ? (prCache[cacheKey] as { data?: { state?: string } } | undefined) + : undefined + const pr = prEntry?.data + + if (!pr) return 'in-progress' + if (pr.state === 'merged') return 'done' + if (pr.state === 'closed') return 'closed' + if (pr.state === 'draft') return 'in-progress' + return 'in-review' +} + const WorktreeList = React.memo(function WorktreeList() { // ── Granular selectors (each is a primitive or shallow-stable ref) ── const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) @@ -23,6 +98,7 @@ const WorktreeList = React.memo(function WorktreeList() { const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) const filterRepoId = useAppStore((s) => s.filterRepoId) + const openModal = useAppStore((s) => s.openModal) // Only read tabsByWorktree when showActiveOnly is on (avoid subscription otherwise) const tabsByWorktree = useAppStore((s) => (showActiveOnly ? s.tabsByWorktree : null)) @@ -93,11 +169,11 @@ const WorktreeList = React.memo(function WorktreeList() { // Collapsed group state const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) - const toggleGroup = useCallback((label: string) => { + const toggleGroup = useCallback((key: string) => { setCollapsedGroups((prev) => { const next = new Set(prev) - if (next.has(label)) next.delete(label) - else next.add(label) + if (next.has(key)) next.delete(key) + else next.add(key) return next }) }, []) @@ -114,29 +190,65 @@ const WorktreeList = React.memo(function WorktreeList() { } // Group items - const grouped = new Map() + const grouped = new Map() for (const w of worktrees) { + let key: string let label: string + let repo: Repo | undefined if (groupBy === 'repo') { - label = repoMap.get(w.repoId)?.displayName ?? 'Unknown' + repo = repoMap.get(w.repoId) + key = `repo:${w.repoId}` + label = repo?.displayName ?? 'Unknown' } else { - // pr-status - const repo = repoMap.get(w.repoId) - const branch = branchName(w.branch) - const cacheKey = repo ? `${repo.path}::${branch}` : '' - const prEntry = cacheKey && prCache ? prCache[cacheKey] : undefined - const pr = prEntry !== undefined ? prEntry.data : undefined - label = pr ? pr.state.charAt(0).toUpperCase() + pr.state.slice(1) : 'No PR' + const prGroup = getPRGroupKey(w, repoMap, prCache) + key = `pr:${prGroup}` + label = PR_GROUP_META[prGroup].label } - if (!grouped.has(label)) grouped.set(label, []) - grouped.get(label)!.push(w) + if (!grouped.has(key)) grouped.set(key, { label, items: [], repo }) + grouped.get(key)!.items.push(w) } - for (const [label, items] of grouped) { - const isCollapsed = collapsedGroups.has(label) - result.push({ type: 'header', label, count: items.length }) + const orderedGroups: Array<[string, { label: string; items: Worktree[]; repo?: Repo }]> = [] + if (groupBy === 'pr-status') { + for (const prGroup of PR_GROUP_ORDER) { + const key = `pr:${prGroup}` + const group = grouped.get(key) + if (group) orderedGroups.push([key, group]) + } + } else { + orderedGroups.push(...Array.from(grouped.entries())) + } + + for (const [key, group] of orderedGroups) { + const isCollapsed = collapsedGroups.has(key) + const repo = group.repo + const header = + groupBy === 'repo' + ? { + type: 'header' as const, + key, + label: group.label, + count: group.items.length, + tone: 'border-border/70 bg-background/70 text-foreground', + icon: FolderGit2, + repo + } + : (() => { + const prGroup = key.replace(/^pr:/, '') as PRGroupKey + const meta = PR_GROUP_META[prGroup] + return { + type: 'header' as const, + key, + label: meta.label, + count: group.items.length, + tone: meta.tone, + icon: meta.icon + } + })() + + result.push(header) if (!isCollapsed) { - for (const w of items) { + for (const w of group.items) { result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) }) } } @@ -149,14 +261,21 @@ const WorktreeList = React.memo(function WorktreeList() { const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollRef.current, - estimateSize: (index) => (rows[index].type === 'header' ? 28 : 56), + estimateSize: (index) => (rows[index].type === 'header' ? 38 : 56), overscan: 10, getItemKey: (index) => { const row = rows[index] - return row.type === 'header' ? `hdr:${row.label}` : `wt:${row.worktree.id}` + return row.type === 'header' ? `hdr:${row.key}` : `wt:${row.worktree.id}` } }) + const handleCreateForRepo = useCallback( + (repoId: string) => { + openModal('create-worktree', { preselectedRepoId: repoId }) + }, + [openModal] + ) + if (worktrees.length === 0) { return (
@@ -181,19 +300,74 @@ const WorktreeList = React.memo(function WorktreeList() { style={{ transform: `translateY(${vItem.start}px)` }} >
+ +
+
+
+ {row.label} +
+
+ {row.count} +
+
+
+ + {row.repo ? ( + + + + + + Create worktree for {row.label} + + + ) : null} + +
+ +
)