diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index fffd83215..8d3ed2982 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -56,8 +56,10 @@ import { } from './resource-session-navigation' import { getResourceUsageAllWorktrees, + getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, + getResourceUsageTerminalLayoutsByTabId, getResourceUsageTabsByWorktree } from './resource-usage-open-slices' import { @@ -73,9 +75,11 @@ import { countUnboundDaemonSessions, type ResourceSessionBindingInputs } from './resource-session-bindings' +import { createClosedResourceSessionCountSelector } from './resource-session-count-selector' import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 +const selectClosedResourceSessionCount = createClosedResourceSessionCountSelector() type SortOption = 'memory' | 'cpu' | 'name' @@ -737,9 +741,7 @@ export function ResourceUsageStatusSegment({ const memorySnapshotError = useAppStore((s) => s.memorySnapshotError) const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const tabsByWorktreeForPtyBindings = useAppStore((s) => s.tabsByWorktree) - const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + const closedSessionCount = useAppStore(selectClosedResourceSessionCount) const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) @@ -774,17 +776,21 @@ export function ResourceUsageStatusSegment({ const repos = useAppStore((s) => getResourceUsageRepos(s, open)) const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) + // Why: the closed trigger owns a scalar selector. Full binding maps stay + // behind open sentinels so unchanged counts do not rerender the segment. + const ptyIdsByTabId = useAppStore((s) => getResourceUsagePtyIdsByTabId(s, open)) + const terminalLayoutsByTabId = useAppStore((s) => getResourceUsageTerminalLayoutsByTabId(s, open)) const resourceSnapshot = snapshot // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource // Manager also reads restored wake hints, but only for classification. const resourceSessionBindings = useMemo( () => ({ ptyIdsByTabId, - tabsByWorktree: tabsByWorktreeForPtyBindings, + tabsByWorktree, terminalLayoutsByTabId, workspaceSessionReady }), - [ptyIdsByTabId, tabsByWorktreeForPtyBindings, terminalLayoutsByTabId, workspaceSessionReady] + [ptyIdsByTabId, tabsByWorktree, terminalLayoutsByTabId, workspaceSessionReady] ) // Why: after a kill confirms and the session unmounts, focus would otherwise @@ -975,12 +981,6 @@ export function ResourceUsageStatusSegment({ return countUnboundDaemonSessions(sessions, resourceSessionBindings) }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - const closedSessionCount = useMemo(() => { - if (!workspaceSessionReady) { - return 0 - } - return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size - }, [resourceSessionBindings, workspaceSessionReady]) const triggerSessionCount = open ? sessions.length : closedSessionCount const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { diff --git a/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts b/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts new file mode 100644 index 000000000..7d416ff85 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-count-selector.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + buildResourceSessionBindingIndex, + type ResourceSessionBindingInputs +} from './resource-session-bindings' +import { + createClosedResourceSessionCountSelector, + type ClosedResourceSessionCountState +} from './resource-session-count-selector' + +const TAB_COUNT = 100 +const TITLE_WRITES = 600 + +function makeTab(id: string, ptyId: string | null = null, title = 'Terminal'): TerminalTab { + return { + id, + ptyId, + worktreeId: `wt-${id}`, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +function makeState( + overrides: Partial = {} +): ClosedResourceSessionCountState { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true, + ...overrides + } +} + +function withCountedIteration(tabs: TerminalTab[], onVisit: () => void): TerminalTab[] { + return new Proxy(tabs, { + get(target, property, receiver) { + if (property !== Symbol.iterator) { + return Reflect.get(target, property, receiver) + } + return function* countedIterator(): Generator { + for (const tab of target) { + onVisit() + yield tab + } + } + } + }) +} + +function makeScaleTabs(onVisit: () => void): Record { + return Object.fromEntries( + Array.from({ length: TAB_COUNT }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, withCountedIteration([makeTab(`tab-${index}`, `pty-${index}`)], onVisit)] + }) + ) +} + +function countNextTabComparison( + tab: TerminalTab, + onComparison: () => void +): [TerminalTab[], () => void] { + let counting = true + const tabs = new Proxy([tab], { + get(target, property, receiver) { + if (counting && property === '0') { + onComparison() + } + return Reflect.get(target, property, receiver) + } + }) + return [ + tabs, + () => { + counting = false + } + ] +} + +describe('closed resource session count selector', () => { + it('removes full binding-index rebuilds from title-only tab churn at scale', () => { + let oldPathTabVisits = 0 + const initialTabs = makeScaleTabs(() => { + oldPathTabVisits += 1 + }) + const inputs: ResourceSessionBindingInputs = { + tabsByWorktree: initialTabs, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true + } + + // Why: this models the previous useMemo dependency on tabsByWorktree: + // every title write replaces that map and rebuilds both tab passes. + for (let index = 0; index < TITLE_WRITES; index += 1) { + buildResourceSessionBindingIndex({ ...inputs, tabsByWorktree: { ...initialTabs } }) + } + expect(oldPathTabVisits).toBe(120_000) + + let optimizedTabVisits = 0 + const optimizedTabs = makeScaleTabs(() => { + optimizedTabVisits += 1 + }) + const buildIndex = vi.fn(buildResourceSessionBindingIndex) + const selectCount = createClosedResourceSessionCountSelector(buildIndex) + let state = makeState({ tabsByWorktree: optimizedTabs }) + const currentTabByWorktree = Object.fromEntries( + Object.entries(optimizedTabs).map(([worktreeId, tabs]) => [worktreeId, tabs[0]]) + ) + let changedArrayTabComparisons = 0 + + expect(selectCount(state)).toBe(TAB_COUNT) + buildIndex.mockClear() + optimizedTabVisits = 0 + + for (let index = 0; index < TITLE_WRITES; index += 1) { + const worktreeId = `wt-${index % TAB_COUNT}` + const nextTab = { ...currentTabByWorktree[worktreeId], title: `Terminal ${index}` } + const [nextTabs, stopCounting] = countNextTabComparison(nextTab, () => { + changedArrayTabComparisons += 1 + }) + state = { + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + [worktreeId]: nextTabs + } + } + expect(selectCount(state)).toBe(TAB_COUNT) + stopCounting() + currentTabByWorktree[worktreeId] = nextTab + } + + expect(buildIndex).not.toHaveBeenCalled() + expect(optimizedTabVisits).toBe(0) + expect(changedArrayTabComparisons).toBe(TITLE_WRITES) + }) + + it('reacts to every binding and readiness input while ignoring display-only tab fields', () => { + const buildIndex = vi.fn(buildResourceSessionBindingIndex) + const selectCount = createClosedResourceSessionCountSelector(buildIndex) + let state = makeState({ + workspaceSessionReady: false, + tabsByWorktree: { + 'wt-1': [makeTab('tab-1', 'pty-wake')] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-live'] + }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout({ 'leaf-1': 'pty-layout' }) + } + }) + + expect(selectCount(state)).toBe(0) + expect(buildIndex).not.toHaveBeenCalled() + + state = { ...state, workspaceSessionReady: true } + expect(selectCount(state)).toBe(3) + expect(buildIndex).toHaveBeenCalledTimes(1) + + state = { + ...state, + tabsByWorktree: { + 'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], title: 'Working' }] + } + } + expect(selectCount(state)).toBe(3) + expect(buildIndex).toHaveBeenCalledTimes(1) + + state = { + ...state, + ptyIdsByTabId: { 'tab-1': ['pty-live', 'pty-live-2'] } + } + expect(selectCount(state)).toBe(4) + expect(buildIndex).toHaveBeenCalledTimes(2) + + state = { + ...state, + terminalLayoutsByTabId: { + 'tab-1': makeLayout({ 'leaf-1': 'pty-layout', 'leaf-2': 'pty-layout-2' }) + } + } + expect(selectCount(state)).toBe(5) + expect(buildIndex).toHaveBeenCalledTimes(3) + + state = { + ...state, + tabsByWorktree: { + 'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], ptyId: null }] + } + } + expect(selectCount(state)).toBe(4) + expect(buildIndex).toHaveBeenCalledTimes(4) + + state = { ...state, tabsByWorktree: {} } + expect(selectCount(state)).toBe(2) + expect(buildIndex).toHaveBeenCalledTimes(5) + + state = { ...state, workspaceSessionReady: false } + expect(selectCount(state)).toBe(0) + expect(buildIndex).toHaveBeenCalledTimes(5) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-count-selector.ts b/src/renderer/src/components/status-bar/resource-session-count-selector.ts new file mode 100644 index 000000000..4fb924f45 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-count-selector.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../../store' +import { + buildResourceSessionBindingIndex, + type ResourceSessionBindingIndex, + type ResourceSessionBindingInputs +} from './resource-session-bindings' + +export type ClosedResourceSessionCountState = Pick< + AppState, + 'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'workspaceSessionReady' +> + +type BuildResourceSessionBindingIndex = ( + inputs: ResourceSessionBindingInputs +) => ResourceSessionBindingIndex + +export type ClosedResourceSessionCountSelector = (state: ClosedResourceSessionCountState) => number + +function haveSameTabBindings( + previous: AppState['tabsByWorktree'], + next: AppState['tabsByWorktree'] +): boolean { + if (previous === next) { + return true + } + + const previousWorktreeIds = Object.keys(previous) + const nextWorktreeIds = Object.keys(next) + if (previousWorktreeIds.length !== nextWorktreeIds.length) { + return false + } + + for (const worktreeId of nextWorktreeIds) { + const previousTabs = previous[worktreeId] + const nextTabs = next[worktreeId] + if (previousTabs === nextTabs) { + continue + } + if (!previousTabs || previousTabs.length !== nextTabs.length) { + return false + } + for (let index = 0; index < nextTabs.length; index += 1) { + const previousTab = previousTabs[index] + const nextTab = nextTabs[index] + // Why: the closed badge counts PTY ownership only. Titles and other + // display fields can churn per terminal frame without changing it. + if (previousTab.id !== nextTab.id || previousTab.ptyId !== nextTab.ptyId) { + return false + } + } + } + + return true +} + +export function createClosedResourceSessionCountSelector( + buildBindingIndex: BuildResourceSessionBindingIndex = buildResourceSessionBindingIndex +): ClosedResourceSessionCountSelector { + // Why: Zustand runs selectors for every store notification. Keep the last + // liveness inputs here so unrelated and title-only writes stay scalar-cheap. + let initialized = false + let previousTabsByWorktree: AppState['tabsByWorktree'] = {} + let previousPtyIdsByTabId: AppState['ptyIdsByTabId'] = {} + let previousTerminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {} + let previousWorkspaceSessionReady = false + let count = 0 + + return (state): number => { + const bindingMapChanged = + state.ptyIdsByTabId !== previousPtyIdsByTabId || + state.terminalLayoutsByTabId !== previousTerminalLayoutsByTabId + const readinessChanged = state.workspaceSessionReady !== previousWorkspaceSessionReady + const tabsReferenceChanged = state.tabsByWorktree !== previousTabsByWorktree + let tabBindingsChanged = tabsReferenceChanged + if ( + initialized && + state.workspaceSessionReady && + !bindingMapChanged && + !readinessChanged && + tabsReferenceChanged + ) { + tabBindingsChanged = !haveSameTabBindings(previousTabsByWorktree, state.tabsByWorktree) + } + + const shouldRebuild = + state.workspaceSessionReady && + (!initialized || bindingMapChanged || readinessChanged || tabBindingsChanged) + + if (shouldRebuild) { + count = buildBindingIndex(state).boundPtyIds.size + } else if (!state.workspaceSessionReady) { + count = 0 + } + + previousTabsByWorktree = state.tabsByWorktree + previousPtyIdsByTabId = state.ptyIdsByTabId + previousTerminalLayoutsByTabId = state.terminalLayoutsByTabId + previousWorkspaceSessionReady = state.workspaceSessionReady + initialized = true + return count + } +} diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 8fd0fef6d..eed3bd0b3 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { getResourceUsageAllWorktrees, + getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, + getResourceUsageTerminalLayoutsByTabId, getResourceUsageTabsByWorktree } from './resource-usage-open-slices' import type { AppState } from '../../store' @@ -41,11 +43,21 @@ const worktree = (): AppState['worktreesByRepo'][string][number] => ({ describe('resource usage open slices', () => { it('returns stable empty slices while the popover is closed', () => { const tabsByWorktree = { 'wt-1': [terminalTab('tab-1')] } + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const terminalLayoutsByTabId = { + 'tab-1': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + } const runtimePaneTitlesByTabId = { 'tab-1': { 'tab-1:0': 'Working' } } as AppState['runtimePaneTitlesByTabId'] const closedTabs = getResourceUsageTabsByWorktree({ tabsByWorktree }, false) + const closedPtyIds = getResourceUsagePtyIdsByTabId({ ptyIdsByTabId }, false) + const closedLayouts = getResourceUsageTerminalLayoutsByTabId({ terminalLayoutsByTabId }, false) const closedTitles = getResourceUsageRuntimePaneTitlesByTabId( { runtimePaneTitlesByTabId }, false @@ -55,17 +67,35 @@ describe('resource usage open slices', () => { expect(closedTitles).toBe( getResourceUsageRuntimePaneTitlesByTabId({ runtimePaneTitlesByTabId: {} }, false) ) + expect(closedPtyIds).toBe(getResourceUsagePtyIdsByTabId({ ptyIdsByTabId: {} }, false)) + expect(closedLayouts).toBe( + getResourceUsageTerminalLayoutsByTabId({ terminalLayoutsByTabId: {} }, false) + ) expect(closedTabs).toEqual({}) + expect(closedPtyIds).toEqual({}) + expect(closedLayouts).toEqual({}) expect(closedTitles).toEqual({}) }) it('returns live slices while the popover is open', () => { const tabsByWorktree = { 'wt-1': [terminalTab('tab-1')] } + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const terminalLayoutsByTabId = { + 'tab-1': { + root: { type: 'leaf' as const, leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + } const runtimePaneTitlesByTabId = { 'tab-1': { 'tab-1:0': 'Working' } } as AppState['runtimePaneTitlesByTabId'] expect(getResourceUsageTabsByWorktree({ tabsByWorktree }, true)).toBe(tabsByWorktree) + expect(getResourceUsagePtyIdsByTabId({ ptyIdsByTabId }, true)).toBe(ptyIdsByTabId) + expect(getResourceUsageTerminalLayoutsByTabId({ terminalLayoutsByTabId }, true)).toBe( + terminalLayoutsByTabId + ) expect(getResourceUsageRuntimePaneTitlesByTabId({ runtimePaneTitlesByTabId }, true)).toBe( runtimePaneTitlesByTabId ) diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index e74a79e2e..7b5e7ba6d 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -2,6 +2,8 @@ import type { AppState } from '../../store' import { getAllWorktreesFromState } from '../../store/selectors' const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} +const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} +const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} const EMPTY_REPOS: AppState['repos'] = [] const EMPTY_WORKTREES: ReturnType = [] @@ -13,6 +15,20 @@ export function getResourceUsageTabsByWorktree( return open ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE } +export function getResourceUsagePtyIdsByTabId( + state: Pick, + open: boolean +): AppState['ptyIdsByTabId'] { + return open ? state.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID +} + +export function getResourceUsageTerminalLayoutsByTabId( + state: Pick, + open: boolean +): AppState['terminalLayoutsByTabId'] { + return open ? state.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID +} + export function getResourceUsageRuntimePaneTitlesByTabId( state: Pick, open: boolean