refactor(renderer): reduce render-time store churn (#966)
This commit is contained in:
parent
45ad798d97
commit
3d8cdcd12c
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue