diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 54405a496fa..8a7164df77e 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -22,26 +22,28 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { useRepoById } from '@/store/selectors' +import { cn } from '@/lib/utils' import type { Worktree } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' -import { runWorktreeDeleteWithToast } from './delete-worktree-flow' +import { runWorktreeDelete } from './delete-worktree-flow' +import { runSleepWorktree } from './sleep-worktree-flow' type Props = { worktree: Worktree children: React.ReactNode + contentClassName?: string } const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' -const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, children }: Props) { +const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ + worktree, + children, + contentClassName +}: Props) { const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const openModal = useAppStore((s) => s.openModal) 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) - const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) - const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) @@ -101,20 +103,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, }, [worktree.id, worktree.displayName, worktree.linkedIssue, worktree.comment, openModal]) const handleCloseTerminals = useCallback(async () => { - // Why: shutting down the currently active worktree while its TerminalPane - // is still visible causes a visible "reboot" flicker and can crash the - // pane. clearTransientTerminalState nulls each tab's ptyId in place - // without bumping generation, so TerminalPane stays mounted while its - // PTYs are being killed; PTY exit callbacks then race against the live - // xterm instance. Boot the user to the landing page FIRST so the visible - // surface is detached before the async teardown runs. - if (activeWorktreeId === worktree.id) { - setActiveWorktree(null) - } - await shutdownWorktreeTerminals(worktree.id) - }, [worktree.id, shutdownWorktreeTerminals, activeWorktreeId, setActiveWorktree]) + await runSleepWorktree(worktree.id) + }, [worktree.id]) const handleDelete = useCallback(() => { + // Folder mode handled inline because it routes to a different modal; + // standard delete delegates to the shared runWorktreeDelete helper. setMenuOpen(false) if (isFolder) { // Why: folder mode reuses the worktree row UI for a synthetic root entry, @@ -126,30 +120,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, }) return } - clearWorktreeDeleteState(worktree.id) - // Why: when the user has opted into skipping the confirmation, jump - // straight to the same delete-with-toast flow the dialog would run on - // confirm. The force-delete fallback still surfaces through the toast's - // "Force Delete" action, so the user never silently loses dirty work — - // they just skip the redundant "are you sure?" step for clean deletes. - // The dialog stays the entry point for the main worktree (guarded at the - // DropdownMenuItem level) and for any worktree that becomes unavailable - // mid-action, because those cases produce dialog-specific UI. - if (skipDeleteConfirm && !worktree.isMainWorktree) { - runWorktreeDeleteWithToast(worktree.id, worktree.displayName) - return - } - openModal('delete-worktree', { worktreeId: worktree.id }) - }, [ - worktree.id, - worktree.repoId, - worktree.displayName, - worktree.isMainWorktree, - clearWorktreeDeleteState, - isFolder, - openModal, - skipDeleteConfirm - ]) + // Why delegate to runWorktreeDelete: keeps the skip-confirm vs. modal + // decision tree (and its rationale) in one place shared with the memory + // popover's inline Delete action. Folder mode short-circuits above + // because the confirm-remove-folder modal is unique to this caller. + runWorktreeDelete(worktree.id) + }, [worktree.id, worktree.repoId, worktree.displayName, isFolder, openModal]) return ( <> @@ -175,7 +151,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, style={{ left: menuPoint.x, top: menuPoint.y }} /> - + Open in Finder diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index 103ce1eecb9..3a630528f72 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' +import { getWorktreeMapFromState } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' @@ -73,3 +74,43 @@ export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: str }) }) } + +/** + * Shared funnel for the standard (non-folder) delete decision tree, called + * from both WorktreeContextMenu and MemoryStatusSegment. Mirrors the + * `runSleepWorktree` pattern: reads state imperatively so the helper can be + * invoked from any handler without plumbing selectors through props, then + * branches on the user's `skipDeleteWorktreeConfirm` preference — either + * running the delete immediately with toast feedback, or opening the + * confirmation modal. + * + * Why folder mode is handled at the call site: folder-repo removal branches + * to a different modal (`confirm-remove-folder`) and the folder-vs-git + * determination requires the full Worktree record's repoId. Keeping that + * decision adjacent to the caller (rather than branching inside this helper) + * avoids bleeding folder-mode concerns into what is otherwise a simple + * skip-confirm-vs-modal decision, and lets the context menu short-circuit + * before ever entering this funnel. + * + * The main-worktree / missing-record guard here is defense-in-depth — the + * caller is responsible for disabling UI when this is known ahead of time, + * but we still refuse to act if the record disappeared between render and + * click (e.g. a concurrent delete or state reset). + */ +export function runWorktreeDelete(worktreeId: string): void { + const state = useAppStore.getState() + const target = getWorktreeMapFromState(state).get(worktreeId) ?? null + // Guard: main worktrees cannot be deleted, and a missing record means the + // worktree was removed out from under us — either way, no-op silently + // rather than opening a modal with stale/invalid context. + if (!target || target.isMainWorktree) { + return + } + state.clearWorktreeDeleteState(worktreeId) + const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false + if (skipConfirm) { + runWorktreeDeleteWithToast(worktreeId, target.displayName) + return + } + state.openModal('delete-worktree', { worktreeId }) +} diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts new file mode 100644 index 00000000000..b8e2f6f5f8f --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -0,0 +1,32 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' + +/** + * Shared "sleep worktree" flow (close all panels to free memory / CPU) + * used by WorktreeContextMenu and MemoryStatusSegment's per-row hover action. + * + * Why this is a module helper rather than inlined at each call site: the guard + * that clears `activeWorktreeId` before tearing down terminals isn't optional + * polish — shutting down the active worktree while its TerminalPane is still + * visible causes a visible "reboot" flicker and can crash the pane (PTY exit + * callbacks race against the live xterm instance). See the original comment + * in WorktreeContextMenu's handleCloseTerminals for the full reasoning. + * Centralizing the sequence here keeps that safety invariant in one place so + * a new caller can't accidentally skip it. + */ +export async function runSleepWorktree(worktreeId: string): Promise { + const { activeWorktreeId, setActiveWorktree, shutdownWorktreeTerminals } = useAppStore.getState() + if (activeWorktreeId === worktreeId) { + setActiveWorktree(null) + } + try { + await shutdownWorktreeTerminals(worktreeId) + } catch (err) { + // Why: callers are fire-and-forget; surface the failure as a toast and + // otherwise continue — the active-worktree reset already happened so we + // don't leave the UI in a stale state. + toast.error('Failed to sleep workspace', { + description: err instanceof Error ? err.message : String(err) + }) + } +} diff --git a/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx b/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx index 5fa6079796e..4e314a52fbd 100644 --- a/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/MemoryStatusSegment.tsx @@ -2,25 +2,22 @@ sparkline, and the formatters are all small pieces that only exist to serve this one status-bar segment. Keeping them co-located follows the same convention as the other *StatusSegment.tsx files (see StatusBar.tsx). */ -import React, { memo, useEffect, useMemo, useState } from 'react' -import { ArrowDownWideNarrow, ChevronDown, ChevronRight, MemoryStick } from 'lucide-react' +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { ChevronDown, ChevronRight, MemoryStick, Moon, Trash2 } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '../../store' +import { useWorktreeMap } from '../../store/selectors' +import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' +import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import type { AppMemory, SessionMemory, TerminalTab, UsageValues, + Worktree, WorktreeMemory } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' @@ -31,12 +28,6 @@ const POLL_MS = 2_000 type SortOption = 'memory' | 'cpu' | 'name' -const SORT_LABELS: Record = { - memory: 'Memory', - cpu: 'CPU', - name: 'Name' -} - const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' const CPU_COLUMN_CLS = 'w-12 text-right' const MEM_COLUMN_CLS = 'w-16 text-right' @@ -144,14 +135,21 @@ function bucketByRepo(worktrees: WorktreeMemory[]): RepoGroup[] { return [...map.values()] } -function sortWorktreesBy(list: WorktreeMemory[], sort: SortOption): WorktreeMemory[] { +function sortWorktreesBy( + list: WorktreeMemory[], + sort: SortOption, + labelFor: (wt: WorktreeMemory) => string +): WorktreeMemory[] { const copy = [...list] if (sort === 'memory') { copy.sort((a, b) => b.memory - a.memory) } else if (sort === 'cpu') { copy.sort((a, b) => b.cpu - a.cpu) } else { - copy.sort((a, b) => a.worktreeName.localeCompare(b.worktreeName)) + // Why labelFor instead of worktreeName: the row label prefers the + // user-editable displayName over the dirname, so alphabetical order + // needs to match what the user actually sees in the list. + copy.sort((a, b) => labelFor(a).localeCompare(labelFor(b))) } return copy } @@ -251,41 +249,7 @@ const Sparkline = memo(SparklineImpl, (a, b) => { return true }) -// ─── Leaf UI: metric chip + row ───────────────────────────────────── - -function MetricChip({ - label, - value, - tooltip -}: { - label: string - value: string - tooltip?: string -}): React.JSX.Element { - const body = ( -
- - {label} - - - {value} - -
- ) - if (!tooltip) { - return body - } - return ( - - {body} - {/* Why z-[70]: parent PopoverContent stacks at z-[60]; the default - tooltip z-50 would render behind it. */} - - {tooltip} - - - ) -} +// ─── Leaf UI: metric row ──────────────────────────────────────────── function MetricPair({ cpu, @@ -307,20 +271,49 @@ function MetricPair({ // ─── Section: app (main / renderer / other) ───────────────────────── -function AppSection({ app }: { app: AppMemory }): React.JSX.Element { +function AppSection({ + app, + isCollapsed, + onToggle +}: { + app: AppMemory + isCollapsed: boolean + onToggle: () => void +}): React.JSX.Element { return ( -
-
- Orca App -
- - +
+
+ +
+ + Orca + +
+ + +
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - + {!isCollapsed && ( +
+ + + {(app.other.cpu > 0 || app.other.memory > 0) && ( + + )} +
)}
) @@ -344,7 +337,9 @@ function WorktreeSection({ toggleRepo, collapsedWorktrees, toggleWorktree, - navigateToWorktree + navigateToWorktree, + onSleep, + onDelete }: { worktrees: WorktreeMemory[] sortOption: SortOption @@ -353,6 +348,8 @@ function WorktreeSection({ collapsedWorktrees: Set toggleWorktree: (worktreeId: string) => void navigateToWorktree: (worktreeId: string) => void + onSleep: (worktreeId: string) => void + onDelete: (worktreeId: string) => void }): React.JSX.Element { // Why: these slices mutate frequently (runtimePaneTitlesByTabId updates on // every terminal OSC escape). Subscribing inside WorktreeSection — which @@ -361,17 +358,59 @@ function WorktreeSection({ const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + // Why: WorktreeMemory is a lightweight snapshot; we look the real Worktree + // record up from the store so rows can (a) disable Delete for the main + // worktree, and (b) render the user-editable displayName instead of the + // dirname. Use the shared cached selector so we don't duplicate the + // WeakMap-cached Map the rest of the app already shares. + const worktreeById = useWorktreeMap() + + // Shared label resolver: prefer displayName, fall back to the dirname + // carried on the memory snapshot. Used for both rendering and alpha-sort. + const labelFor = useCallback( + (wt: WorktreeMemory): string => + worktreeById.get(wt.worktreeId)?.displayName?.trim() || wt.worktreeName, + [worktreeById] + ) + // Memoize grouping: popover polls every 2s, so without this we'd rebuild // the Map + arrays on every render even when nothing changed. const repoGroups = useMemo( () => sortRepoGroupsBy(bucketByRepo(worktrees), sortOption).map((group) => ({ ...group, - worktrees: sortWorktreesBy(group.worktrees, sortOption) + worktrees: sortWorktreesBy(group.worktrees, sortOption, labelFor) })), - [worktrees, sortOption] + [worktrees, sortOption, labelFor] ) + // Why: when only one repo is active, the repo header row adds a useless + // level of nesting — the worktrees are the interesting thing. Flatten + // straight to worktree rows in that case. + const singleRepo = repoGroups.length === 1 + + const renderWorktree = (wt: WorktreeMemory): React.JSX.Element => { + const storeRecord = worktreeById.get(wt.worktreeId) ?? null + return ( + toggleWorktree(wt.worktreeId)} + onNavigate={() => navigateToWorktree(wt.worktreeId)} + onSleep={() => onSleep(wt.worktreeId)} + onDelete={() => onDelete(wt.worktreeId)} + tabsByWorktree={tabsByWorktree} + runtimePaneTitlesByTabId={runtimePaneTitlesByTabId} + /> + ) + } + + if (singleRepo) { + return <>{repoGroups[0].worktrees.map(renderWorktree)} + } + return ( <> {repoGroups.map((group) => { @@ -400,19 +439,7 @@ function WorktreeSection({
{!repoCollapsed && ( -
- {group.worktrees.map((wt) => ( - toggleWorktree(wt.worktreeId)} - onNavigate={() => navigateToWorktree(wt.worktreeId)} - tabsByWorktree={tabsByWorktree} - runtimePaneTitlesByTabId={runtimePaneTitlesByTabId} - /> - ))} -
+
{group.worktrees.map(renderWorktree)}
)}
) @@ -423,30 +450,44 @@ function WorktreeSection({ function WorktreeRow({ worktree, + storeRecord, isCollapsed, onToggle, onNavigate, + onSleep, + onDelete, tabsByWorktree, runtimePaneTitlesByTabId }: { worktree: WorktreeMemory + storeRecord: Worktree | null isCollapsed: boolean onToggle: () => void onNavigate: () => void + onSleep: () => void + onDelete: () => void tabsByWorktree: Record runtimePaneTitlesByTabId: Record> }): React.JSX.Element { const hasSessions = worktree.sessions.length > 0 + // Why: actions are only meaningful for real worktrees — orphan/unknown + // rows are synthetic buckets with no row to act on. + const showActions = worktree.worktreeId !== ORPHAN_WORKTREE_ID && storeRecord !== null + const isMainWorktree = storeRecord?.isMainWorktree ?? false + // Why: Worktree.displayName is the user-editable workspace name (set via + // Rename). Fall back to the dirname-shaped worktreeName from the memory + // snapshot for orphan/unresolved rows that have no store record. + const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName return (
-
- {hasSessions && ( +
+ {hasSessions ? ( + ) : ( + // Why this width: matches the chevron button's pl-2 + w-3 + pr-0.5 + // footprint so rows without sessions don't shift horizontally + // relative to rows with sessions. + )} +
+ {/* Why the relative wrapper + absolute actions: the sparkline + reserves the space so the row width never changes on hover. + The actions fade in on top of the sparkline (which fades out + in the same transition), preventing the layout "jump" that + happened when the sparkline was toggled via display:none. */} +
+ + + + {showActions && ( + // Why pointer-events pairing: opacity alone leaves the buttons + // clickable when invisible (touch devices have no hover state), + // so the Delete button can fire on an accidental tap. +
+ + + + + + Sleep — close all panels in this workspace to free memory. + + + + + + + + {isMainWorktree ? 'The main workspace cannot be deleted.' : 'Delete workspace.'} + + +
+ )} +
+ +
{!isCollapsed && @@ -494,6 +607,8 @@ function WorktreeRow({ ))}
) + + // Why: wrap real rows in the shared context menu so right-click exposes } // ─── Segment (top-level) ──────────────────────────────────────────── @@ -509,11 +624,23 @@ export function MemoryStatusSegment({ }): React.JSX.Element { const snapshot = useAppStore((s) => s.memorySnapshot) const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) + // Why: worktree metadata (map, skipDeleteWorktreeConfirm, + // clearWorktreeDeleteState, openModal) is only needed at click time inside + // `deleteWorktree`. This segment is always mounted in the status bar, so + // subscribing to those slices at the top level would cause it (and every + // descendant) to re-render on unrelated worktree metadata churn + // (pin/rename/unread/session). The shared `runWorktreeDelete` helper pulls + // them imperatively via `useAppStore.getState()` instead. const [open, setOpen] = useState(false) const [sortOption, setSortOption] = useState('memory') const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) + // Why: the Orca app breakdown (Main/Renderer/Other) is a diagnostic detail + // most users don't need to see every time — collapse it by default and + // surface the per-worktree usage, which is what people usually open this + // popover to investigate. + const [appCollapsed, setAppCollapsed] = useState(true) // Why: only poll while the popover is open. When closed, the badge shows // whatever value was last fetched — good enough for a passive indicator @@ -543,7 +670,11 @@ export function MemoryStatusSegment({ } }, [snapshot]) - const toggleRepo = (repoId: string): void => { + // Why empty deps: these callbacks only call the state setter returned by + // useState, which React guarantees is stable across renders — so we don't + // need to list it. Wrapping in useCallback keeps the reference stable across + // the 2s polling re-renders so descendants can be memoized downstream. + const toggleRepo = useCallback((repoId: string): void => { setCollapsedRepos((prev) => { const next = new Set(prev) if (next.has(repoId)) { @@ -553,9 +684,9 @@ export function MemoryStatusSegment({ } return next }) - } + }, []) - const toggleWorktree = (worktreeId: string): void => { + const toggleWorktree = useCallback((worktreeId: string): void => { setCollapsedWorktrees((prev) => { const next = new Set(prev) if (next.has(worktreeId)) { @@ -565,9 +696,11 @@ export function MemoryStatusSegment({ } return next }) - } + }, []) - const navigateToWorktree = (worktreeId: string): void => { + // Deps intentionally empty: only uses the stable setOpen setter and + // module-level imports (ORPHAN_WORKTREE_ID, activateAndRevealWorktree). + const navigateToWorktree = useCallback((worktreeId: string): void => { // Orphan bucket has a synthetic id with no real worktree to reveal. if (worktreeId === ORPHAN_WORKTREE_ID) { setOpen(false) @@ -581,7 +714,21 @@ export function MemoryStatusSegment({ return } setOpen(false) - } + }, []) + + // Why this thin wrapper: the popover needs to close before the modal/toast + // appears (Radix's outside-pointerdown would otherwise dismiss the dialog). + // The actual decision tree lives in `runWorktreeDelete` so both the popover + // and the sidebar context menu stay in sync. + const deleteWorktree = useCallback((worktreeId: string): void => { + setOpen(false) + runWorktreeDelete(worktreeId) + }, []) + + // Stable callback so onSleep prop identity doesn't churn across polls. + const handleSleep = useCallback((id: string): void => { + void runSleepWorktree(id) + }, []) return ( @@ -607,66 +754,122 @@ export function MemoryStatusSegment({ - -
-
-

- Memory & CPU -

- - - - - {/* Why z-[70]: PopoverContent is z-[60]; the default dropdown - z-50 would render behind it. */} - - { - if (value === 'memory' || value === 'cpu' || value === 'name') { - setSortOption(value) - } - }} + {formatCpu(totalCpu)} + + + + Combined CPU load. Values above 100% mean more than one core is working at once. + + + · + + + - Memory - CPU - Name - - - + {formatMemory(totalMemory)} + + + + Resident memory held by Orca plus the processes under each worktree's + terminals. + + + · + + + + {formatPercent(hostShare)} of system RAM + + + + How much of this machine's physical RAM the Orca-tracked processes are sitting + on. + +
+ )} - {snapshot && ( -
- - - + {/* Why click-to-sort on the column headers: the headers already + label the columns, so doubling them up with a separate sort + control was pure redundancy. The active column is bolded so + users can see at a glance which one drives the order. */} + {snapshot && ( +
+ +
+ +
- )} -
+
+ )}
- {snapshot && } - {snapshot && snapshot.worktrees.length > 0 && ( )} @@ -685,6 +890,17 @@ export function MemoryStatusSegment({
)} + {/* Why Orca App at the bottom: it's a constant baseline everyone has, + so it's less informative than the per-worktree breakdown. Keep + it available but out of the way. */} + {snapshot && ( + setAppCollapsed((v) => !v)} + /> + )} + {!snapshot && (
Loading…
)}