fix: follow a worktree in the sidebar when you pin or unpin it

This commit is contained in:
Trevin Chow 2026-06-02 20:56:34 -07:00 committed by GitHub
parent abfe9bd329
commit d617a08a8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 149 additions and 22 deletions

View File

@ -210,6 +210,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const defaultSelectedWorktrees = useMemo(() => [worktree], [worktree])
const effectiveSelectedWorktrees = selectedWorktrees ?? defaultSelectedWorktrees
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const openModal = useAppStore((s) => s.openModal)
const projectGroups = useAppStore((s) => s.projectGroups)
@ -219,8 +220,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const [contextWorktrees, setContextWorktrees] =
useState<readonly Worktree[]>(effectiveSelectedWorktrees)
const [contextWorktrees, setContextWorktrees] = useState<readonly Worktree[]>(
effectiveSelectedWorktrees
)
const [createGroupDialogOpen, setCreateGroupDialogOpen] = useState(false)
const isDeleting = deleteState?.isDeleting ?? false
const repoMap = useRepoMap()
@ -306,8 +308,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
}, [worktree.id, worktree.isUnread, updateWorktreeMeta])
const handleTogglePin = useCallback(() => {
updateWorktreeMeta(worktree.id, { isPinned: !worktree.isPinned })
}, [worktree.id, worktree.isPinned, updateWorktreeMeta])
setWorktreesPinnedAndReveal([worktree.id], !worktree.isPinned)
}, [worktree.id, worktree.isPinned, setWorktreesPinnedAndReveal])
const handleCreateGroupFromRepo = useCallback(() => {
if (!repo) {

View File

@ -3497,6 +3497,7 @@ const WorktreeList = React.memo(function WorktreeList({
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree)
const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar)
const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal)
const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId)
const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode)
// Why: agent-send eligibility only matters while the picker is open. When it
@ -4386,30 +4387,16 @@ const WorktreeList = React.memo(function WorktreeList({
const pinWorktree = useCallback(
(worktreeId: string) => {
const current = worktreeMap.get(worktreeId)
if (!current || current.isPinned) {
return
}
void updateWorktreeMeta(worktreeId, { isPinned: true })
setWorktreesPinnedAndReveal([worktreeId], true)
},
[updateWorktreeMeta, worktreeMap]
[setWorktreesPinnedAndReveal]
)
const pinWorktrees = useCallback(
(worktreeIds: readonly string[]) => {
const updates = new Map<string, { isPinned: true }>()
for (const worktreeId of worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current || current.isPinned) {
continue
}
updates.set(worktreeId, { isPinned: true })
}
if (updates.size > 0) {
void updateWorktreesMeta(updates)
}
setWorktreesPinnedAndReveal(worktreeIds, true)
},
[updateWorktreesMeta, worktreeMap]
[setWorktreesPinnedAndReveal]
)
const reorderWorktrees = useCallback(

View File

@ -128,6 +128,12 @@ export type WorktreeSlice = {
updateWorktreesMeta: (
updatesByWorktreeId: ReadonlyMap<string, Partial<WorktreeMeta>>
) => Promise<void>
/**
* Pin/unpin worktrees, then reveal the first changed one. The reveal is the
* point: pinning moves the row to the Pinned section (unpinning moves it
* back), so without it the viewport stays put and the user loses the row.
*/
setWorktreesPinnedAndReveal: (worktreeIds: readonly string[], isPinned: boolean) => void
markWorktreeUnread: (worktreeId: string) => void
observeTerminalGitHubPullRequestLink: (worktreeId: string, link: TerminalGitHubPRLink) => void
/** Clear the worktree's unread dot. Called on user interaction with any

View File

@ -3049,3 +3049,110 @@ describe('markWorktreeVisited', () => {
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ 'repo1::/hidden': 100 })
})
})
describe('setWorktreesPinnedAndReveal', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
it('pins a worktree and reveals it so the viewport follows it into the Pinned section', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false })
const reveal = vi.fn()
store.setState({
worktreesByRepo: { repo1: [wt] },
revealWorktreeInSidebar: reveal
} as Partial<AppState>)
store.getState().setWorktreesPinnedAndReveal([wt.id], true)
expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true)
expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true })
})
it('reveals on unpin so the viewport follows the row back to its status group', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true })
const reveal = vi.fn()
store.setState({
worktreesByRepo: { repo1: [wt] },
revealWorktreeInSidebar: reveal
} as Partial<AppState>)
store.getState().setWorktreesPinnedAndReveal([wt.id], false)
expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(false)
expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true })
})
it('skips a no-op toggle without requesting a reveal', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true })
const reveal = vi.fn()
store.setState({
worktreesByRepo: { repo1: [wt] },
revealWorktreeInSidebar: reveal
} as Partial<AppState>)
store.getState().setWorktreesPinnedAndReveal([wt.id], true)
expect(reveal).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true)
})
it('does nothing for an unknown worktree id', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false })
const reveal = vi.fn()
store.setState({
worktreesByRepo: { repo1: [wt] },
revealWorktreeInSidebar: reveal
} as Partial<AppState>)
store.getState().setWorktreesPinnedAndReveal(['repo1::/missing'], true)
expect(reveal).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(false)
})
it('does nothing for an empty id list', () => {
const store = createTestStore()
const reveal = vi.fn()
store.setState({
worktreesByRepo: { repo1: [] },
revealWorktreeInSidebar: reveal
} as Partial<AppState>)
store.getState().setWorktreesPinnedAndReveal([], true)
expect(reveal).not.toHaveBeenCalled()
})
it('reveals only the first newly-pinned worktree when pinning several at once', () => {
const store = createTestStore()
const alreadyPinned = makeWorktree({
id: 'repo1::/a',
repoId: 'repo1',
path: '/a',
isPinned: true
})
const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false })
const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false })
const reveal = vi.fn()
store.setState({
worktreesByRepo: { repo1: [alreadyPinned, first, second] },
revealWorktreeInSidebar: reveal
} as Partial<AppState>)
store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, second.id], true)
expect(reveal).toHaveBeenCalledTimes(1)
expect(reveal).toHaveBeenCalledWith(first.id, { behavior: 'smooth', highlight: true })
// Every targeted row is pinned, not just the revealed one, and the
// already-pinned row is left untouched.
expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true)
expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true)
expect(store.getState().worktreesByRepo.repo1[2].isPinned).toBe(true)
})
})

View File

@ -1690,6 +1690,31 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
)
},
setWorktreesPinnedAndReveal: (worktreeIds, isPinned) => {
// Skip worktrees already in the target state so a no-op toggle doesn't
// scroll the viewport away from where the user is.
const updates = new Map<string, Partial<WorktreeMeta>>()
let revealWorktreeId: string | null = null
for (const worktreeId of worktreeIds) {
const current = get().getKnownWorktreeById(worktreeId)
if (!current || current.isPinned === isPinned) {
continue
}
updates.set(worktreeId, { isPinned })
if (revealWorktreeId === null) {
revealWorktreeId = worktreeId
}
}
if (revealWorktreeId === null) {
return
}
// updateWorktreesMeta applies its store update synchronously (only the
// persistence is async), so the reveal below resolves against a render
// where the row already sits in its new section.
void get().updateWorktreesMeta(updates)
get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true })
},
markWorktreeUnread: (worktreeId) => {
// Why: terminal attention should remain visible until the user engages
// with the worktree. Interaction with a pane inside the worktree dismisses