mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: add pinned worktrees to sidebar (#674)
This commit is contained in:
parent
6507ff8564
commit
97f3cd5199
19 changed files with 166 additions and 22 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
|||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: overrides?.isUnread ?? false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function makeWorktree(id: string, repoId = 'repo1'): Worktree {
|
|||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
|||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export function makeWorktree(
|
|||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue