diff --git a/src/renderer/src/components/status-bar/ResourcePtyPanel.tsx b/src/renderer/src/components/status-bar/ResourcePtyPanel.tsx index 6cf4681080e..9e0a319538e 100644 --- a/src/renderer/src/components/status-bar/ResourcePtyPanel.tsx +++ b/src/renderer/src/components/status-bar/ResourcePtyPanel.tsx @@ -6,6 +6,8 @@ import { ResourcePtyList } from './ResourcePtyList' import type { useResourceSessionInventory } from './use-resource-session-inventory' import type { useResourcePtyActions } from './use-resource-pty-actions' +const EMPTY_PTY_ROWS: ReturnType = [] + type Props = Omit[0], 'sessions'> & { active: boolean inventory: ReturnType @@ -37,14 +39,17 @@ export function ResourcePtyPanel({ runtimePaneTitlesByTabId, sessions }) - : [], + : EMPTY_PTY_ROWS, [active, sessions, bindings, worktrees, repos, projectGroups, runtimePaneTitlesByTabId] ) const groups = useMemo(() => groupResourcePtyRows(rows), [rows]) - const unbound = rows.filter((row) => row.binding === 'unbound').length + const unbound = useMemo( + () => rows.reduce((count, row) => count + Number(row.binding === 'unbound'), 0), + [rows] + ) return ( -
-
+
+
{inventory.observedAt === null ? translate('resourceManager.pty.thisComputer', 'This computer') @@ -75,7 +80,7 @@ export function ResourcePtyPanel({ )}
{inventory.sessionsError && ( -
+
{translate( 'resourceManager.pty.failed', 'Unverifiable: local terminals could not be refreshed. Last-observed terminals are shown.' diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index bb922fd3367..e053f3ca38d 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -106,7 +106,7 @@ export function ResourceUsageStatusSegment({ align="end" sideOffset={8} {...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS} - className="w-[26rem] max-w-[calc(100vw-2rem)] p-0" + className="flex h-[min(40rem,var(--radix-popover-content-available-height))] w-[26rem] max-w-[calc(100vw-2rem)] flex-col" onOpenAutoFocus={(event) => event.preventDefault()} // Why: activating a tab focuses xterm's DOM node; Radix would read that as focus-outside and close. Outside-click and Escape still close. onFocusOutside={(event) => event.preventDefault()} @@ -123,8 +123,12 @@ export function ResourceUsageStatusSegment({ ? () => void controller.localPtyInventory.refreshSessions() : controller.refreshResources })} - -
+ +
- + {controller.actionError && (

{controller.actionError} @@ -159,7 +166,6 @@ export function ResourceUsageStatusSegment({ commitToneClass, orphanCount })} - {/* Why: fixed 420px height so the popover doesn't jump as worktrees expand/collapse or sessions change; inner tree owns its scroll. */} {renderResourceUsagePopoverBody({ setPopoverBodyNode, unifiedRepos, @@ -188,7 +194,7 @@ export function ResourceUsageStatusSegment({ })} - + +

diff --git a/src/renderer/src/components/status-bar/resource-pty-view-model.test.ts b/src/renderer/src/components/status-bar/resource-pty-view-model.test.ts index 6193613982d..1b291ce9fb9 100644 --- a/src/renderer/src/components/status-bar/resource-pty-view-model.test.ts +++ b/src/renderer/src/components/status-bar/resource-pty-view-model.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildResourcePtyRows, groupResourcePtyRows } from './resource-pty-view-model' import type { PtyListedSession } from '../../../../shared/pty-listed-session' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' @@ -220,16 +220,100 @@ describe('local PTY view model', () => { expect(order(rows.filter((row) => row.identity !== '3'))).toEqual(expected) }) - it('joins and groups 1,000 sessions in memory', () => { - const start = performance.now() + it('sorts projects and workspaces by latest activity, including folder workspaces', () => { + const folder = folderWorkspaceToWorktree({ ...makeFolderWorkspace(), lastActivityAt: 300 }) const rows = buildResourcePtyRows({ ...base(), - sessions: Array.from({ length: 1_000 }, (_, index) => - session(`pty-${index}`, { title: `Terminal ${index}` }) - ) + worktrees: [ + { ...makeWorktree('older', 'Alpha'), lastActivityAt: 100 }, + { ...makeWorktree('recent', 'Zeta'), lastActivityAt: 200 }, + folder + ], + projectGroups: [makeProjectGroup()], + sessions: [ + session('old', { worktreeId: 'older' }), + session('recent', { worktreeId: 'recent' }), + session('folder', { worktreeId: folder.id }), + session('unknown') + ] }) - const groups = groupResourcePtyRows(rows) - expect(groups[0]?.workspaces[0]?.rows).toHaveLength(1_000) - console.info(`PTY model 1,000 rows: ${(performance.now() - start).toFixed(2)}ms`) + const order = (input: typeof rows) => + groupResourcePtyRows(input).map((group) => ({ + id: group.id, + workspaces: group.workspaces.map((workspace) => workspace.id) + })) + const expected = [ + { id: folder.repoId, workspaces: [folder.id] }, + { id: 'repo-1', workspaces: ['recent', 'older'] }, + { id: '', workspaces: [''] } + ] + expect(order(rows)).toEqual(expected) + expect(order(rows.toReversed())).toEqual(expected) + expect(rows.map((row) => row.lastActivityAt)).toEqual([100, 200, 300, 0]) + }) + + it('falls back to stable name ordering for missing or invalid activity timestamps', () => { + const inputs = base() + const rows = buildResourcePtyRows({ + ...inputs, + worktrees: [ + { ...makeWorktree('a', 'Alpha'), lastActivityAt: Number.NaN }, + { ...makeWorktree('z', 'Zeta'), lastActivityAt: Number.POSITIVE_INFINITY } + ], + sessions: [session('z', { worktreeId: 'z' }), session('a', { worktreeId: 'a' })] + }) + expect(rows.every((row) => row.lastActivityAt === 0)).toBe(true) + expect(groupResourcePtyRows(rows)[0]?.workspaces.map((workspace) => workspace.id)).toEqual([ + 'a', + 'z' + ]) + }) + + it.each([1_000, 10_000])('joins and sorts %i sessions with bounded comparison work', (count) => { + const workspaceCount = count / 10 + const inputs = { + ...base(), + repos: Array.from({ length: 20 }, (_, index) => ({ + ...makeRepo(), + id: `repo-${index}`, + displayName: `Project ${index}` + })), + worktrees: Array.from({ length: workspaceCount }, (_, index) => + makeWorktree(`workspace-${index}`, `Workspace ${index}`, { + repoId: `repo-${index % 20}`, + lastActivityAt: index % 7 + }) + ), + sessions: Array.from({ length: count }, (_, index) => + session(`pty-${(index * 7919) % count}`, { + worktreeId: `workspace-${index % workspaceCount}` + }) + ) + } + const start = performance.now() + const rows = buildResourcePtyRows(inputs) + const elapsedJoin = performance.now() - start + const comparisons = vi.spyOn(String.prototype, 'localeCompare') + const normalizations = vi.spyOn(String.prototype, 'toLocaleLowerCase') + try { + const groups = groupResourcePtyRows(rows) + const elapsedSort = performance.now() - start - elapsedJoin + expect(normalizations).toHaveBeenCalledTimes(workspaceCount + 20) + expect(comparisons.mock.calls.length).toBeLessThan(2 * count * Math.ceil(Math.log2(count))) + expect( + groups.flatMap((group) => group.workspaces.flatMap((workspace) => workspace.rows)) + ).toHaveLength(count) + for (const group of groups) { + expect(group.workspaces.map((workspace) => workspace.lastActivityAt)).toEqual( + group.workspaces.map((workspace) => workspace.lastActivityAt).toSorted((a, b) => b - a) + ) + } + console.info( + `PTY ${count}: join ${elapsedJoin.toFixed(2)}ms, sort ${elapsedSort.toFixed(2)}ms` + ) + } finally { + comparisons.mockRestore() + normalizations.mockRestore() + } }) }) diff --git a/src/renderer/src/components/status-bar/resource-pty-view-model.ts b/src/renderer/src/components/status-bar/resource-pty-view-model.ts index d333212f2a9..c6d02aa5780 100644 --- a/src/renderer/src/components/status-bar/resource-pty-view-model.ts +++ b/src/renderer/src/components/status-bar/resource-pty-view-model.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/execution-host' import { projectGroupIdFromRepoId } from '../../../../shared/folder-workspace-worktree' import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { recentStamp } from '../../../../shared/sidebar-list/stamp-order' import { buildResourceSessionBindingIndex, type ResourceSessionBindingInputs @@ -22,6 +23,7 @@ export type ResourcePtyRow = { identity: string label: string sortName: string + lastActivityAt: number workspace: string | null projectId: string project: string | null @@ -34,7 +36,15 @@ export type ResourcePtyRow = { export type ResourcePtyGroup = { id: string name: string | null - workspaces: { id: string; name: string | null; rows: ResourcePtyRow[] }[] + sortName: string + lastActivityAt: number + workspaces: { + id: string + name: string | null + sortName: string + lastActivityAt: number + rows: ResourcePtyRow[] + }[] } export function buildResourcePtyRows(args: { @@ -152,6 +162,7 @@ export function buildResourcePtyRows(args: { identity: ptySessionIdentity(session), label, sortName: tab?.customTitle?.trim() || tab?.defaultTitle?.trim() || session.id, + lastActivityAt: recentStamp(workspace ?? {}), workspace: workspaceName, workspaceId: workspace?.id ?? null, projectId: project ? (workspace?.repoId ?? '') : '', @@ -177,7 +188,13 @@ export function groupResourcePtyRows(rows: readonly ResourcePtyRow[]): ResourceP let project = projects.get(row.projectId) if (!project) { project = { - group: { id: row.projectId, name: row.project, workspaces: [] }, + group: { + id: row.projectId, + name: row.project, + sortName: row.project?.trim().toLocaleLowerCase() ?? '\uffff', + lastActivityAt: 0, + workspaces: [] + }, workspaces: new Map() } projects.set(row.projectId, project) @@ -185,19 +202,27 @@ export function groupResourcePtyRows(rows: readonly ResourcePtyRow[]): ResourceP const workspaceId = row.workspaceId ?? '' let workspace = project.workspaces.get(workspaceId) if (!workspace) { - workspace = { id: workspaceId, name: row.workspace, rows: [] } + workspace = { + id: workspaceId, + name: row.workspace, + sortName: row.workspace?.trim().toLocaleLowerCase() ?? '\uffff', + lastActivityAt: 0, + rows: [] + } project.workspaces.set(workspaceId, workspace) project.group.workspaces.push(workspace) } workspace.rows.push(row) + workspace.lastActivityAt = Math.max(workspace.lastActivityAt, row.lastActivityAt) + project.group.lastActivityAt = Math.max(project.group.lastActivityAt, row.lastActivityAt) } const groups = [...projects.values()].map(({ group }) => group) for (const group of groups) { group.workspaces.sort( (a, b) => - (a.name?.trim().toLocaleLowerCase() ?? '\uffff').localeCompare( - b.name?.trim().toLocaleLowerCase() ?? '\uffff' - ) || a.id.localeCompare(b.id) + b.lastActivityAt - a.lastActivityAt || + a.sortName.localeCompare(b.sortName) || + a.id.localeCompare(b.id) ) for (const workspace of group.workspaces) { workspace.rows.sort( @@ -207,8 +232,8 @@ export function groupResourcePtyRows(rows: readonly ResourcePtyRow[]): ResourceP } return groups.sort( (a, b) => - (a.name?.trim().toLocaleLowerCase() ?? '\uffff').localeCompare( - b.name?.trim().toLocaleLowerCase() ?? '\uffff' - ) || a.id.localeCompare(b.id) + b.lastActivityAt - a.lastActivityAt || + a.sortName.localeCompare(b.sortName) || + a.id.localeCompare(b.id) ) } diff --git a/src/renderer/src/components/status-bar/resource-pty-virtualization.test.tsx b/src/renderer/src/components/status-bar/resource-pty-virtualization.test.tsx new file mode 100644 index 00000000000..16b6e725bf8 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-pty-virtualization.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import type { ComponentProps } from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { ResourcePtyPanel } from './ResourcePtyPanel' +import * as model from './resource-pty-view-model' +import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, String(value)), + fallback + ) +})) + +const VIEWPORT_HEIGHT = 360 +const ROW_HEIGHT = 40 + +beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + return this.hasAttribute('data-resource-pty-body') ? VIEWPORT_HEIGHT : ROW_HEIGHT + }) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + return new DOMRect( + 0, + 0, + 400, + this.hasAttribute('data-resource-pty-body') ? VIEWPORT_HEIGHT : ROW_HEIGHT + ) + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function panelProps(count: number): ComponentProps { + return { + active: true, + inventory: { + sessionInventory: { + sessions: Array.from({ length: count }, (_, index) => ({ + id: `pty-${String(index).padStart(5, '0')}`, + incarnationId: 'A', + fencedTermination: true, + cwd: '/notes', + title: `Terminal ${index}`, + worktreeId: 'wt-1', + agentOwnership: 'absent' as const + })), + count + }, + observedAt: 1, + sessionsError: false, + loading: false, + refreshSessions: async () => {}, + clearSessionsError: () => {}, + invalidate: () => {}, + removeSession: () => {}, + removeSessions: () => {} + }, + actions: { + confirmation: null, + setConfirmation: () => {}, + ending: new Set(), + errors: new Map(), + confirm: () => {}, + endConfirmed: async () => {} + }, + onNavigate: () => {}, + bindings: { tabsByWorktree: {}, ptyIdsByTabId: {}, workspaceSessionReady: true }, + worktrees: [makeWorktree('wt-1', 'Notes', { lastActivityAt: 100 })], + repos: [makeRepo()], + projectGroups: [], + runtimePaneTitlesByTabId: {} + } +} + +it.each([1_000, 10_000])( + 'bounds mounted PTY rows for %i sessions and reaches the last row', + (count) => { + const props = panelProps(count) + const view = render( + + + + ) + const scroller = view.container.querySelector('[data-resource-pty-body]') + if (!scroller) { + throw new Error('PTY scroller missing') + } + const mountedRows = () => view.container.querySelectorAll('[data-resource-pty-row]') + expect(mountedRows().length).toBeGreaterThan(0) + expect(mountedRows().length).toBeLessThan(50) + expect(mountedRows()[0]?.getAttribute('data-resource-pty-row')).toContain('pty-00000') + expect(view.container.querySelector('[role="listitem"]')?.getAttribute('aria-setsize')).toBe( + String(count + 2) + ) + act(() => { + scroller.scrollTop = (count + 2) * ROW_HEIGHT - VIEWPORT_HEIGHT + fireEvent.scroll(scroller) + }) + expect(mountedRows().length).toBeGreaterThan(0) + expect(mountedRows().length).toBeLessThan(50) + expect( + mountedRows()[mountedRows().length - 1]?.getAttribute('data-resource-pty-row') + ).toContain(`pty-${String(count - 1).padStart(5, '0')}`) + } +) + +it('skips joins and sorting on scrolling or action updates, and does no model work when inactive', () => { + const join = vi.spyOn(model, 'buildResourcePtyRows') + const sort = vi.spyOn(model, 'groupResourcePtyRows') + const props = panelProps(1_000) + const panel = (value: typeof props) => ( + + + + ) + const view = render(panel(props)) + const joined = join.mock.calls.length + const sorted = sort.mock.calls.length + const scroller = view.container.querySelector('[data-resource-pty-body]') + if (!scroller) { + throw new Error('PTY scroller missing') + } + act(() => { + scroller.scrollTop = ROW_HEIGHT * 20 + fireEvent.scroll(scroller) + }) + view.rerender(panel({ ...props, actions: { ...props.actions, errors: new Map() } })) + expect(join).toHaveBeenCalledTimes(joined) + expect(sort).toHaveBeenCalledTimes(sorted) + view.rerender(panel({ ...props, active: false })) + const inactiveSorts = sort.mock.calls.length + view.rerender(panel({ ...props, active: false, worktrees: [...props.worktrees] })) + expect(join).toHaveBeenCalledTimes(joined) + expect(sort).toHaveBeenCalledTimes(inactiveSorts) + expect(view.container.querySelectorAll('[data-resource-pty-row]')).toHaveLength(0) +}) diff --git a/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx b/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx index ee18e15a325..076fa35d60f 100644 --- a/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx +++ b/src/renderer/src/components/status-bar/resource-usage-popover-body.tsx @@ -57,7 +57,11 @@ export function renderResourceUsagePopoverBody({ runtimeHostNotices: readonly string[] }): React.JSX.Element { return ( -
+
{(unifiedRepos.length > 0 || resourceSnapshot) && (