mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): vertical resize for the bottom-row terminal pane
Extends the pane store with heightOverride (alongside widthOverride) and a get/set/clear API, and wires the pane shell + desktop controller so the bottom-row terminal pane can be resized on the Y axis with its size persisted.
This commit is contained in:
parent
ff81365988
commit
1f950e189c
4 changed files with 199 additions and 45 deletions
|
|
@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
|
|||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
import { latestSessionTodos } from '../lib/todos'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sessionsLimit,
|
||||
|
|
@ -41,6 +43,7 @@ import {
|
|||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $paneOpen } from '../store/panes'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { setPetActivity } from '../store/pet'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
|
|
@ -223,6 +226,8 @@ export function DesktopController() {
|
|||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const reviewOpen = useStore($reviewOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const profileScope = useStore($profileScope)
|
||||
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
|
||||
|
|
@ -1207,6 +1212,16 @@ export function DesktopController() {
|
|||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||
const railSide = panesFlipped ? 'left' : 'right'
|
||||
|
||||
// Other sidebars docked as real columns on the terminal's rail. Force-collapsed
|
||||
// hover-reveal overlays (narrow window) don't take a column, so they don't count.
|
||||
const railColumnOpen =
|
||||
(chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) ||
|
||||
(chatOpen && !narrowViewport && fileBrowserOpen) ||
|
||||
(chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen)
|
||||
// Once the terminal would share its rail with another sidebar, drop it to a
|
||||
// full-width row beneath them rather than cramming in one more skinny column.
|
||||
const terminalAsRow = terminalSidebarOpen && railColumnOpen
|
||||
|
||||
const previewPane = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
|
|
@ -1277,18 +1292,31 @@ export function DesktopController() {
|
|||
|
||||
const terminalPane = (
|
||||
<Pane
|
||||
bottomRow={terminalAsRow}
|
||||
defaultOpen
|
||||
disabled={!terminalSidebarOpen}
|
||||
divider
|
||||
height="38vh"
|
||||
id="terminal-sidebar"
|
||||
key="terminal-sidebar"
|
||||
maxHeight="80vh"
|
||||
maxWidth="80vw"
|
||||
minHeight="8rem"
|
||||
minWidth="22vw"
|
||||
resizable
|
||||
side={railSide}
|
||||
width="42vw"
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
|
||||
{/* As a column the terminal clears the titlebar; as a bottom row it sits
|
||||
below the rail's panes (so it fills its row edge-to-edge) and gets a
|
||||
left border separating it from the chat — the column-mode separator
|
||||
lives on the resize sash, which moves to the top edge as a row. */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)',
|
||||
terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)'
|
||||
)}
|
||||
>
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
</Pane>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
export interface PaneSlot {
|
||||
column: number
|
||||
side: 'left' | 'right'
|
||||
open: boolean
|
||||
/** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */
|
||||
gridColumn: string
|
||||
/** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */
|
||||
gridRow: string
|
||||
/** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */
|
||||
bottomRow: boolean
|
||||
}
|
||||
|
||||
export interface PaneShellContextValue {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
|
|
@ -38,6 +38,17 @@ export interface PaneProps {
|
|||
forceCollapsed?: boolean
|
||||
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
|
||||
hoverReveal?: boolean
|
||||
/**
|
||||
* Lay the pane out as a horizontal row beneath its rail (spanning every column on
|
||||
* its `side`) instead of as a vertical column. The pane then resizes on the Y axis.
|
||||
* Used to drop the terminal under a crowded rail rather than squeezing another column in.
|
||||
*/
|
||||
bottomRow?: boolean
|
||||
/** Default height of a `bottomRow` pane. */
|
||||
height?: WidthValue
|
||||
/** Min/max height clamps for a `bottomRow` pane's vertical resize. */
|
||||
maxHeight?: WidthValue
|
||||
minHeight?: WidthValue
|
||||
/** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */
|
||||
overlayWidth?: WidthValue
|
||||
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
|
||||
|
|
@ -62,9 +73,11 @@ export interface PaneShellProps {
|
|||
}
|
||||
|
||||
interface CollectedPane {
|
||||
bottomRow: boolean
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
forceCollapsed: boolean
|
||||
height: string
|
||||
id: string
|
||||
resizable: boolean
|
||||
side: PaneSide
|
||||
|
|
@ -72,7 +85,26 @@ interface CollectedPane {
|
|||
}
|
||||
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
const DEFAULT_HEIGHT = '18rem'
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
||||
const DEFAULT_RESIZE_MIN_HEIGHT = 120
|
||||
|
||||
// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a
|
||||
// column; `y` is a horizontal bar on the top edge of a bottom row.
|
||||
const SASH = {
|
||||
x: {
|
||||
orientation: 'vertical',
|
||||
bar: 'bottom-0 top-0 w-1 cursor-col-resize',
|
||||
line: 'inset-y-0 left-1/2 w-px -translate-x-1/2',
|
||||
hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2'
|
||||
},
|
||||
y: {
|
||||
orientation: 'horizontal',
|
||||
bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize',
|
||||
line: 'inset-x-0 top-1/2 h-px -translate-y-1/2',
|
||||
hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
|
||||
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
|
||||
|
|
@ -102,15 +134,16 @@ const remPx = () =>
|
|||
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||
|
||||
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
|
||||
const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight)
|
||||
|
||||
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
|
||||
// pixels for drag clamping. Viewport units resolve against the current window width.
|
||||
// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to
|
||||
// pixels for drag clamping. vw/% resolve against window width, vh against height.
|
||||
function widthToPx(value: WidthValue | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
|
||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/)
|
||||
|
||||
if (!match) {
|
||||
return undefined
|
||||
|
|
@ -122,6 +155,9 @@ function widthToPx(value: WidthValue | undefined) {
|
|||
case 'rem':
|
||||
return n * remPx()
|
||||
|
||||
case 'vh':
|
||||
return (n * viewportHeightPx()) / 100
|
||||
|
||||
case 'vw':
|
||||
|
||||
case '%':
|
||||
|
|
@ -155,9 +191,11 @@ function collectPanes(children: ReactNode) {
|
|||
const props = child.props as PaneProps
|
||||
|
||||
const entry: CollectedPane = {
|
||||
bottomRow: props.bottomRow ?? false,
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
forceCollapsed: props.forceCollapsed ?? false,
|
||||
height: widthToCss(props.height, DEFAULT_HEIGHT),
|
||||
id: props.id,
|
||||
resizable: props.resizable ?? false,
|
||||
side: props.side,
|
||||
|
|
@ -170,9 +208,16 @@ function collectPanes(children: ReactNode) {
|
|||
return { left, mainCount, right }
|
||||
}
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
|
||||
type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }>
|
||||
|
||||
function paneIsOpen(pane: CollectedPane, states: PaneStoreState) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
|
||||
|
||||
return !pane.disabled && !pane.forceCollapsed && stateOpen
|
||||
}
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: PaneStoreState) {
|
||||
const open = paneIsOpen(pane, states)
|
||||
|
||||
if (!open) {
|
||||
return { open: false, track: '0px' }
|
||||
|
|
@ -183,6 +228,12 @@ function trackForPane(pane: CollectedPane, states: Record<string, { open: boolea
|
|||
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
|
||||
}
|
||||
|
||||
function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) {
|
||||
const override = pane.resizable ? states[pane.id]?.heightOverride : undefined
|
||||
|
||||
return override !== undefined ? `${override}px` : pane.height
|
||||
}
|
||||
|
||||
export function PaneShell({ children, className, style }: PaneShellProps) {
|
||||
const paneStates = useStore($paneStates)
|
||||
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
|
||||
|
|
@ -197,34 +248,65 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
|
|||
const cssVars: Record<string, string> = {}
|
||||
let column = 1
|
||||
|
||||
for (const pane of left) {
|
||||
// A bottom-row pane drops out of its rail's column flow and instead spans
|
||||
// every column on its side as a new row below them. The first open one wins
|
||||
// and decides which rail gets split into two rows.
|
||||
const leftCols = left.filter(pane => !pane.bottomRow)
|
||||
const rightCols = right.filter(pane => !pane.bottomRow)
|
||||
const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow)
|
||||
const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null
|
||||
const bottomRailSide = activeBottomRow?.side ?? null
|
||||
|
||||
// Open column panes on the bottom row's side shrink to the top row; everything
|
||||
// else (main, the other rail, closed / hover-reveal panes) stays full height.
|
||||
const addColumn = (pane: CollectedPane, paneSide: PaneSide) => {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
paneById.set(pane.id, { column, open, side: 'left' })
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1'
|
||||
paneById.set(pane.id, { open, side: paneSide, gridColumn: `${column} / ${column + 1}`, gridRow, bottomRow: false })
|
||||
column++
|
||||
}
|
||||
|
||||
for (const pane of leftCols) {
|
||||
addColumn(pane, 'left')
|
||||
}
|
||||
|
||||
tracks.push('minmax(0,1fr)')
|
||||
const mainColumn = column++
|
||||
|
||||
for (const pane of right) {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
paneById.set(pane.id, { column, open, side: 'right' })
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
column++
|
||||
for (const pane of rightCols) {
|
||||
addColumn(pane, 'right')
|
||||
}
|
||||
|
||||
return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & {
|
||||
// Place every bottom-row pane: span its rail's columns on the second row.
|
||||
for (const pane of bottomRowPanes) {
|
||||
const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1`
|
||||
paneById.set(pane.id, { open: pane === activeBottomRow, side: pane.side, gridColumn, gridRow: '2 / 3', bottomRow: true })
|
||||
}
|
||||
|
||||
// Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves
|
||||
// against a known last line. With a bottom row active there are two tracks;
|
||||
// otherwise a single 1fr track behaves exactly like the old single-row grid.
|
||||
const gridTemplateRows = activeBottomRow
|
||||
? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}`
|
||||
: 'minmax(0,1fr)'
|
||||
|
||||
return { cssVars, gridTemplate: tracks.join(' '), gridTemplateRows, mainColumn, paneById } satisfies PaneShellContextValue & {
|
||||
cssVars: Record<string, string>
|
||||
gridTemplate: string
|
||||
gridTemplateRows: string
|
||||
}
|
||||
}, [left, paneStates, right])
|
||||
|
||||
const composedStyle = useMemo<CSSProperties>(
|
||||
() => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }),
|
||||
[ctxValue.cssVars, ctxValue.gridTemplate, style]
|
||||
() => ({
|
||||
...ctxValue.cssVars,
|
||||
...style,
|
||||
gridTemplateColumns: ctxValue.gridTemplate,
|
||||
gridTemplateRows: ctxValue.gridTemplateRows
|
||||
}),
|
||||
[ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -243,6 +325,8 @@ export function Pane({
|
|||
divider = false,
|
||||
disabled = false,
|
||||
hoverReveal = false,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
overlayWidth: overlayWidthProp,
|
||||
id,
|
||||
maxWidth,
|
||||
|
|
@ -308,33 +392,44 @@ export function Pane({
|
|||
onOverlayActiveChange?.(overlayActive)
|
||||
}, [onOverlayActiveChange, overlayActive])
|
||||
|
||||
const isBottomRow = Boolean(slot?.bottomRow)
|
||||
const axis = isBottomRow ? 'y' : 'x'
|
||||
const sash = SASH[axis]
|
||||
const canResize = open && resizable
|
||||
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
||||
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
||||
const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT
|
||||
const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
// One pointer-drag for both axes. Columns grow toward the main column (left
|
||||
// rail → right, right rail → left); the bottom row grows up from its top edge.
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
|
||||
(event: ReactPointerEvent<HTMLDivElement>, axis: 'x' | 'y') => {
|
||||
const rect = paneRef.current?.getBoundingClientRect()
|
||||
const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0
|
||||
|
||||
if (!canResize || paneWidth <= 0) {
|
||||
if (!canResize || base <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const handle = event.currentTarget
|
||||
const { pointerId, clientX: startX } = event
|
||||
const dir = side === 'left' ? 1 : -1
|
||||
const { pointerId } = event
|
||||
const start = axis === 'x' ? event.clientX : event.clientY
|
||||
const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1
|
||||
const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH]
|
||||
const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride
|
||||
const restoreCursor = document.body.style.cursor
|
||||
const restoreSelect = document.body.style.userSelect
|
||||
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const next = paneWidth + (e.clientX - startX) * dir
|
||||
setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next))))
|
||||
const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir
|
||||
apply(id, Math.round(Math.min(max, Math.max(min, next))))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
|
|
@ -352,7 +447,7 @@ export function Pane({
|
|||
window.addEventListener('pointercancel', cleanup, true)
|
||||
window.addEventListener('blur', cleanup)
|
||||
},
|
||||
[canResize, hi, id, lo, side]
|
||||
[canResize, hi, hiH, id, lo, loH, side]
|
||||
)
|
||||
|
||||
if (!ctx) {
|
||||
|
|
@ -377,14 +472,14 @@ export function Pane({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
|
||||
className={cn('group/reveal pointer-events-none relative min-w-0', className)}
|
||||
data-forced={forced ? '' : undefined}
|
||||
data-pane-hover-reveal={forced ? 'open' : 'closed'}
|
||||
data-pane-id={id}
|
||||
data-pane-open="false"
|
||||
data-pane-side={side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
|
@ -423,27 +518,33 @@ export function Pane({
|
|||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn('relative row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
className={cn('relative min-h-0 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
data-pane-id={id}
|
||||
data-pane-open={open ? 'true' : 'false'}
|
||||
data-pane-side={slot.side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }}
|
||||
>
|
||||
{canResize && (
|
||||
<div
|
||||
aria-label={`Resize ${id}`}
|
||||
aria-orientation="vertical"
|
||||
aria-orientation={sash.orientation}
|
||||
className={cn(
|
||||
'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]',
|
||||
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
|
||||
'group absolute z-20 [-webkit-app-region:no-drag]',
|
||||
sash.bar,
|
||||
!isBottomRow && (slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2')
|
||||
)}
|
||||
onPointerDown={startResize}
|
||||
onPointerDown={e => startResize(e, axis)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
|
||||
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
{divider && <span className={cn('absolute bg-(--ui-stroke-secondary)', sash.line)} />}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100',
|
||||
sash.hover
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
|
|
@ -466,9 +567,9 @@ export function PaneMain({ children, className }: PaneMainProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
data-pane-main="true"
|
||||
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
|
||||
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}`, gridRow: '1 / -1' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
|
|||
export interface PaneStateSnapshot {
|
||||
open: boolean
|
||||
widthOverride?: number
|
||||
/** Vertical size override (px) for panes that resize on the Y axis (e.g. the bottom-row terminal). */
|
||||
heightOverride?: number
|
||||
}
|
||||
|
||||
export interface PaneRegisterDefaults {
|
||||
|
|
@ -23,7 +25,11 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot {
|
|||
return false
|
||||
}
|
||||
|
||||
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
|
||||
const widthOk = r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
|
||||
const heightOk =
|
||||
r.heightOverride === undefined || (typeof r.heightOverride === 'number' && Number.isFinite(r.heightOverride))
|
||||
|
||||
return widthOk && heightOk
|
||||
}
|
||||
|
||||
function load(): Record<string, PaneStateSnapshot> {
|
||||
|
|
@ -42,7 +48,7 @@ function load(): Record<string, PaneStateSnapshot> {
|
|||
|
||||
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (isSnapshot(value)) {
|
||||
out[id] = { open: value.open, widthOverride: value.widthOverride }
|
||||
out[id] = { open: value.open, widthOverride: value.widthOverride, heightOverride: value.heightOverride }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,10 +98,12 @@ function memoized<T>(
|
|||
const openCache = new Map<string, ReadableAtom<boolean>>()
|
||||
const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>()
|
||||
const widthCache = new Map<string, ReadableAtom<number | undefined>>()
|
||||
const heightCache = new Map<string, ReadableAtom<number | undefined>>()
|
||||
|
||||
export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false)
|
||||
export const $paneState = (id: string) => memoized(stateCache, id, s => s)
|
||||
export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride)
|
||||
export const $paneHeightOverride = (id: string) => memoized(heightCache, id, s => s?.heightOverride)
|
||||
|
||||
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
|
||||
const current = $paneStates.get()
|
||||
|
|
@ -115,13 +123,13 @@ export function setPaneOpen(id: string, open: boolean) {
|
|||
return
|
||||
}
|
||||
|
||||
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
|
||||
$paneStates.set({ ...current, [id]: { ...existing, open } })
|
||||
}
|
||||
|
||||
export function togglePane(id: string) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id]
|
||||
$paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } })
|
||||
$paneStates.set({ ...current, [id]: { ...existing, open: !(existing?.open ?? false) } })
|
||||
}
|
||||
|
||||
export function setPaneWidthOverride(id: string, width: number | undefined) {
|
||||
|
|
@ -132,8 +140,20 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
|
|||
return
|
||||
}
|
||||
|
||||
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
|
||||
$paneStates.set({ ...current, [id]: { ...existing, widthOverride: width } })
|
||||
}
|
||||
|
||||
export function setPaneHeightOverride(id: string, height: number | undefined) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id] ?? { open: false }
|
||||
|
||||
if (existing.heightOverride === height) {
|
||||
return
|
||||
}
|
||||
|
||||
$paneStates.set({ ...current, [id]: { ...existing, heightOverride: height } })
|
||||
}
|
||||
|
||||
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
|
||||
export const clearPaneHeightOverride = (id: string) => setPaneHeightOverride(id, undefined)
|
||||
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue