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:
parent
e5c394b09b
commit
5f7b13e01e
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue