refactor(renderer): reduce render-time store churn (#966)

This commit is contained in:
Neil 2026-04-22 23:03:10 -07:00 committed by GitHub
parent 45ad798d97
commit 3d8cdcd12c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 286 additions and 304 deletions

View File

@ -1,5 +1,5 @@
/* eslint-disable max-lines */
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { ChevronLeft, ChevronRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
@ -14,13 +14,7 @@ import { useIpcEvents } from './hooks/useIpcEvents'
import Sidebar from './components/Sidebar'
import Terminal from './components/Terminal'
import { shutdownBufferCaptures } from './components/terminal-pane/TerminalPane'
import Landing from './components/Landing'
import TaskPage from './components/TaskPage'
import Settings from './components/settings/Settings'
import RightSidebar from './components/right-sidebar'
import QuickOpen from './components/QuickOpen'
import WorktreeJumpPalette from './components/WorktreeJumpPalette'
import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal'
import { StatusBar } from './components/status-bar/StatusBar'
import { UpdateCard } from './components/UpdateCard'
import { StarNagCard } from './components/StarNagCard'
@ -46,6 +40,12 @@ import {
import { dispatchClearModifierHints } from './hooks/useModifierHint'
const isMac = navigator.userAgent.includes('Mac')
const Landing = lazy(() => import('./components/Landing'))
const TaskPage = lazy(() => import('./components/TaskPage'))
const Settings = lazy(() => import('./components/settings/Settings'))
const QuickOpen = lazy(() => import('./components/QuickOpen'))
const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette'))
const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal'))
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@ -100,6 +100,7 @@ function App(): React.JSX.Element {
)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
@ -134,6 +135,7 @@ function App(): React.JSX.Element {
const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory)
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
const [mountedLazyModalIds, setMountedLazyModalIds] = useState(() => new Set<string>())
// Subscribe to IPC push events
useIpcEvents()
@ -619,6 +621,26 @@ function App(): React.JSX.Element {
sidebarOpen
])
useEffect(() => {
if (
activeModal !== 'quick-open' &&
activeModal !== 'worktree-palette' &&
activeModal !== 'new-workspace-composer'
) {
return
}
setMountedLazyModalIds((currentIds) => {
if (currentIds.has(activeModal)) {
return currentIds
}
const nextIds = new Set(currentIds)
// Why: lazy-load these modals only after first use, then keep them mounted
// so repeat opens preserve their local state and avoid re-fetch flashes.
nextIds.add(activeModal)
return nextIds
})
}, [activeModal])
// Why: extracted so both the full-width titlebar (settings/landing) and
// the sidebar-width left header (workspace view) can share the same
// controls without duplicating the agent badge popover.
@ -700,9 +722,9 @@ function App(): React.JSX.Element {
{wt?.displayName ?? fallbackName}
</span>
</button>
{agents.map((agent, index) => (
{agents.map((agent) => (
<button
key={index}
key={`${agent.tabId}:${agent.paneId ?? 'none'}:${agent.label}`}
className="titlebar-agent-hovercard-agent"
onClick={() => {
activateAndRevealWorktree(worktreeId)
@ -923,9 +945,11 @@ function App(): React.JSX.Element {
>
<Terminal />
</div>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
</div>
</div>
{/* Why: keep RightSidebar mounted even when closed so that its
@ -940,10 +964,14 @@ function App(): React.JSX.Element {
when mounted outside a TooltipProvider ancestor. Keep the global
composer modal inside this provider so the card renders safely
whether triggered from Cmd+J or any future entry point. */}
<NewWorkspaceComposerModal />
<Suspense fallback={null}>
{mountedLazyModalIds.has('new-workspace-composer') ? <NewWorkspaceComposerModal /> : null}
</Suspense>
</TooltipProvider>
<QuickOpen />
<WorktreeJumpPalette />
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
</Suspense>
<UpdateCard />
<StarNagCard />
<ZoomOverlay />

View File

@ -1,7 +1,8 @@
/* oxlint-disable max-lines */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { File } from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useWorktreesForRepo } from '@/store/selectors'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
@ -57,56 +58,44 @@ export default function QuickOpen(): React.JSX.Element | null {
const visible = useAppStore((s) => s.activeModal === 'quick-open')
const closeModal = useAppStore((s) => s.closeModal)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const openFile = useAppStore((s) => s.openFile)
const activeWorktree = useActiveWorktree()
const repoWorktrees = useWorktreesForRepo(activeWorktree?.repoId ?? null)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [files, setFiles] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const lastFilesRequestKeyRef = useRef('')
// Why: the derived tuple (worktreePath, excludePaths) must have a stable
// identity across unrelated store updates. We key the memo on a joined
// string so any worktreesByRepo mutation that doesn't affect this worktree's
// path or its siblings leaves our array reference untouched, which in turn
// keeps the file-load effect below from refetching and blinking the list.
const worktreePath = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
return wt.path
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const worktreePath = activeWorktree?.path ?? null
const excludePathsKey = useMemo(() => {
if (!activeWorktreeId || !worktreePath) {
if (!activeWorktreeId || !worktreePath || repoWorktrees.length === 0) {
return ''
}
for (const worktrees of Object.values(worktreesByRepo)) {
if (!worktrees.some((w) => w.id === activeWorktreeId)) {
continue
}
// Why: when the active worktree is the repo root (isMainWorktree),
// linked worktrees are nested subdirectories. Without excluding them,
// file listing returns files from every worktree, not just this one.
return worktrees
.filter((w) => w.id !== activeWorktreeId && w.path.startsWith(`${worktreePath}/`))
.map((w) => w.path)
.sort()
.join('\n')
}
return ''
}, [activeWorktreeId, worktreePath, worktreesByRepo])
// Why: when the active worktree is the repo root (isMainWorktree), linked
// worktrees are nested subdirectories. Restricting the exclusion scan to
// sibling worktrees in the same repo avoids rescanning the entire store.
return repoWorktrees
.filter(
(worktree) =>
worktree.id !== activeWorktreeId && worktree.path.startsWith(`${worktreePath}/`)
)
.map((worktree) => worktree.path)
.sort()
.join('\n')
}, [activeWorktreeId, repoWorktrees, worktreePath])
const connectionId = useMemo(
() => getConnectionId(activeWorktreeId ?? null) ?? undefined,
[activeWorktreeId]
)
const filesRequestKey = useMemo(
() => `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}`,
[connectionId, excludePathsKey, worktreePath]
)
// Why: reset input only on open. Keeping this out of the file-load effect
// prevents unrelated store updates (which can produce a new excludePaths
@ -125,11 +114,17 @@ export default function QuickOpen(): React.JSX.Element | null {
if (!worktreePath) {
setFiles([])
setLoadError(null)
setLoading(false)
return
}
let cancelled = false
setFiles([])
const requestKeyChanged = lastFilesRequestKeyRef.current !== filesRequestKey
if (requestKeyChanged) {
setFiles([])
}
lastFilesRequestKeyRef.current = filesRequestKey
setLoadError(null)
setLoading(true)
@ -166,24 +161,25 @@ export default function QuickOpen(): React.JSX.Element | null {
return () => {
cancelled = true
}
}, [visible, worktreePath, connectionId, excludePathsKey])
}, [visible, worktreePath, connectionId, excludePathsKey, filesRequestKey])
// Filter files by fuzzy match
const filtered = useMemo(() => {
if (!query.trim()) {
const normalizedQuery = deferredQuery.trim()
if (!normalizedQuery) {
// Show first 50 files when no query
return files.slice(0, 50).map((f) => ({ path: f, score: 0 }))
}
const results: { path: string; score: number }[] = []
for (const f of files) {
const score = fuzzyMatch(query.trim(), f)
const score = fuzzyMatch(normalizedQuery, f)
if (score !== -1) {
results.push({ path: f, score })
}
}
results.sort((a, b) => a.score - b.score)
return results.slice(0, 50)
}, [files, query])
}, [deferredQuery, files])
const handleSelect = useCallback(
(relativePath: string) => {
@ -256,7 +252,7 @@ export default function QuickOpen(): React.JSX.Element | null {
</CommandList>
{/* Accessibility: announce result count changes */}
<div aria-live="polite" className="sr-only">
{query.trim() ? `${filtered.length} files found` : ''}
{deferredQuery.trim() ? `${filtered.length} files found` : ''}
</div>
</CommandDialog>
)

View File

@ -18,6 +18,7 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@ -147,6 +148,7 @@ export default function TaskPage(): React.JSX.Element {
const closeTaskPage = useAppStore((s) => s.closeTaskPage)
const activeModal = useAppStore((s) => s.activeModal)
const repos = useAppStore((s) => s.repos)
const repoMap = useRepoMap()
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchWorkItemsAcrossRepos = useAppStore((s) => s.fetchWorkItemsAcrossRepos)
@ -914,7 +916,7 @@ export default function TaskPage(): React.JSX.Element {
<div className="divide-y divide-border/50">
{filteredWorkItems.map((item) => {
const itemRepo = repos.find((r) => r.id === item.repoId)
const itemRepo = repoMap.get(item.repoId) ?? null
return (
// Why: the row is a clickable container rather than a
// <button> because it holds nested interactive elements
@ -1139,8 +1141,9 @@ export default function TaskPage(): React.JSX.Element {
repoPath={
// Why: the drawer is for a single item — resolve its repoPath from the
// item's own repoId (set when fan-out merged the list) so it works in
// cross-repo mode too.
drawerWorkItem ? (repos.find((r) => r.id === drawerWorkItem.repoId)?.path ?? null) : null
// cross-repo mode too. Reusing the memoized repo map avoids an O(n)
// scan on every render while the drawer is open.
drawerWorkItem ? (repoMap.get(drawerWorkItem.repoId)?.path ?? null) : null
}
onUse={(item) => {
setDrawerWorkItem(null)

View File

@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../store'
import { useAllWorktrees } from '../store/selectors'
import { findWorktreeById } from '../store/slices/worktree-helpers'
import { createUntitledMarkdownFile } from '../lib/create-untitled-markdown'
import { extractIpcErrorMessage } from '../lib/ipc-error'
@ -41,9 +42,9 @@ import CodexRestartChip from './CodexRestartChip'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
function Terminal(): React.JSX.Element | null {
const allWorktrees = useAllWorktrees()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeView = useAppStore((s) => s.activeView)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
@ -84,7 +85,6 @@ function Terminal(): React.JSX.Element | null {
() => (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
// the same row as the "Orca" title. The target element is created by App.tsx.

View File

@ -1,8 +1,9 @@
/* oxlint-disable max-lines */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Globe, Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
CommandDialog,
CommandInput,
@ -134,6 +135,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const allWorktrees = useAllWorktrees()
const repos = useAppStore((s) => s.repos)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const prCache = useAppStore((s) => s.prCache)
@ -145,7 +147,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [selectedItemId, setSelectedItemId] = useState('')
const previousWorktreeIdRef = useRef<string | null>(null)
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal')
@ -156,27 +158,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const prevQueryRef = useRef('')
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const id = setTimeout(() => setDebouncedQuery(query), 150)
return () => clearTimeout(id)
}, [query])
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
const canCreateWorktree = useMemo(() => repos.some((repo) => isGitRepoKind(repo)), [repos])
const sortedWorktrees = useMemo(() => {
const all: Worktree[] = Object.values(worktreesByRepo)
.flat()
.filter((w) => !w.isArchived)
return sortWorktreesSmart(all, tabsByWorktree, repoMap, prCache)
}, [worktreesByRepo, tabsByWorktree, repoMap, prCache])
const visibleWorktrees = allWorktrees.filter((worktree) => !worktree.isArchived)
return sortWorktreesSmart(visibleWorktrees, tabsByWorktree, repoMap, prCache)
}, [allWorktrees, tabsByWorktree, repoMap, prCache])
const browserSortedWorktrees = useMemo(() => {
const all: Worktree[] = Object.values(worktreesByRepo).flat()
// Why: browser-tab search is explicitly cross-worktree, so it must keep
// indexing live browser pages even when their owning worktree is archived.
return sortWorktreesSmart(all, tabsByWorktree, repoMap, prCache)
}, [worktreesByRepo, tabsByWorktree, repoMap, prCache])
return sortWorktreesSmart(allWorktrees, tabsByWorktree, repoMap, prCache)
}, [allWorktrees, tabsByWorktree, repoMap, prCache])
// Why: browser rows need worktree lookups for repo badge colors, and browser
// search intentionally includes archived worktrees. This map must cover all
@ -195,8 +189,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)
const worktreeMatches = useMemo(
() => searchWorktrees(sortedWorktrees, debouncedQuery.trim(), repoMap, prCache, issueCache),
[sortedWorktrees, debouncedQuery, repoMap, prCache, issueCache]
() => searchWorktrees(sortedWorktrees, deferredQuery.trim(), repoMap, prCache, issueCache),
[sortedWorktrees, deferredQuery, repoMap, prCache, issueCache]
)
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
@ -233,8 +227,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
])
const browserMatches = useMemo(
() => searchBrowserPages(browserPageEntries, debouncedQuery.trim()),
[browserPageEntries, debouncedQuery]
() => searchBrowserPages(browserPageEntries, deferredQuery.trim()),
[browserPageEntries, deferredQuery]
)
const worktreeItems = useMemo<WorktreePaletteItem[]>(
@ -280,7 +274,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const listEntries = useMemo<PaletteListEntry[]>(() => {
const entries: PaletteListEntry[] = []
const bothSectionsPopulated = worktreeItems.length > 0 && browserItems.length > 0
const hasQuery = debouncedQuery.trim().length > 0
const hasQuery = deferredQuery.trim().length > 0
const EMPTY_QUERY_BROWSER_PREVIEW = 3
const visibleWorktreeItems = worktreeItems
@ -306,21 +300,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
entries.push(...visibleBrowserItems)
}
return entries
}, [worktreeItems, browserItems, debouncedQuery])
}, [worktreeItems, browserItems, deferredQuery])
const selectableItems = useMemo<PaletteItem[]>(
() => listEntries.filter((e): e is PaletteItem => e.type !== 'section-header'),
[listEntries]
)
const createWorktreeName = debouncedQuery.trim()
const createWorktreeName = deferredQuery.trim()
const showCreateAction =
canCreateWorktree && createWorktreeName.length > 0 && worktreeItems.length === 0
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
const hasAnyWorktrees = sortedWorktrees.length > 0
const hasAnyBrowserPages = browserPageEntries.length > 0
const hasQuery = debouncedQuery.trim().length > 0
const hasQuery = deferredQuery.trim().length > 0
useEffect(() => {
if (visible && !wasVisibleRef.current) {
@ -344,7 +338,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
skipRestoreFocusRef.current = false
prevQueryRef.current = ''
setQuery('')
setDebouncedQuery('')
setSelectedItemId('')
}
@ -355,8 +348,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
if (!visible) {
return
}
const queryChanged = debouncedQuery !== prevQueryRef.current
prevQueryRef.current = debouncedQuery
const queryChanged = deferredQuery !== prevQueryRef.current
prevQueryRef.current = deferredQuery
const firstSelectableId = showCreateAction ? '__create_worktree__' : null
@ -385,7 +378,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
) {
setSelectedItemId(firstSelectableId ?? selectableItems[0].id)
}
}, [debouncedQuery, selectedItemId, showCreateAction, visible, selectableItems])
}, [deferredQuery, selectedItemId, showCreateAction, visible, selectableItems])
const focusFallbackSurface = useCallback(() => {
requestAnimationFrame(() => {
@ -522,7 +515,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// is repo-agnostic on the worktree meta side. We don't currently cache a
// repo-slug map, so slug-matching against a specific repo happens
// implicitly when we pick a repo for the `gh workItem` lookup below.
const allWorktrees = Object.values(state.worktreesByRepo).flat()
const matches = allWorktrees.filter(
(w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number)
)
@ -577,7 +569,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// Case 2: user typed a raw issue number. Resolve against the active repo.
if (ghNumber !== null) {
const state = useAppStore.getState()
const allWorktrees = Object.values(state.worktreesByRepo).flat()
const matches = allWorktrees.filter(
(w) => !w.isArchived && (w.linkedIssue === ghNumber || w.linkedPR === ghNumber)
)
@ -589,8 +580,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
}
const repoForLookup =
(state.activeRepoId && state.repos.find((r) => r.id === state.activeRepoId)) ||
state.repos.find((r) => isGitRepoKind(r))
(state.activeRepoId ? (repoMap.get(state.activeRepoId) ?? null) : null) ||
[...getRepoMapFromState(state).values()].find((repo) => isGitRepoKind(repo))
if (!repoForLookup || !isGitRepoKind(repoForLookup)) {
openComposer({ prefilledName: trimmed })
return
@ -628,7 +619,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// Case 3: plain name — open composer prefilled.
openComposer(trimmed ? { prefilledName: trimmed } : {})
}, [closeModal, createWorktreeName, openModal])
}, [allWorktrees, closeModal, createWorktreeName, openModal, repoMap])
const handleCloseAutoFocus = useCallback((e: Event) => {
e.preventDefault()
@ -921,7 +912,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
</div>
</div>
<div aria-live="polite" className="sr-only">
{debouncedQuery.trim()
{deferredQuery.trim()
? `${resultCount} results found${showCreateAction ? ', create new worktree action available' : ''}`
: `${resultCount} items available${showCreateAction ? ', create new worktree action available' : ''}`}
</div>

View File

@ -1,8 +1,9 @@
/* eslint-disable max-lines -- Why: the checks panel co-locates PR header, checks, comments,
merge actions, and conflict state in one component to keep the data flow straightforward. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { isFolderRepo } from '../../../../shared/repo-kind'
import PRActions from './PRActions'
@ -17,9 +18,9 @@ import {
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
export default function ChecksPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const repo = useRepoById(activeWorktree?.repoId ?? null)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
@ -69,21 +70,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
// Find active worktree and repo
const { worktree, repo } = useMemo(() => {
if (!activeWorktreeId) {
return { worktree: null, repo: null }
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
const r = repos.find((rp) => rp.id === wt.repoId)
return { worktree: wt, repo: r ?? null }
}
}
return { worktree: null, repo: null }
}, [activeWorktreeId, worktreesByRepo, repos])
const branch = worktree ? worktree.branch.replace(/^refs\/heads\//, '') : ''
const branch = activeWorktree ? activeWorktree.branch.replace(/^refs\/heads\//, '') : ''
const isFolder = repo ? isFolderRepo(repo) : false
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
@ -340,7 +327,7 @@ export default function ChecksPanel(): React.JSX.Element {
}, [pr])
// ── Empty state ──
if (!worktree) {
if (!activeWorktree) {
return (
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">No worktree selected</div>
@ -491,8 +478,8 @@ export default function ChecksPanel(): React.JSX.Element {
)}
{/* Merge / Delete Worktree actions */}
{worktree && repo && (
<PRActions pr={pr} repo={repo} worktree={worktree} onRefreshPR={handleRefreshPR} />
{activeWorktree && repo && (
<PRActions pr={pr} repo={repo} worktree={activeWorktree} onRefreshPR={handleRefreshPR} />
)}
</div>

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Loader2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree } from '@/store/selectors'
import { dirname } from '@/lib/path'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
@ -17,7 +18,6 @@ import { useFileExplorerReveal } from './useFileExplorerReveal'
import { useFileExplorerInlineInput } from './useFileExplorerInlineInput'
import { clearFileExplorerUndoHistory } from './fileExplorerUndoRedo'
import { useFileExplorerKeys } from './useFileExplorerKeys'
import { useActiveWorktreePath } from './useActiveWorktreePath'
import { useFileDuplicate } from './useFileDuplicate'
import { useFileExplorerDragDrop } from './useFileExplorerDragDrop'
import { useFileExplorerImport } from './useFileExplorerImport'
@ -26,7 +26,7 @@ import { useFileExplorerWatch } from './useFileExplorerWatch'
function FileExplorerInner(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const activeWorktree = useActiveWorktree()
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const expandedDirs = useAppStore((s) => s.expandedDirs)
const toggleDir = useAppStore((s) => s.toggleDir)
@ -39,7 +39,7 @@ function FileExplorerInner(): React.JSX.Element {
const openFiles = useAppStore((s) => s.openFiles)
const closeFile = useAppStore((s) => s.closeFile)
const worktreePath = useActiveWorktreePath(activeWorktreeId, worktreesByRepo)
const worktreePath = activeWorktree?.path ?? null
const expanded = useMemo(
() =>

View File

@ -1,6 +1,7 @@
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
import { useActiveWorktree } from '@/store/selectors'
import { getConnectionId } from '@/lib/connection-context'
import type { SearchFileResult, SearchMatch } from '../../../../shared/types'
import { buildSearchRows } from './search-rows'
@ -14,8 +15,8 @@ const SEARCH_VIRTUAL_OVERSCAN = 12
const EMPTY_COLLAPSED_FILES = new Set<string>()
export default function Search(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const openFile = useAppStore((s) => s.openFile)
const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
@ -82,19 +83,7 @@ export default function Search(): React.JSX.Element {
updateActiveSearchState({ loading: false })
}, [updateActiveSearchState])
// Find active worktree path
const worktreePath = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
return wt.path
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const worktreePath = activeWorktree?.path ?? null
// Focus input on mount
useEffect(() => {

View File

@ -25,6 +25,7 @@ import {
X
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
@ -104,10 +105,10 @@ const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
function SourceControlInner(): React.JSX.Element {
const sourceControlRef = useRef<HTMLDivElement>(null)
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree)
@ -180,23 +181,6 @@ function SourceControlInner(): React.JSX.Element {
const [filterQuery, setFilterQuery] = useState('')
const filterInputRef = useRef<HTMLInputElement>(null)
const activeWorktree = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const worktree = worktrees.find((entry) => entry.id === activeWorktreeId)
if (worktree) {
return worktree
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const activeRepo = useMemo(
() => repos.find((repo) => repo.id === activeWorktree?.repoId) ?? null,
[activeWorktree?.repoId, repos]
)
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const worktreePath = activeWorktree?.path ?? null
const entries = useMemo(

View File

@ -1,11 +1,13 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor'
import type { CheckStatus } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import {
ContextMenu,
@ -35,31 +37,15 @@ function branchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
function findWorktreeById(
worktreesByRepo: ReturnType<typeof useAppStore.getState>['worktreesByRepo'],
worktreeId: string | null
) {
if (!worktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const worktree = worktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
return worktree
}
}
return null
}
function getActiveChecksStatus(state: ReturnType<typeof useAppStore.getState>): CheckStatus | null {
const activeWorktree = findWorktreeById(state.worktreesByRepo, state.activeWorktreeId)
const activeWorktree = state.activeWorktreeId
? findWorktreeById(state.worktreesByRepo, state.activeWorktreeId)
: null
if (!activeWorktree) {
return null
}
const activeRepo = state.repos.find((repo) => repo.id === activeWorktree.repoId)
const activeRepo = getRepoMapFromState(state).get(activeWorktree.repoId)
if (!activeRepo) {
return null
}
@ -115,6 +101,7 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
]
function RightSidebarInner(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
@ -127,10 +114,7 @@ function RightSidebarInner(): React.JSX.Element {
// Why: source control and checks are meaningless for non-git folders.
// Hide those tabs so the activity bar only shows relevant actions.
const activeRepo = useAppStore((s) => {
const wt = findWorktreeById(s.worktreesByRepo, s.activeWorktreeId)
return wt ? (s.repos.find((r) => r.id === wt.repoId) ?? null) : null
})
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const visibleItems = useMemo(
() => (isFolder ? ACTIVITY_ITEMS.filter((item) => !item.gitOnly) : ACTIVITY_ITEMS),

View File

@ -1,23 +0,0 @@
import { useMemo } from 'react'
import type { Worktree } from '../../../../shared/types'
/**
* Resolves the on-disk path for the currently active worktree.
*/
export function useActiveWorktreePath(
activeWorktreeId: string | null,
worktreesByRepo: Record<string, Worktree[]>
): string | null {
return useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
return wt.path
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } from '@/store/selectors'
import type { GitConflictOperation, GitStatusResult } from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getConnectionId } from '@/lib/connection-context'
@ -7,38 +8,18 @@ import { getConnectionId } from '@/lib/connection-context'
const POLL_INTERVAL_MS = 3000
export function useGitStatusPolling(): void {
const activeWorktree = useActiveWorktree()
const allWorktrees = useAllWorktrees()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const setConflictOperation = useAppStore((s) => s.setConflictOperation)
const conflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
const repoMap = useRepoMap()
const worktreePath = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
return wt.path
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const activeRepoId = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
if (worktrees.some((wt) => wt.id === activeWorktreeId)) {
return repoId
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const activeRepo = useAppStore((s) => s.repos.find((repo) => repo.id === activeRepoId) ?? null)
const worktreePath = activeWorktree?.path ?? null
const activeRepoId = activeWorktree?.repoId ?? null
const activeRepo = useRepoById(activeRepoId)
const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false
// Why: build a list of non-active worktrees that still have a known conflict
@ -51,20 +32,17 @@ export function useGitStatusPolling(): void {
if (worktreeId === activeWorktreeId || op === 'unknown') {
continue
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === worktreeId)
if (wt) {
const repo = useAppStore.getState().repos.find((entry) => entry.id === wt.repoId)
if (repo && !isGitRepoKind(repo)) {
break
}
result.push({ id: wt.id, path: wt.path })
break
const worktree = allWorktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
const repo = repoMap.get(worktree.repoId)
if (repo && !isGitRepoKind(repo)) {
continue
}
result.push({ id: worktree.id, path: worktree.path })
}
}
return result
}, [conflictOperationByWorktree, activeWorktreeId, worktreesByRepo])
}, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap])
const fetchStatus = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !activeRepoSupportsGit) {

View File

@ -1,6 +1,7 @@
import React from 'react'
import { Github, ListChecks } from 'lucide-react'
import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getTaskPresetQuery } from '@/lib/new-workspace'
@ -17,6 +18,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const openTaskPage = useAppStore((s) => s.openTaskPage)
const activeView = useAppStore((s) => s.activeView)
const repos = useAppStore((s) => s.repos)
const repoMap = useRepoMap()
const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo))
// Why: warm the GitHub work-item cache on hover/focus so by the time the
@ -29,8 +31,9 @@ const SidebarNav = React.memo(function SidebarNav() {
if (!canBrowseTasks) {
return
}
const activeRepo = repos.find((r) => r.id === activeRepoId && isGitRepoKind(r))
const firstGitRepo = activeRepo ?? repos.find((r) => isGitRepoKind(r))
const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null
const activeGitRepo = activeRepo && isGitRepoKind(activeRepo) ? activeRepo : null
const firstGitRepo = activeGitRepo ?? repos.find((r) => isGitRepoKind(r))
if (firstGitRepo?.path) {
// Why: warm the exact cache key the page will read on mount — must
// match TaskPage's `initialTaskQuery` derived from the same default
@ -43,7 +46,7 @@ const SidebarNav = React.memo(function SidebarNav() {
getTaskPresetQuery(defaultTaskViewPreset)
)
}
}, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repos])
}, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, repos])
const tasksActive = activeView === 'tasks'

View File

@ -21,6 +21,7 @@ import {
Trash2
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import type { Worktree } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { runWorktreeDeleteWithToast } from './delete-worktree-flow'
@ -35,7 +36,7 @@ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, children }: Props) {
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const repo = useRepoById(worktree.repoId)
const skipDeleteConfirm = useAppStore((s) => s.settings?.skipDeleteWorktreeConfirm ?? false)
const shutdownWorktreeTerminals = useAppStore((s) => s.shutdownWorktreeTerminals)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@ -45,7 +46,6 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const isDeleting = deleteState?.isDeleting ?? false
const repo = repos.find((entry) => entry.id === worktree.repoId)
const isFolder = repo ? isFolderRepo(repo) : false
useEffect(() => {

View File

@ -3,6 +3,12 @@ import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffe
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronDown, CircleX, Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import {
getAllWorktreesFromState,
useAllWorktrees,
useRepoMap,
useWorktreeMap
} from '@/store/selectors'
import WorktreeCard from './WorktreeCard'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@ -388,8 +394,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const WorktreeList = React.memo(function WorktreeList() {
// ── Granular selectors (each is a primitive or shallow-stable ref) ──
const allWorktrees = useAllWorktrees()
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const searchQuery = useAppStore((s) => s.searchQuery)
const groupBy = useAppStore((s) => s.groupBy)
@ -428,15 +436,13 @@ const WorktreeList = React.memo(function WorktreeList() {
// can apply immediately when the list shape changes.
const worktreeCount = useMemo(() => {
let count = 0
for (const ws of Object.values(worktreesByRepo)) {
for (const w of ws) {
if (!w.isArchived) {
count++
}
for (const worktree of allWorktrees) {
if (!worktree.isArchived) {
count++
}
}
return count
}, [worktreesByRepo])
}, [allWorktrees])
// Why debounce: sort scores include a time-decaying activity component.
// Recomputing instantly on every sortEpoch bump (e.g. AI starting work,
@ -476,13 +482,6 @@ const WorktreeList = React.memo(function WorktreeList() {
// reverts, so the cold-start path is only used on actual cold start.
const sessionHasHadPty = useRef(false)
const repoMap = useMemo(() => {
const m = new Map<string, Repo>()
for (const r of repos) {
m.set(r.id, r)
}
return m
}, [repos])
// ── Stable sort order ──────────────────────────────────────────
// The sort order is cached and only recomputed when `sortEpoch` changes
// (worktree add/remove, terminal activity, backend refresh, etc.).
@ -496,9 +495,9 @@ const WorktreeList = React.memo(function WorktreeList() {
// first render (and epoch bumps) would use stale/empty data from the ref.
const sortedIds = useMemo(() => {
const state = useAppStore.getState()
const allWorktrees: Worktree[] = Object.values(state.worktreesByRepo)
.flat()
.filter((w) => !w.isArchived)
const nonArchivedWorktrees = getAllWorktreesFromState(state).filter(
(worktree) => !worktree.isArchived
)
// Why cold-start detection: the smart score is dominated by ephemeral
// signals (running jobs +60, live terminals +12, needs attention +35)
@ -514,24 +513,23 @@ const WorktreeList = React.memo(function WorktreeList() {
if (hasAnyLivePty) {
sessionHasHadPty.current = true
} else {
allWorktrees.sort(
nonArchivedWorktrees.sort(
(a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName)
)
return allWorktrees.map((w) => w.id)
return nonArchivedWorktrees.map((w) => w.id)
}
}
const currentRepoMap = new Map(state.repos.map((r) => [r.id, r]))
const currentTabs = state.tabsByWorktree
allWorktrees.sort(
buildWorktreeComparator(sortBy, currentTabs, currentRepoMap, state.prCache, Date.now())
nonArchivedWorktrees.sort(
buildWorktreeComparator(sortBy, currentTabs, repoMap, state.prCache, Date.now())
)
return allWorktrees.map((w) => w.id)
return nonArchivedWorktrees.map((w) => w.id)
// debouncedSortEpoch is an intentional trigger: it's not read inside the
// memo, but its change signals that the sort order should be recomputed.
// The debounce prevents jarring mid-interaction position shifts.
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSortEpoch, sortBy, repos])
}, [debouncedSortEpoch, repoMap, sortBy])
// Persist the computed sort order so the sidebar can be restored after
// restart. Only persist during live sessions (sessionHasHadPty latched) —
@ -557,16 +555,8 @@ const WorktreeList = React.memo(function WorktreeList() {
prCache,
issueCache
})
// Resolve IDs back to Worktree objects for rendering
const allMap = new Map<string, Worktree>()
for (const ws of Object.values(worktreesByRepo)) {
for (const w of ws) {
allMap.set(w.id, w)
}
}
return ids.map((id) => allMap.get(id)).filter((w): w is Worktree => w != null)
return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null)
}, [
worktreesByRepo,
filterRepoIds,
searchQuery,
showActiveOnly,
@ -576,7 +566,9 @@ const WorktreeList = React.memo(function WorktreeList() {
browserTabsByWorktree,
sortedIds,
prCache,
issueCache
issueCache,
worktreeMap,
worktreesByRepo
])
const worktrees = visibleWorktrees

View File

@ -3,6 +3,7 @@ import type { AppState } from '@/store/types'
import { matchesSearch } from './worktree-list-groups'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors'
/**
* Shared pure utility that computes the ordered list of visible (non-archived,
@ -29,7 +30,7 @@ export function computeVisibleWorktreeIds(
issueCache: AppState['issueCache'] | null
}
): string[] {
let all: Worktree[] = Object.values(worktreesByRepo).flat()
let all: Worktree[] = getAllWorktreesFromState({ worktreesByRepo })
// Filter archived
all = all.filter((w) => !w.isArchived)
@ -111,12 +112,10 @@ export function getVisibleWorktreeIds(): string[] {
// Fallback: live recomputation for the window before WorktreeList renders.
const state = useAppStore.getState()
const allWorktrees: Worktree[] = Object.values(state.worktreesByRepo)
.flat()
.filter((w) => !w.isArchived)
const allWorktrees = getAllWorktreesFromState(state).filter((w) => !w.isArchived)
// Hoist repoMap so it's built once and reused across all branches below.
const repoMap = new Map(state.repos.map((r) => [r.id, r]))
const repoMap = getRepoMapFromState(state)
let sortedIds: string[]

View File

@ -21,7 +21,7 @@ import {
import { basename, normalizeRelativePath } from '@/lib/path'
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import { renameFileOnDisk } from '@/lib/rename-file'
import { useAppStore } from '@/store'
import { useWorktreeById } from '@/store/selectors'
import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
import type { GitFileStatus } from '../../../../shared/types'
import type { OpenFile } from '../../store/slices/editor'
@ -63,6 +63,7 @@ export default function EditorFileTab({
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
}): React.JSX.Element {
const worktree = useWorktreeById(file.worktreeId)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
// Why: split groups can duplicate the same open file into multiple visible
// tabs. Using the unified tab ID keeps each rendered tab draggable as a
@ -114,16 +115,7 @@ export default function EditorFileTab({
if (newName === oldName) {
return
}
const worktreePath = (() => {
const state = useAppStore.getState()
for (const worktrees of Object.values(state.worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === file.worktreeId)
if (wt) {
return wt.path
}
}
return null
})()
const worktreePath = worktree?.path ?? null
if (!worktreePath) {
return
}

View File

@ -12,6 +12,7 @@ import type {
TerminalTab
} from '../../../../shared/types'
import { useAppStore } from '../../store'
import { useAllWorktrees } from '../../store/selectors'
import { createUntitledMarkdownFile } from '../../lib/create-untitled-markdown'
import { extractIpcErrorMessage } from '../../lib/ipc-error'
import { destroyPersistentWebview } from '../browser-pane/BrowserPane'
@ -43,6 +44,7 @@ export function useTabGroupWorkspaceModel({
groupId: string
worktreeId: string
}) {
const allWorktrees = useAllWorktrees()
const worktreeState = useAppStore(
useShallow((state) => ({
// Why: Zustand v5 expects selector snapshots to be referentially stable
@ -55,11 +57,7 @@ export function useTabGroupWorkspaceModel({
openFiles: state.openFiles,
browserTabs: state.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
runtimeTerminalTabs: state.tabsByWorktree[worktreeId] ?? EMPTY_RUNTIME_TERMINAL_TABS,
expandedPaneByTabId: state.expandedPaneByTabId,
worktree:
Object.values(state.worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId) ?? null
expandedPaneByTabId: state.expandedPaneByTabId
}))
)
@ -91,6 +89,10 @@ export function useTabGroupWorkspaceModel({
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeState.groups]
)
const worktree = useMemo(
() => allWorktrees.find((candidate) => candidate.id === worktreeId) ?? null,
[allWorktrees, worktreeId]
)
const groupTabs = useMemo(
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeState.unifiedTabs]
@ -378,7 +380,7 @@ export function useTabGroupWorkspaceModel({
terminalTabs,
tabBarOrder,
groupTabs,
worktreePath: worktreeState.worktree?.path,
worktreePath: worktree?.path,
runtimeTerminalTabById,
expandedPaneByTabId: worktreeState.expandedPaneByTabId,
commands: {
@ -416,7 +418,7 @@ export function useTabGroupWorkspaceModel({
// assistive-tech activation because the "+" menu can be triggered from
// an unfocused panel without first updating global group focus.
newFileTab: async () => {
const path = worktreeState.worktree?.path
const path = worktree?.path
if (!path) {
return
}

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
/**
* Returns a stable dispatch function for terminal notifications.
@ -29,8 +30,8 @@ export function useNotificationDispatch(
}
const repoId = worktreeId.includes('::') ? worktreeId.slice(0, worktreeId.indexOf('::')) : ''
const repo = state.repos.find((c) => c.id === repoId)
const worktree = state.allWorktrees().find((c) => c.id === worktreeId)
const repo = getRepoMapFromState(state).get(repoId)
const worktree = getWorktreeMapFromState(state).get(worktreeId)
void window.api.notifications.dispatch({
source: event.source,

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { useShallow } from 'zustand/react/shallow'
import { reconcileTabOrder } from '../tab-bar/reconcile-order'
import {
@ -18,10 +19,10 @@ export type UnifiedTerminalItem = {
}
export function useTerminalTabs() {
const allWorktrees = useAllWorktrees()
const {
activeWorktreeId,
activeView,
worktreesByRepo,
tabsByWorktree,
activeTabId,
tabBarOrderByWorktree,
@ -40,7 +41,6 @@ export function useTerminalTabs() {
useShallow((s) => ({
activeWorktreeId: s.activeWorktreeId,
activeView: s.activeView,
worktreesByRepo: s.worktreesByRepo,
tabsByWorktree: s.tabsByWorktree,
activeTabId: s.activeTabId,
tabBarOrderByWorktree: s.tabBarOrderByWorktree,
@ -62,7 +62,6 @@ export function useTerminalTabs() {
() => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, tabsByWorktree]
)
const allWorktrees = Object.values(worktreesByRepo).flat()
const worktreeFiles = useMemo(
() => (activeWorktreeId ? openFiles.filter((f) => f.worktreeId === activeWorktreeId) : []),
[activeWorktreeId, openFiles]

View File

@ -1,22 +1,99 @@
import { useAppStore } from './index'
import { useShallow } from 'zustand/react/shallow'
import type { Worktree, TerminalTab } from '../../../shared/types'
import type { Repo, Worktree, TerminalTab } from '../../../shared/types'
import type { AppState } from './types'
const EMPTY_WORKTREES: Worktree[] = []
const EMPTY_TABS: TerminalTab[] = []
type WorktreeSnapshot = {
allWorktrees: Worktree[]
worktreeMap: Map<string, Worktree>
}
// Why: Zustand reruns selectors on every write, so hot-path flatten/map work
// needs cross-render caching. WeakMap ties each snapshot to the store slice ref
// without pinning old test/dev instances in memory once that slice is replaced.
const worktreeSnapshotCache = new WeakMap<AppState['worktreesByRepo'], WorktreeSnapshot>()
const repoMapCache = new WeakMap<AppState['repos'], Map<string, Repo>>()
function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot {
const cachedSnapshot = worktreeSnapshotCache.get(worktreesByRepo)
if (cachedSnapshot) {
return cachedSnapshot
}
const allWorktrees = Object.values(worktreesByRepo).flat()
const worktreeMap = new Map<string, Worktree>()
for (const worktree of allWorktrees) {
worktreeMap.set(worktree.id, worktree)
}
const snapshot = { allWorktrees, worktreeMap }
worktreeSnapshotCache.set(worktreesByRepo, snapshot)
return snapshot
}
function getCachedAllWorktrees(worktreesByRepo: AppState['worktreesByRepo']): Worktree[] {
return getWorktreeSnapshot(worktreesByRepo).allWorktrees
}
function getCachedWorktreeMap(worktreesByRepo: AppState['worktreesByRepo']): Map<string, Worktree> {
const snapshot = worktreeSnapshotCache.get(worktreesByRepo)
if (snapshot) {
return snapshot.worktreeMap
}
return getWorktreeSnapshot(worktreesByRepo).worktreeMap
}
function getCachedRepoMap(repos: AppState['repos']): Map<string, Repo> {
const cachedMap = repoMapCache.get(repos)
if (cachedMap) {
return cachedMap
}
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
repoMapCache.set(repos, repoMap)
return repoMap
}
export function getAllWorktreesFromState(state: Pick<AppState, 'worktreesByRepo'>): Worktree[] {
return getCachedAllWorktrees(state.worktreesByRepo)
}
export function getWorktreeMapFromState(
state: Pick<AppState, 'worktreesByRepo'>
): Map<string, Worktree> {
return getCachedWorktreeMap(state.worktreesByRepo)
}
export function getRepoMapFromState(state: Pick<AppState, 'repos'>): Map<string, Repo> {
return getCachedRepoMap(state.repos)
}
// ─── Repos ──────────────────────────────────────────────────────────
export const useRepos = () => useAppStore((s) => s.repos)
export const useActiveRepoId = () => useAppStore((s) => s.activeRepoId)
export const useActiveRepo = () =>
useAppStore(useShallow((s) => s.repos.find((r) => r.id === s.activeRepoId) ?? null))
export const useRepoMap = () => useAppStore((s) => getCachedRepoMap(s.repos))
export const useRepoById = (repoId: string | null) =>
useAppStore((s) => (repoId ? (getCachedRepoMap(s.repos).get(repoId) ?? null) : null))
// ─── Worktrees ──────────────────────────────────────────────────────
export const useActiveWorktreeId = () => useAppStore((s) => s.activeWorktreeId)
export const useWorktreesForRepo = (repoId: string | null) =>
useAppStore((s) => (repoId ? (s.worktreesByRepo[repoId] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES))
export const useAllWorktrees = () =>
useAppStore(useShallow((s) => Object.values(s.worktreesByRepo).flat()))
export const useAllWorktrees = () => useAppStore((s) => getCachedAllWorktrees(s.worktreesByRepo))
export const useWorktreeMap = () => useAppStore((s) => getCachedWorktreeMap(s.worktreesByRepo))
export const useWorktreeById = (worktreeId: string | null) =>
useAppStore((s) =>
worktreeId ? (getCachedWorktreeMap(s.worktreesByRepo).get(worktreeId) ?? null) : null
)
export const useActiveWorktree = () => {
const activeWorktreeId = useActiveWorktreeId()
return useWorktreeById(activeWorktreeId)
}
// ─── Terminals ──────────────────────────────────────────────────────
export const useActiveTerminalTabs = () =>