From e0e01cd68756e7decfd1870eaddd2a9e1bed1e45 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 31 Mar 2026 08:49:00 -0700
Subject: [PATCH] feat: configurable worktree cards with property toggles
(#232)
- fix: stay under max-lines lint limit after merge with main
Extract restoreScrollbackBuffers into layout-serialization.ts to reduce
use-terminal-pane-lifecycle.ts below the 300-line limit. Also filter
stale leaf IDs from persisted scrollback buffers and remove design doc.
- fix: polish worktree card controls
- feat: configurable worktree cards with property toggles
---
src/renderer/src/App.tsx | 5 +-
.../src/components/sidebar/GroupControls.tsx | 70 +--------
.../src/components/sidebar/SidebarHeader.tsx | 134 +++++++++++++++---
.../src/components/sidebar/WorktreeCard.tsx | 96 +++++++------
.../src/components/sidebar/WorktreeList.tsx | 8 +-
src/renderer/src/components/sidebar/index.tsx | 2 +-
.../components/terminal-pane/TerminalPane.tsx | 5 +-
.../terminal-pane/layout-serialization.ts | 2 +
src/renderer/src/store/slices/ui.ts | 17 ++-
src/shared/constants.ts | 15 +-
src/shared/types.ts | 3 +
11 files changed, 218 insertions(+), 139 deletions(-)
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index e5a839cbee3..6015bf57152 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1,4 +1,6 @@
import { useEffect } from 'react'
+import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
+
import { Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
@@ -100,7 +102,8 @@ function App(): React.JSX.Element {
groupBy: 'none',
sortBy: 'name',
filterRepoIds: [],
- uiZoomLevel: 0
+ uiZoomLevel: 0,
+ worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES]
})
hydrateWorkspaceSession({
activeRepoId: null,
diff --git a/src/renderer/src/components/sidebar/GroupControls.tsx b/src/renderer/src/components/sidebar/GroupControls.tsx
index ff892427e10..90e58a1e844 100644
--- a/src/renderer/src/components/sidebar/GroupControls.tsx
+++ b/src/renderer/src/components/sidebar/GroupControls.tsx
@@ -1,41 +1,13 @@
import React from 'react'
-import { ArrowUpAZ, ArrowUpDown, Check, Clock3, FolderTree } from 'lucide-react'
import { useAppStore } from '@/store'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
-import { Button } from '@/components/ui/button'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-
-const SORT_OPTIONS = {
- name: {
- label: 'Name',
- icon: ArrowUpAZ
- },
- recent: {
- label: 'Recent',
- icon: Clock3
- },
- repo: {
- label: 'Repo',
- icon: FolderTree
- }
-} as const
const GroupControls = React.memo(function GroupControls() {
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
- const sortBy = useAppStore((s) => s.sortBy)
- const setSortBy = useAppStore((s) => s.setSortBy)
- const selectedSort = SORT_OPTIONS[sortBy]
- const SelectedSortIcon = selectedSort.icon
return (
-
+
-
-
-
-
-
-
-
-
- Sort by {selectedSort.label}
-
-
-
- {Object.entries(SORT_OPTIONS).map(([value, option]) => {
- const Icon = option.icon
- const isSelected = value === sortBy
-
- return (
- setSortBy(value as typeof sortBy)}
- className="pr-7"
- >
-
- {option.label}
- {isSelected ? : null}
-
- )
- })}
-
-
)
})
diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx
index c7d6d2261be..960b08cffa1 100644
--- a/src/renderer/src/components/sidebar/SidebarHeader.tsx
+++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx
@@ -1,40 +1,130 @@
import React from 'react'
-import { Plus } from 'lucide-react'
+import { Plus, SlidersHorizontal } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ DropdownMenuCheckboxItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem
+} from '@/components/ui/dropdown-menu'
+import type { WorktreeCardProperty } from '../../../../shared/types'
+
+const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
+ { id: 'status', label: 'Terminal status' },
+ { id: 'unread', label: 'Unread indicator' },
+ { id: 'ci', label: 'CI checks' },
+ { id: 'issue', label: 'Linked issue' },
+ { id: 'pr', label: 'Linked PR' },
+ { id: 'comment', label: 'Comment' }
+]
+
+const SORT_OPTIONS = [
+ { id: 'name', label: 'Name' },
+ { id: 'recent', label: 'Recent' },
+ { id: 'repo', label: 'Repo' }
+] as const
+
+const isMac = navigator.userAgent.includes('Mac')
+const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
const SidebarHeader = React.memo(function SidebarHeader() {
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.length > 0
+ const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
+ const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
+ const sortBy = useAppStore((s) => s.sortBy)
+ const setSortBy = useAppStore((s) => s.setSortBy)
+
return (
Worktrees
-
-
-
-
-
- {canCreateWorktree ? 'New worktree (⌘N)' : 'Add a repo to create worktrees'}
-
-
+
+
+
+
+
+
+
+
+
+ View options
+
+
+
+ Sort by
+ setSortBy(v as typeof sortBy)}
+ >
+ {SORT_OPTIONS.map((opt) => (
+ e.preventDefault()}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+ Show properties
+ {PROPERTY_OPTIONS.map((opt) => (
+ toggleWorktreeCardProperty(opt.id)}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ {canCreateWorktree
+ ? `New worktree (${newWorktreeShortcutLabel})`
+ : 'Add a repo to create worktrees'}
+
+
+
)
})
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 62c3d2e1dc5..9577d5d9a42 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -99,6 +99,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
+ const cardProps = useAppStore((s) => s.worktreeCardProperties)
const handleEditIssue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -166,18 +167,23 @@ const WorktreeCard = React.memo(function WorktreeCard({
return liveTabs.length > 0 ? 'active' : 'inactive'
}, [hasTerminals, tabs])
- // Fetch PR data on mount. The store handles freshness checks, and
- // activity-based refresh is triggered by setActiveWorktree + visibilitychange.
+ const showPR = cardProps.includes('pr')
+ const showCI = cardProps.includes('ci')
+ const showIssue = cardProps.includes('issue')
+
+ // Skip GitHub fetches when the corresponding card sections are hidden.
+ // This preference is purely presentational, so background refreshes would
+ // spend rate limit budget on data the user cannot see.
useEffect(() => {
- if (repo && !worktree.isBare && prCacheKey) {
+ if (repo && !worktree.isBare && prCacheKey && (showPR || showCI)) {
fetchPRForBranch(repo.path, branch)
}
- }, [repo, worktree.isBare, fetchPRForBranch, branch, prCacheKey])
+ }, [repo, worktree.isBare, fetchPRForBranch, branch, prCacheKey, showPR, showCI])
- // Fetch issue data on mount + background poll as safety net.
- // Primary refresh comes from setActiveWorktree + visibilitychange.
+ // Same rationale for issues: once that section is hidden, polling only burns
+ // GitHub calls and keeps stale-but-invisible data warm for no user benefit.
useEffect(() => {
- if (!repo || !worktree.linkedIssue || !issueCacheKey) {
+ if (!repo || !worktree.linkedIssue || !issueCacheKey || !showIssue) {
return
}
@@ -189,7 +195,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}, 5 * 60_000) // 5 minutes
return () => clearInterval(interval)
- }, [repo, worktree.linkedIssue, fetchIssue, issueCacheKey])
+ }, [repo, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
// Stable click handler – ignore clicks that are really text selections
const handleClick = useCallback(() => {
@@ -244,33 +250,37 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{/* Status indicator on the left */}
-
-
+ {(cardProps.includes('status') || cardProps.includes('unread')) && (
+
+ {cardProps.includes('status') && }
-
-
-
-
-
- {unreadTooltip}
-
-
-
+ {cardProps.includes('unread') && (
+
+
+
+
+
+ {unreadTooltip}
+
+
+ )}
+
+ )}
{/* Content area */}
@@ -281,8 +291,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* CI Checks & PR state on the right */}
-
- {pr && pr.checksStatus !== 'neutral' && (
+ {cardProps.includes('ci') && pr && pr.checksStatus !== 'neutral' && (
+
@@ -301,8 +311,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
CI checks {checksLabel(pr.checksStatus).toLowerCase()}
- )}
-
+
+ )}
{/* Subtitle row: Repo badge + Branch */}
@@ -348,9 +358,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Meta section: Issue / PR Links / Comment */}
- {(issue || worktree.comment || pr) && (
+ {((cardProps.includes('issue') && issue) ||
+ (cardProps.includes('pr') && pr) ||
+ (cardProps.includes('comment') && worktree.comment)) && (