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)) && (
- {issue && ( + {cardProps.includes('issue') && issue && (
)} - {pr && ( + {cardProps.includes('pr') && pr && (
)} - {worktree.comment && ( + {cardProps.includes('comment') && worktree.comment && (
+
{virtualizer.getVirtualItems().map((vItem) => { const row = rows[vItem.index] diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 5b407b41cbf..3db86855ac2 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -28,7 +28,7 @@ export default function Sidebar(): React.JSX.Element { } }, [repoCount, fetchAllWorktrees]) - // ─── Resize logic ──────────────────────────────────── + // ─── Resize logic ─────────────────────────────────────────────────── const isResizing = useRef(false) const startX = useRef(0) const startWidth = useRef(0) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index cac22279765..292b80558d9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -86,7 +86,10 @@ export default function TerminalPane({ // reorder) don't clobber previously captured scrollback. const existing = useAppStore.getState().terminalLayoutsByTabId[tabId] if (existing?.buffersByLeafId) { - layout.buffersByLeafId = existing.buffersByLeafId + const currentLeafIds = new Set(manager.getPanes().map((p) => paneLeafId(p.id))) + layout.buffersByLeafId = Object.fromEntries( + Object.entries(existing.buffersByLeafId).filter(([id]) => currentLeafIds.has(id)) + ) } setTabLayout(tabId, layout) } diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index a07b880b594..a19090fea2a 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -138,6 +138,8 @@ export function restoreScrollbackBuffers( } try { let buf = buffer + // If buffer ends in alt-screen mode (agent TUI was running at + // shutdown), exit alt-screen so the user sees a usable terminal. const lastOn = buf.lastIndexOf(ALT_SCREEN_ON) const lastOff = buf.lastIndexOf(ALT_SCREEN_OFF) if (lastOn > lastOff) { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 5b9ea16a103..f021e8a4e9b 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { PersistedUIState, UpdateStatus } from '../../../../shared/types' +import type { PersistedUIState, UpdateStatus, WorktreeCardProperty } from '../../../../shared/types' +import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' type LegacyPersistedSortBy = PersistedUIState['sortBy'] | 'smart' @@ -26,6 +27,8 @@ export type UISlice = { setShowActiveOnly: (v: boolean) => void filterRepoIds: string[] setFilterRepoIds: (ids: string[]) => void + worktreeCardProperties: WorktreeCardProperty[] + toggleWorktreeCardProperty: (prop: WorktreeCardProperty) => void pendingRevealWorktreeId: string | null revealWorktreeInSidebar: (worktreeId: string) => void clearPendingRevealWorktreeId: () => void @@ -67,6 +70,17 @@ export const createUISlice: StateCreator = (set) => ( filterRepoIds: [], setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), + worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES], + toggleWorktreeCardProperty: (prop) => + set((s) => { + const current = s.worktreeCardProperties || DEFAULT_WORKTREE_CARD_PROPERTIES + const updated = current.includes(prop) + ? current.filter((p) => p !== prop) + : [...current, prop] + window.api.ui.set({ worktreeCardProperties: updated }).catch(console.error) + return { worktreeCardProperties: updated } + }), + pendingRevealWorktreeId: null, revealWorktreeInSidebar: (worktreeId) => set({ pendingRevealWorktreeId: worktreeId }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktreeId: null }), @@ -82,6 +96,7 @@ export const createUISlice: StateCreator = (set) => ( groupBy: ui.groupBy, sortBy, filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), + worktreeCardProperties: ui.worktreeCardProperties ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES], persistedUIReady: true } }), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 525c42fb57f..b1d68442100 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -3,12 +3,22 @@ import type { PersistedState, PersistedUIState, RepoHookSettings, - WorkspaceSessionState + WorkspaceSessionState, + WorktreeCardProperty } from './types' import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' export const SCHEMA_VERSION = 1 +export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ + 'status', + 'unread', + 'ci', + 'issue', + 'pr', + 'comment' +] + export const REPO_COLORS = [ '#737373', // neutral '#ef4444', // red @@ -77,7 +87,8 @@ export function getDefaultUIState(): PersistedUIState { groupBy: 'none', sortBy: 'name', filterRepoIds: [], - uiZoomLevel: 0 + uiZoomLevel: 0, + worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES] } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 8670121a9e1..b923d560e37 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -194,6 +194,8 @@ export type GlobalSettings = { rightSidebarOpenByDefault: boolean } +export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment' + export type PersistedUIState = { lastActiveRepoId: string | null lastActiveWorktreeId: string | null @@ -203,6 +205,7 @@ export type PersistedUIState = { sortBy: 'name' | 'recent' | 'repo' filterRepoIds: string[] uiZoomLevel: number + worktreeCardProperties: WorktreeCardProperty[] } // ─── Persistence shape ──────────────────────────────────────────────