feat: add pinned worktrees to sidebar (#674)

This commit is contained in:
Jinwoo Hong 2026-04-15 15:23:13 -04:00 committed by GitHub
parent 6507ff8564
commit 97f3cd5199
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 166 additions and 22 deletions

View file

@ -168,6 +168,7 @@ describe('mergeWorktree', () => {
linkedPR: 10,
isArchived: true,
isUnread: true,
isPinned: true,
sortOrder: 5,
lastActivityAt: 1000
}
@ -186,6 +187,7 @@ describe('mergeWorktree', () => {
linkedPR: 10,
isArchived: true,
isUnread: true,
isPinned: true,
sortOrder: 5,
lastActivityAt: 1000
})
@ -199,6 +201,7 @@ describe('mergeWorktree', () => {
expect(result.linkedPR).toBeNull()
expect(result.isArchived).toBe(false)
expect(result.isUnread).toBe(false)
expect(result.isPinned).toBe(false)
expect(result.sortOrder).toBe(0)
expect(result.lastActivityAt).toBe(0)
})

View file

@ -161,6 +161,7 @@ export function mergeWorktree(
linkedPR: meta?.linkedPR ?? null,
isArchived: meta?.isArchived ?? false,
isUnread: meta?.isUnread ?? false,
isPinned: meta?.isPinned ?? false,
sortOrder: meta?.sortOrder ?? 0,
lastActivityAt: meta?.lastActivityAt ?? 0
}

View file

@ -166,10 +166,7 @@ export class Store {
getRepo(id: string): Repo | undefined {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return undefined
}
return this.hydrateRepo(repo)
return repo ? this.hydrateRepo(repo) : undefined
}
addRepo(repo: Repo): void {
@ -376,6 +373,7 @@ function getDefaultWorktreeMeta(): WorktreeMeta {
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: Date.now(),
lastActivityAt: 0
}

View file

@ -98,6 +98,7 @@ const store = {
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
@ -711,6 +712,7 @@ describe('OrcaRuntimeService', () => {
linkedPR: meta.linkedPR ?? existingMeta?.linkedPR ?? null,
isArchived: meta.isArchived ?? existingMeta?.isArchived ?? false,
isUnread: meta.isUnread ?? existingMeta?.isUnread ?? false,
isPinned: meta.isPinned ?? existingMeta?.isPinned ?? false,
sortOrder: meta.sortOrder ?? existingMeta?.sortOrder ?? 0,
lastActivityAt: meta.lastActivityAt ?? existingMeta?.lastActivityAt ?? 0
}

View file

@ -75,6 +75,7 @@ describe('OrcaRuntimeRpcServer', () => {
linkedPR: null,
isArchived: false,
isUnread: overrides?.isUnread ?? false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}

View file

@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import React, { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../store'
@ -76,7 +76,10 @@ function Terminal(): React.JSX.Element | null {
const tabBarOrderByWorktree = useAppStore((s) => s.tabBarOrderByWorktree)
const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : undefined
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const tabs = useMemo(
() => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, tabsByWorktree]
)
const allWorktrees = Object.values(worktreesByRepo).flat()
// Why: the TabBar is rendered into the titlebar via a portal so tabs share

View file

@ -14,6 +14,8 @@ import {
Link,
MessageSquare,
Pencil,
Pin,
PinOff,
XCircle,
Trash2
} from 'lucide-react'
@ -61,6 +63,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
}, [worktree.id, worktree.isUnread, updateWorktreeMeta])
const handleTogglePin = useCallback(() => {
updateWorktreeMeta(worktree.id, { isPinned: !worktree.isPinned })
}, [worktree.id, worktree.isPinned, updateWorktreeMeta])
const handleRename = useCallback(() => {
openModal('edit-meta', {
worktreeId: worktree.id,
@ -155,6 +161,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
Copy Path
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleTogglePin} disabled={isDeleting}>
{worktree.isPinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
{worktree.isPinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleRename} disabled={isDeleting}>
<Pencil className="size-3.5" />
Rename

View file

@ -94,7 +94,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (!row) {
return `__stale_${index}`
}
return row.type === 'header' ? `hdr:${row.key}` : `wt:${row.worktree.id}`
return row.type === 'header'
? `hdr:${row.key}`
: row.type === 'separator'
? `sep:${row.key}`
: `wt:${row.worktree.id}`
}
})
@ -258,6 +262,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{virtualItems.map((vItem) => {
const row = rows[vItem.index]
if (row.type === 'separator') {
return (
<div
key={vItem.key}
role="separator"
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 right-0"
style={{ transform: `translateY(${vItem.start}px)` }}
>
<div className="mx-2 my-1.5 border-t border-border/60" />
</div>
)
}
if (row.type === 'header') {
return (
<div
@ -275,19 +294,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)}
onClick={() => toggleGroup(row.key)}
>
<div
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
row.repo ? 'text-foreground' : ''
)}
style={row.repo ? { color: row.repo.badgeColor } : undefined}
>
<row.icon className="size-3" />
</div>
{row.icon ? (
<div
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
row.repo ? 'text-foreground' : ''
)}
style={row.repo ? { color: row.repo.badgeColor } : undefined}
>
<row.icon className="size-3" />
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-semibold leading-none lowercase">
<div className="truncate text-[13px] font-semibold leading-none capitalize">
{row.label}
</div>
<div className="rounded-full bg-black/12 px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground/90">

View file

@ -32,6 +32,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
isArchived: overrides.isArchived ?? false,
comment: overrides.comment ?? '',
isUnread: overrides.isUnread ?? false,
isPinned: overrides.isPinned ?? false,
displayName: overrides.displayName ?? overrides.id ?? 'wt-1',
sortOrder: overrides.sortOrder ?? 0,
lastActivityAt: overrides.lastActivityAt ?? 0

View file

@ -17,6 +17,7 @@ function makeWorktree(id: string, repoId = 'repo1'): Worktree {
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getPRGroupKey, matchesSearch } from './worktree-list-groups'
import { buildRows, getPRGroupKey, matchesSearch } from './worktree-list-groups'
import type { Repo, Worktree } from '../../../../shared/types'
const repo: Repo = {
@ -23,6 +23,7 @@ const worktree: Worktree = {
isArchived: false,
comment: '',
isUnread: false,
isPinned: false,
displayName: 'feature/super-critical',
sortOrder: 0,
lastActivityAt: 0
@ -147,3 +148,52 @@ describe('matchesSearch', () => {
expect(matchesSearch(w, '#', repoMap, prCache, null)).toBe(false)
})
})
describe('buildRows with pinned worktrees', () => {
const pinned = { ...worktree, id: 'wt-pinned', isPinned: true, displayName: 'pinned-feature' }
const unpinned1 = { ...worktree, id: 'wt-1', displayName: 'alpha' }
const unpinned2 = { ...worktree, id: 'wt-2', displayName: 'beta' }
it('emits a Pinned header followed by pinned items in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned', count: 1 })
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
})
it('emits a separator between pinned and unpinned in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows[2]).toMatchObject({ type: 'separator', key: 'sep:pinned' })
expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } })
})
it('excludes pinned items from regular groups in pr-status mode', () => {
const rows = buildRows('pr-status', [unpinned1, pinned], repoMap, null, new Set())
const pinnedHeader = rows.find((r) => r.type === 'header' && r.key === 'pinned')
expect(pinnedHeader).toBeDefined()
const prGroup = rows.filter((r) => r.type === 'header' && r.key.startsWith('pr:'))
for (const header of prGroup) {
if (header.type === 'header') {
expect(header.count).toBe(1)
}
}
})
it('does not emit pinned section when no worktrees are pinned', () => {
const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set())
expect(rows.every((r) => r.type === 'item')).toBe(true)
})
it('collapses pinned group when in collapsedGroups', () => {
const rows = buildRows('none', [pinned, unpinned1], repoMap, null, new Set(['pinned']))
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' })
expect(rows[1]).toMatchObject({ type: 'separator' })
expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
})
it('does not emit separator when all worktrees are pinned', () => {
const allPinned = { ...unpinned1, isPinned: true }
const rows = buildRows('none', [pinned, allPinned], repoMap, null, new Set())
expect(rows.some((r) => r.type === 'separator')).toBe(false)
})
})

View file

@ -11,12 +11,13 @@ export type GroupHeaderRow = {
label: string
count: number
tone: string
icon: React.ComponentType<{ className?: string }>
icon?: React.ComponentType<{ className?: string }>
repo?: Repo
}
export type SeparatorRow = { type: 'separator'; key: string }
export type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
export type Row = GroupHeaderRow | WorktreeRow
export type Row = GroupHeaderRow | SeparatorRow | WorktreeRow
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
@ -57,6 +58,13 @@ export const REPO_GROUP_META = {
icon: FolderGit2
} as const
export const PINNED_GROUP_KEY = 'pinned'
export const PINNED_GROUP_META = {
label: 'Pinned',
tone: 'text-muted-foreground'
} as const
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
@ -86,6 +94,36 @@ export function getPRGroupKey(
return 'in-review'
}
/**
* Emit a "Pinned" header + its items into `result`, returning the set of
* pinned worktree IDs so the caller can exclude them from regular groups.
*/
function emitPinnedGroup(
worktrees: Worktree[],
repoMap: Map<string, Repo>,
collapsedGroups: Set<string>,
result: Row[]
): Set<string> {
const pinned = worktrees.filter((w) => w.isPinned)
if (pinned.length === 0) {
return new Set()
}
result.push({
type: 'header',
key: PINNED_GROUP_KEY,
label: PINNED_GROUP_META.label,
count: pinned.length,
tone: PINNED_GROUP_META.tone
})
if (!collapsedGroups.has(PINNED_GROUP_KEY)) {
for (const w of pinned) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
}
return new Set(pinned.map((w) => w.id))
}
/**
* Build the flat row list consumed by the virtualizer.
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
@ -99,15 +137,21 @@ export function buildRows(
): Row[] {
const result: Row[] = []
const pinnedIds = emitPinnedGroup(worktrees, repoMap, collapsedGroups, result)
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
if (groupBy === 'none') {
for (const w of worktrees) {
if (pinnedIds.size > 0 && unpinned.length > 0) {
result.push({ type: 'separator', key: 'sep:pinned' })
}
for (const w of unpinned) {
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
}
return result
}
const grouped = new Map<string, { label: string; items: Worktree[]; repo?: Repo }>()
for (const w of worktrees) {
for (const w of unpinned) {
let key: string
let label: string
let repo: Repo | undefined

View file

@ -17,6 +17,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides

View file

@ -133,6 +133,7 @@ function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: strin
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides

View file

@ -78,6 +78,7 @@ export function makeWorktree(
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides

View file

@ -561,6 +561,7 @@ describe('TabsSlice', () => {
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
@ -652,6 +653,7 @@ describe('TabsSlice', () => {
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}

View file

@ -75,6 +75,7 @@ function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: strin
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides

View file

@ -31,6 +31,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.linkedPR === candidate.linkedPR &&
worktree.isArchived === candidate.isArchived &&
worktree.isUnread === candidate.isUnread &&
worktree.isPinned === candidate.isPinned &&
worktree.sortOrder === candidate.sortOrder &&
worktree.lastActivityAt === candidate.lastActivityAt
)

View file

@ -42,6 +42,7 @@ export type Worktree = {
linkedPR: number | null
isArchived: boolean
isUnread: boolean
isPinned: boolean
sortOrder: number
lastActivityAt: number
} & GitWorktreeInfo
@ -54,6 +55,7 @@ export type WorktreeMeta = {
linkedPR: number | null
isArchived: boolean
isUnread: boolean
isPinned: boolean
sortOrder: number
lastActivityAt: number
}