From 5f7b13e01e15121f040e231bffa5abb3d7e9d604 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 13:37:28 -0700 Subject: [PATCH] Add terminal pane equalize action Add a terminal context menu action that equalizes split pane sizes and handles nested split weighting. --- .../terminal-pane/TerminalContextMenu.tsx | 11 +++ .../components/terminal-pane/TerminalPane.tsx | 2 + .../use-terminal-pane-context-menu.ts | 12 ++++ .../src/lib/pane-manager/pane-manager.ts | 17 ++++- .../lib/pane-manager/pane-tree-ops.test.ts | 69 ++++++++++++++++++- .../src/lib/pane-manager/pane-tree-ops.ts | 51 ++++++++++++++ 6 files changed, 159 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index f1946791c..f4fce7e40 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -5,6 +5,7 @@ import { Maximize2, Minimize2, PanelBottomClose, + PanelsTopLeft, PanelRightClose, Pencil, SquareTerminal, @@ -36,6 +37,8 @@ type TerminalContextMenuProps = { onPaste: () => void onSplitRight: () => void onSplitDown: () => void + canEqualizePaneSizes: boolean + onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void quickCommands: TerminalQuickCommand[] @@ -56,6 +59,8 @@ export default function TerminalContextMenu({ onPaste, onSplitRight, onSplitDown, + canEqualizePaneSizes, + onEqualizePaneSizes, onClosePane, onClearScreen, quickCommands, @@ -154,6 +159,12 @@ export default function TerminalContextMenu({ Ctrl+Shift+D is taken by split-right (#586). */} {isMac ? `${mod}${shift}D` : `Alt+${shift}D`} + {canEqualizePaneSizes && ( + + + Equalize Pane Sizes + + )} {canExpandPane && ( {menuPaneIsExpanded ? : } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f891f762d..da162b18c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1150,6 +1150,7 @@ export default function TerminalPane({ menuOpenedAtRef={contextMenu.menuOpenedAtRef} canClosePane={contextMenu.paneCount > 1} canExpandPane={contextMenu.paneCount > 1} + canEqualizePaneSizes={contextMenu.paneCount > 1 && expandedPaneId === null} menuPaneIsExpanded={ contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId } @@ -1157,6 +1158,7 @@ export default function TerminalPane({ onPaste={() => void contextMenu.onPaste()} onSplitRight={contextMenu.onSplitRight} onSplitDown={contextMenu.onSplitDown} + onEqualizePaneSizes={contextMenu.onEqualizePaneSizes} onClosePane={contextMenu.onClosePane} onClearScreen={contextMenu.onClearScreen} quickCommands={(settings?.terminalQuickCommands ?? []).filter( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index c41841921..e265ca980 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -30,6 +30,7 @@ type TerminalMenuState = { onPaste: () => Promise onSplitRight: () => void onSplitDown: () => void + onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void onQuickCommand: (command: TerminalQuickCommand) => void @@ -148,6 +149,16 @@ export function useTerminalPaneContextMenu({ const onSplitRight = (): void => splitWithInheritedCwd('vertical') const onSplitDown = (): void => splitWithInheritedCwd('horizontal') + const onEqualizePaneSizes = (): void => { + const pane = resolveMenuPane() + const manager = managerRef.current + if (!pane || !manager) { + return + } + manager.equalizePaneSizes() + pane.terminal.focus() + } + const onClosePane = (): void => { const pane = resolveMenuPane() if (pane && (managerRef.current?.getPanes().length ?? 0) > 1) { @@ -241,6 +252,7 @@ export function useTerminalPaneContextMenu({ onPaste, onSplitRight, onSplitDown, + onEqualizePaneSizes, onClosePane, onClearScreen, onQuickCommand, diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 5ead38cc9..6923fa333 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -14,7 +14,7 @@ import { import { createDragReorderState, hideDropOverlay, handlePaneDrop } from './pane-drag-reorder' import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle' import { shouldFollowMouseFocus } from './focus-follows-mouse' -import { safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops' +import { equalizePaneSplitSizes, safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops' import { toPublicPane } from './pane-public-view' import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' import { @@ -119,6 +119,21 @@ export class PaneManager { fitAllPanesInternal(this.panes) } + equalizePaneSizes(): void { + if (this.panes.size < 2) { + return + } + + const changed = equalizePaneSplitSizes( + this.root.firstElementChild instanceof HTMLElement ? this.root.firstElementChild : null + ) + if (!changed) { + return + } + + this.options.onLayoutChanged?.() + } + getActivePane(): ManagedPane | null { if (this.activePaneId === null) { return null diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index b163d4934..577dd8e02 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -1,8 +1,24 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { safeFit } from './pane-tree-ops' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides' +class MockHTMLElement { + classList: { contains: (cls: string) => boolean } + children: MockHTMLElement[] + style: Record + + constructor(classes: string[], children: MockHTMLElement[] = [], flex = '') { + this.classList = { contains: (cls: string) => classes.includes(cls) } + this.children = children + this.style = { flex } + } +} + +beforeAll(() => { + ;(globalThis as unknown as Record).HTMLElement = MockHTMLElement +}) + afterEach(() => { hydrateOverrides([]) }) @@ -214,3 +230,52 @@ describe('safeFit', () => { expect(paneB.terminal.resize).not.toHaveBeenCalled() }) }) + +describe('equalizePaneSplitSizes', () => { + const pane = (flex = '1 1 0%'): MockHTMLElement => new MockHTMLElement(['pane'], [], flex) + const split = ( + direction: 'vertical' | 'horizontal', + children: MockHTMLElement[], + flex = '1 1 0%' + ): MockHTMLElement => + new MockHTMLElement( + ['pane-split', direction === 'vertical' ? 'is-vertical' : 'is-horizontal'], + children, + flex + ) + + it('weights nested same-axis splits so same-axis panes equalize evenly', () => { + const left = pane('10 1 0%') + const middle = pane('20 1 0%') + const right = pane('30 1 0%') + const rightSplit = split('vertical', [middle, right], '90 1 0%') + const root = split('vertical', [left, rightSplit]) + + expect(equalizePaneSplitSizes(root as unknown as HTMLElement)).toBe(true) + + expect(left.style.flex).toBe('1 1 0%') + expect(rightSplit.style.flex).toBe('2 1 0%') + expect(middle.style.flex).toBe('1 1 0%') + expect(right.style.flex).toBe('1 1 0%') + }) + + it('treats perpendicular child splits as one weighted region', () => { + const top = pane('7 1 0%') + const bottom = pane('3 1 0%') + const leftStack = split('horizontal', [top, bottom], '15 1 0%') + const right = pane('85 1 0%') + const root = split('vertical', [leftStack, right]) + + expect(equalizePaneSplitSizes(root as unknown as HTMLElement)).toBe(true) + + expect(leftStack.style.flex).toBe('1 1 0%') + expect(right.style.flex).toBe('1 1 0%') + expect(top.style.flex).toBe('1 1 0%') + expect(bottom.style.flex).toBe('1 1 0%') + }) + + it('returns false when there is no split tree to change', () => { + expect(equalizePaneSplitSizes(pane() as unknown as HTMLElement)).toBe(false) + expect(equalizePaneSplitSizes(null)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 4359e4fd7..cdb97e47f 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -280,6 +280,57 @@ export function findPaneChildren(parent: HTMLElement): HTMLElement[] { ) } +function getSplitDirection(split: HTMLElement): 'vertical' | 'horizontal' { + return split.classList.contains('is-horizontal') ? 'horizontal' : 'vertical' +} + +function getEqualizeWeight(el: HTMLElement, direction: 'vertical' | 'horizontal'): number { + if (!el.classList.contains('pane-split') || getSplitDirection(el) !== direction) { + return 1 + } + + const children = findPaneChildren(el) + return Math.max( + 1, + children.reduce((sum, child) => sum + getEqualizeWeight(child, direction), 0) + ) +} + +export function equalizePaneSplitSizes(root: HTMLElement | null): boolean { + if (!root) { + return false + } + + let changed = false + const visit = (el: HTMLElement): void => { + if (!el.classList.contains('pane-split')) { + return + } + + const direction = getSplitDirection(el) + const children = findPaneChildren(el) + if (children.length >= 2) { + for (const child of children) { + // Why: same-axis nested splits need pane-count weighting so three + // side-by-side panes become thirds, not 50/25/25. + const weight = getEqualizeWeight(child, direction) + const nextFlex = `${weight} 1 0%` + if (child.style.flex !== nextFlex) { + child.style.flex = nextFlex + changed = true + } + } + } + + for (const child of children) { + visit(child) + } + } + + visit(root) + return changed +} + /** * Create a flex split wrapper that replaces `existingContainer` in the DOM, * then places [existing] [divider] [new] inside it.