Add terminal pane equalize action

Add a terminal context menu action that equalizes split pane sizes and handles nested split weighting.
This commit is contained in:
Neil 2026-05-15 13:37:28 -07:00 committed by GitHub
parent e5c394b09b
commit 5f7b13e01e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 159 additions and 3 deletions

View File

@ -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). */}
<DropdownMenuShortcut>{isMac ? `${mod}${shift}D` : `Alt+${shift}D`}</DropdownMenuShortcut>
</DropdownMenuItem>
{canEqualizePaneSizes && (
<DropdownMenuItem onSelect={onEqualizePaneSizes}>
<PanelsTopLeft />
Equalize Pane Sizes
</DropdownMenuItem>
)}
{canExpandPane && (
<DropdownMenuItem onSelect={onToggleExpand}>
{menuPaneIsExpanded ? <Minimize2 /> : <Maximize2 />}

View File

@ -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(

View File

@ -30,6 +30,7 @@ type TerminalMenuState = {
onPaste: () => Promise<void>
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,

View File

@ -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

View File

@ -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<string, string>
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<string, unknown>).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)
})
})

View File

@ -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.