feat(desktop): layout-tree renderer — splits, zones, pointer drag-session, tab strip

fix/verification-admin-route-recovery
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent c388daa665
commit 63a9bde77b
20 changed files with 4601 additions and 954 deletions

View File

@ -1,19 +0,0 @@
import { createContext } from 'react'
export interface PaneSlot {
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 {
paneById: Map<string, PaneSlot>
mainColumn: number
}
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)

View File

@ -0,0 +1,59 @@
/**
* Layout edit mode the shared toggle for the tree renderer's FancyZones-style
* rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds`
* contribution (`layout.editMode`, default \ the sibling of \ = flip
* panes), so it's rebindable and collision-checked like every other action.
* This hook only owns Escape-to-exit.
*/
import { atom } from 'nanostores'
import { useEffect } from 'react'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
export const $layoutEditMode = atom(false)
export function toggleLayoutEditMode() {
$layoutEditMode.set(!$layoutEditMode.get())
}
/** Escape exits edit mode. Registered once by the layout root. */
export function useLayoutEditHotkey(enabled: boolean) {
useEffect(() => {
if (!enabled || typeof window === 'undefined') {
return
}
// Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the
// narrow-pane reveal the rest of the time.
let releaseLayer: (() => void) | null = null
const unsub = $layoutEditMode.subscribe(on => {
if (on && !releaseLayer) {
releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit)
} else if (!on && releaseLayer) {
releaseLayer()
releaseLayer = null
}
})
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) {
return
}
if ($layoutEditMode.get()) {
e.preventDefault()
$layoutEditMode.set(false)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
unsub()
releaseLayer?.()
}
}, [enabled])
}

View File

@ -1,4 +1,7 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
/**
* Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the
* keybinds (B / G / titlebar toggles on narrow viewports) with the pane id
* in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen
* and slide the pane over the grid.
*/
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'

View File

@ -1,333 +0,0 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
import { Pane, PaneMain, PaneShell } from './pane-shell'
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
const root = rendered.container.firstElementChild
if (!(root instanceof HTMLElement)) {
throw new Error('PaneShell did not render a root element')
}
return root
}
function getColumnTemplate(container: HTMLElement): string[] {
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
}
function mockWidth(element: HTMLElement, width: number) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 0,
height: 0,
left: 0,
right: width,
top: 0,
width,
x: 0,
y: 0,
toJSON: () => ({})
})
})
}
describe('PaneShell composition', () => {
beforeEach(() => {
$paneStates.set({})
window.localStorage.clear()
})
afterEach(() => {
cleanup()
$paneStates.set({})
window.localStorage.clear()
})
it('builds a 2-column grid for one left pane + main', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
})
it('orders panes left-to-right by side, preserving source order within a side', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<Pane id="sessions" side="left" width="200px">
sessions
</Pane>
<PaneMain>main</PaneMain>
<Pane id="preview" side="right" width="320px">
preview
</Pane>
<Pane id="inspector" side="right" width="280px">
inspector
</Pane>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
})
it('collapses a closed pane to 0px', () => {
const rendered = render(
<PaneShell>
<Pane defaultOpen={false} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const tracks = getColumnTemplate(gridContainer(rendered))
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
})
it('reads open state from the panes store', () => {
setPaneOpen('files', false)
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
})
it('disabled forces the track to 0px even when the store says open', () => {
setPaneOpen('files', true)
const rendered = render(
<PaneShell>
<Pane disabled={true} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
})
it('disabled does NOT mutate the store-persisted open state', () => {
setPaneOpen('files', true)
render(
<PaneShell>
<Pane disabled={true} id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect($paneStates.get().files?.open).toBe(true)
})
it('uses widthOverride from the store when set', () => {
setPaneOpen('files', true)
setPaneWidthOverride('files', 320)
const rendered = render(
<PaneShell>
<Pane id="files" resizable side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
})
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
const rendered = render(
<PaneShell>
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
inspector
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const template = gridContainer(rendered).style.gridTemplateColumns
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
})
it('coerces numeric widths to px', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width={224}>
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
})
it('emits per-pane width as a CSS variable', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const root = gridContainer(rendered)
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
})
it('places a Pane in the correct grid column via inline style', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>
<span data-testid="main-content">main</span>
</PaneMain>
<Pane id="preview" side="right" width="320px">
<span data-testid="preview-content">preview</span>
</Pane>
</PaneShell>
)
const filesCell = rendered.getByTestId('files-content').parentElement!
const mainCell = rendered.getByTestId('main-content').parentElement!
const previewCell = rendered.getByTestId('preview-content').parentElement!
expect(filesCell.style.gridColumn).toBe('1 / 2')
expect(mainCell.style.gridColumn).toBe('2 / 3')
expect(previewCell.style.gridColumn).toBe('3 / 4')
})
it('marks closed panes aria-hidden', () => {
const rendered = render(
<PaneShell>
<Pane defaultOpen={false} id="files" side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const cell = rendered.getByTestId('files-content').parentElement!
expect(cell.getAttribute('aria-hidden')).toBe('true')
expect(cell.getAttribute('data-pane-open')).toBe('false')
})
it('passes through arbitrary non-Pane children for self-placement', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<PaneMain>main</PaneMain>
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
overlay
</div>
</PaneShell>
)
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
})
it('shows a resize handle only when resizable', () => {
const rendered = render(
<PaneShell>
<Pane id="files" side="left" width="240px">
files
</Pane>
<Pane id="preview" resizable side="right" width="320px">
preview
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
expect(rendered.queryByLabelText('Resize files')).toBeNull()
expect(rendered.getByLabelText('Resize preview')).toBeDefined()
})
it('dragging a left-pane separator stores a wider width override', () => {
const rendered = render(
<PaneShell>
<Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px">
<span data-testid="files-content">files</span>
</Pane>
<PaneMain>main</PaneMain>
</PaneShell>
)
const paneCell = rendered.getByTestId('files-content').parentElement
if (!(paneCell instanceof HTMLElement)) {
throw new Error('Expected pane cell element')
}
mockWidth(paneCell, 240)
const separator = rendered.getByLabelText('Resize files')
fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 })
fireEvent.pointerMove(window, { clientX: 300 })
fireEvent.pointerUp(window, { clientX: 300 })
expect($paneStates.get().files?.widthOverride).toBe(300)
})
it('dragging a right-pane separator clamps to max width', () => {
const rendered = render(
<PaneShell>
<PaneMain>main</PaneMain>
<Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px">
<span data-testid="preview-content">preview</span>
</Pane>
</PaneShell>
)
const paneCell = rendered.getByTestId('preview-content').parentElement
if (!(paneCell instanceof HTMLElement)) {
throw new Error('Expected pane cell element')
}
mockWidth(paneCell, 320)
const separator = rendered.getByLabelText('Resize preview')
fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 })
fireEvent.pointerMove(window, { clientX: 760 })
fireEvent.pointerUp(window, { clientX: 760 })
expect($paneStates.get().preview?.widthOverride).toBe(340)
})
})

View File

@ -1,598 +0,0 @@
import { useStore } from '@nanostores/react'
import {
Children,
type CSSProperties,
isValidElement,
type ReactElement,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { cn } from '@/lib/utils'
import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
type PaneSide = 'left' | 'right'
type WidthValue = string | number
interface PaneRoleMarker {
__paneShellRole?: 'pane' | 'main'
}
export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
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). */
onOverlayActiveChange?: (overlayActive: boolean) => void
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
resizable?: boolean
side: PaneSide
width?: WidthValue
}
export interface PaneMainProps {
children?: ReactNode
className?: string
}
export interface PaneShellProps {
children?: ReactNode
className?: string
style?: CSSProperties
}
interface CollectedPane {
bottomRow: boolean
defaultOpen: boolean
disabled: boolean
forceCollapsed: boolean
height: string
id: string
resizable: boolean
side: PaneSide
width: string
}
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.
const HOVER_REVEAL_SLIDE_MS = 220
const HOVER_REVEAL_ENTER_DELAY_MS = 130
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
// Offset shadow lifting the revealed panel off the content (same both sides;
// the mirror axis is offset-x, which is 0). Same color on light + dark.
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
// Edge trigger strip, inset past the OS window-resize grab area AND the
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
// pane on hover and swallow its clicks (#44140).
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
const remPx = () =>
typeof window === 'undefined'
? 16
: 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 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|vh|%)?$/)
if (!match) {
return undefined
}
const n = Number.parseFloat(match[1])
switch (match[2]) {
case 'rem':
return n * remPx()
case 'vh':
return (n * viewportHeightPx()) / 100
case 'vw':
case '%':
return (n * viewportPx()) / 100
default:
return n
}
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
}
function collectPanes(children: ReactNode) {
const left: CollectedPane[] = []
const right: CollectedPane[] = []
let mainCount = 0
Children.forEach(children, child => {
if (isRole(child, 'main')) {
mainCount++
return
}
if (!isRole(child, 'pane')) {
return
}
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,
width: widthToCss(props.width, DEFAULT_WIDTH)
}
;(props.side === 'left' ? left : right).push(entry)
})
return { left, mainCount, right }
}
type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }>
function paneIsOpen(pane: CollectedPane, states: PaneStoreState) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
return !pane.disabled && !pane.forceCollapsed && stateOpen
}
function trackForPane(pane: CollectedPane, states: PaneStoreState) {
const open = paneIsOpen(pane, states)
if (!open) {
return { open: false, track: '0px' }
}
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
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])
if (import.meta.env.DEV && mainCount > 1) {
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
}
const ctxValue = useMemo(() => {
const paneById = new Map<string, PaneSlot>()
const tracks: string[] = []
const cssVars: Record<string, string> = {}
let column = 1
// 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)
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 rightCols) {
addColumn(pane, 'right')
}
// 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,
gridTemplateRows: ctxValue.gridTemplateRows
}),
[ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style]
)
return (
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
{children}
</div>
</PaneShellContext.Provider>
)
}
export function Pane({
children,
className,
defaultOpen = true,
divider = false,
disabled = false,
hoverReveal = false,
maxHeight,
minHeight,
overlayWidth: overlayWidthProp,
id,
maxWidth,
minWidth,
onOverlayActiveChange,
resizable = false,
width
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
const [forced, setForced] = useState(false)
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
// else the persisted resize override, else the docked width.
const overlayWidth =
overlayWidthProp !== undefined
? widthToCss(overlayWidthProp, DEFAULT_WIDTH)
: override !== undefined
? `${override}px`
: widthToCss(width, DEFAULT_WIDTH)
useEffect(() => {
if (registered.current) {
return
}
registered.current = true
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
// a collapsed overlay (reopened / widened).
useEffect(() => {
if (typeof window === 'undefined' || !overlayActive) {
setForced(false)
return
}
const onToggle = (e: Event) => {
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
setForced(v => !v)
}
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
}, [id, overlayActive])
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
useEffect(() => {
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>, axis: 'x' | 'y') => {
const rect = paneRef.current?.getBoundingClientRect()
const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0
if (!canResize || base <= 0) {
return
}
event.preventDefault()
const handle = event.currentTarget
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 = axis === 'x' ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
const onMove = (e: PointerEvent) => {
const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir
apply(id, Math.round(Math.min(max, Math.max(min, next))))
}
const cleanup = () => {
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
handle.releasePointerCapture?.(pointerId)
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', cleanup, true)
window.removeEventListener('pointercancel', cleanup, true)
window.removeEventListener('blur', cleanup)
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', cleanup, true)
window.addEventListener('pointercancel', cleanup, true)
window.addEventListener('blur', cleanup)
},
[canResize, hi, hiH, id, lo, loH, side]
)
if (!ctx) {
if (import.meta.env.DEV) {
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
}
return null
}
if (!slot) {
return null
}
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
// thin edge trigger + the floating panel (both absolute, escaping the zero
// box). group-hover (or data-forced from the keyboard) drives the slide; the
// enter-delay is the hover-intent gate. No JS pointer math.
if (overlayActive) {
const edge = side === 'left' ? 'left' : 'right'
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
return (
<div
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.gridColumn, gridRow: slot.gridRow }}
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
data-pane-reveal-trigger=""
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
{/* Keyed on side so flipping panes remounts off-screen on the new edge
instead of transitioning the transform across the viewport. */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}
style={
{
[edge]: 0,
width: overlayWidth,
'--reveal-shadow': HOVER_REVEAL_SHADOW,
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
transitionTimingFunction: HOVER_REVEAL_EASE,
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
} as CSSProperties
}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
aria-hidden={!open}
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.gridColumn, gridRow: slot.gridRow }}
>
{canResize && (
<div
aria-label={`Resize ${id}`}
aria-orientation={sash.orientation}
className={cn(
'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={e => startResize(e, axis)}
role="separator"
tabIndex={0}
>
{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}
</div>
)
}
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
export function PaneMain({ children, className }: PaneMainProps) {
const ctx = useContext(PaneShellContext)
if (!ctx) {
if (import.meta.env.DEV) {
console.warn('[PaneMain] must be rendered inside <PaneShell>')
}
return null
}
return (
<div
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}`, gridRow: '1 / -1' }}
>
{children}
</div>
)
}
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'

View File

@ -0,0 +1,621 @@
/**
* FancyZones grid model a faithful port of PowerToys'
* `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs`
* (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants
* follow the C# sources so behavior matches the original editor:
*
* - A layout is rows x columns with percent tracks summing to MULTIPLIER
* (10000) and a cellChildMap assigning each cell to a zone; a zone spanning
* multiple cells appears as the same index in adjacent cells.
* - Zones are rectangles in the 0..10000 coordinate space (prefix sums).
* - Resizers are the shared edges between zones, derived from cellChildMap
* discontinuities; dragging one moves every zone touching that edge.
* - Merging computes the rectangular CLOSURE of the selection (extending it
* until no zone is partially cut) the signature FancyZones merge feel.
*/
// The sum of row/column percents should be equal to this number.
export const MULTIPLIER = 10000
/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */
export const MIN_ZONE_SIZE = 500
export interface GridLayout {
rows: number
columns: number
rowPercents: number[]
columnPercents: number[]
/** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */
cellChildMap: number[][]
}
export interface GridZone {
index: number
left: number
top: number
right: number
bottom: number
}
export interface GridResizer {
orientation: 'horizontal' | 'vertical'
/** All zones to the left/up, in order. */
negativeSideIndices: number[]
/** All zones to the right/down, in order. */
positiveSideIndices: number[]
}
// ---------------------------------------------------------------------------
// GridData helpers (verbatim)
// ---------------------------------------------------------------------------
/** result[k] is the sum of the first k elements of the given list. */
export function prefixSum(list: number[]): number[] {
const result: number[] = [0]
let sum = 0
for (const value of list) {
sum += value
result.push(sum)
}
return result
}
/** Opposite of prefixSum: differences of consecutive elements. */
function adjacentDifference(list: number[]): number[] {
if (list.length <= 1) {
return []
}
const result: number[] = []
for (let i = 0; i < list.length - 1; i++) {
result.push(list[i + 1] - list[i])
}
return result
}
/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */
function unique(list: number[]): number[] {
const result: number[] = []
if (list.length === 0) {
return result
}
let last = list[0]
result.push(last)
for (let i = 1; i < list.length; i++) {
if (list[i] !== last) {
last = list[i]
result.push(last)
}
}
return result
}
// ---------------------------------------------------------------------------
// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers)
// ---------------------------------------------------------------------------
export function modelToZones(model: GridLayout): GridZone[] | null {
const { rows, columns: cols, cellChildMap } = model
let zoneCount = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
zoneCount = Math.max(zoneCount, cellChildMap[row][col])
}
}
zoneCount++
if (zoneCount > rows * cols) {
return null
}
const indexCount = new Array<number>(zoneCount).fill(0)
const indexRowLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
const indexRowHigh = new Array<number>(zoneCount).fill(0)
const indexColLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER)
const indexColHigh = new Array<number>(zoneCount).fill(0)
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const index = cellChildMap[row][col]
indexCount[index]++
indexRowLow[index] = Math.min(indexRowLow[index], row)
indexColLow[index] = Math.min(indexColLow[index], col)
indexRowHigh[index] = Math.max(indexRowHigh[index], row)
indexColHigh[index] = Math.max(indexColHigh[index], col)
}
}
for (let index = 0; index < zoneCount; index++) {
if (indexCount[index] === 0) {
return null
}
// Each zone must occupy a full rectangle of cells.
if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) {
return null
}
}
if (
model.rowPercents.length !== rows ||
model.columnPercents.length !== cols ||
model.rowPercents.some(x => x < 1) ||
model.columnPercents.some(x => x < 1)
) {
return null
}
const rowPrefixSum = prefixSum(model.rowPercents)
const colPrefixSum = prefixSum(model.columnPercents)
if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) {
return null
}
const zones: GridZone[] = []
for (let index = 0; index < zoneCount; index++) {
zones.push({
index,
left: colPrefixSum[indexColLow[index]],
right: colPrefixSum[indexColHigh[index] + 1],
top: rowPrefixSum[indexRowLow[index]],
bottom: rowPrefixSum[indexRowHigh[index] + 1]
})
}
return zones
}
export function modelToResizers(model: GridLayout): GridResizer[] {
const grid = model.cellChildMap
const { rows, columns: cols } = model
const resizers: GridResizer[] = []
// Horizontal
for (let row = 1; row < rows; row++) {
for (let startCol = 0; startCol < cols; ) {
if (grid[row - 1][startCol] !== grid[row][startCol]) {
let endCol = startCol
while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) {
endCol++
}
const positive: number[] = []
const negative: number[] = []
for (let col = startCol; col <= endCol; col++) {
negative.push(grid[row - 1][col])
positive.push(grid[row][col])
}
resizers.push({
orientation: 'horizontal',
positiveSideIndices: unique(positive),
negativeSideIndices: unique(negative)
})
startCol = endCol + 1
} else {
startCol++
}
}
}
// Vertical
for (let col = 1; col < cols; col++) {
for (let startRow = 0; startRow < rows; ) {
if (grid[startRow][col - 1] !== grid[startRow][col]) {
let endRow = startRow
while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) {
endRow++
}
const positive: number[] = []
const negative: number[] = []
for (let row = startRow; row <= endRow; row++) {
negative.push(grid[row][col - 1])
positive.push(grid[row][col])
}
resizers.push({
orientation: 'vertical',
positiveSideIndices: unique(positive),
negativeSideIndices: unique(negative)
})
startRow = endRow + 1
} else {
startRow++
}
}
}
return resizers
}
// ---------------------------------------------------------------------------
// Zones -> model (GridData.ZonesToModel)
// ---------------------------------------------------------------------------
export function zonesToModel(zones: GridZone[]): GridLayout {
const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b)
const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b)
const model: GridLayout = {
rows: yCoords.length - 1,
columns: xCoords.length - 1,
rowPercents: adjacentDifference(yCoords),
columnPercents: adjacentDifference(xCoords),
cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array<number>(xCoords.length - 1).fill(0))
}
for (let index = 0; index < zones.length; index++) {
const zone = zones[index]
const startRow = yCoords.indexOf(zone.top)
const endRow = yCoords.indexOf(zone.bottom)
const startCol = xCoords.indexOf(zone.left)
const endCol = xCoords.indexOf(zone.right)
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
model.cellChildMap[row][col] = index
}
}
}
return model
}
// ---------------------------------------------------------------------------
// Closure + merge (GridData.ComputeClosure / DoMerge)
// ---------------------------------------------------------------------------
function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } {
let left = Number.MAX_SAFE_INTEGER
let right = Number.MIN_SAFE_INTEGER
let top = Number.MAX_SAFE_INTEGER
let bottom = Number.MIN_SAFE_INTEGER
if (indices.length === 0) {
return { indices: [], zone: { index: -1, left, right, top, bottom } }
}
const extend = (zone: GridZone) => {
left = Math.min(left, zone.left)
right = Math.max(right, zone.right)
top = Math.min(top, zone.top)
bottom = Math.max(bottom, zone.bottom)
}
for (const index of indices) {
extend(zones[index])
}
let possiblyBroken = true
while (possiblyBroken) {
possiblyBroken = false
for (const zone of zones) {
const area = (zone.bottom - zone.top) * (zone.right - zone.left)
const cutLeft = Math.max(left, zone.left)
const cutRight = Math.min(right, zone.right)
const cutTop = Math.max(top, zone.top)
const cutBottom = Math.min(bottom, zone.bottom)
const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft)
if (newArea !== 0 && newArea !== area) {
// Bad intersection found, extend.
extend(zone)
possiblyBroken = true
}
}
}
const resultIndices = zones
.filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom)
.map(zone => zone.index)
return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } }
}
export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] {
const zones = modelToZones(model)
return zones ? computeClosure(zones, indices).indices : []
}
export function doMerge(model: GridLayout, indices: number[]): GridLayout {
if (indices.length === 0) {
return model
}
const zones = modelToZones(model)
if (!zones) {
return model
}
const lowestIndex = Math.min(...indices)
const closure = computeClosure(zones, indices)
const closureIndices = new Set(closure.indices)
const remaining = zones.filter(zone => !closureIndices.has(zone.index))
remaining.splice(lowestIndex, 0, closure.zone)
return zonesToModel(remaining)
}
// ---------------------------------------------------------------------------
// Split (GridData.CanSplit / Split)
// ---------------------------------------------------------------------------
export function canSplit(
model: GridLayout,
zoneIndex: number,
position: number,
orientation: 'horizontal' | 'vertical'
): boolean {
const zones = modelToZones(model)
if (!zones || !zones[zoneIndex]) {
return false
}
const zone = zones[zoneIndex]
if (orientation === 'horizontal') {
return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE
}
return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE
}
export function splitZone(
model: GridLayout,
zoneIndex: number,
position: number,
orientation: 'horizontal' | 'vertical'
): GridLayout {
if (!canSplit(model, zoneIndex, position, orientation)) {
return model
}
const zones = modelToZones(model)!
const zone = zones[zoneIndex]
const zone1 = { ...zone }
const zone2 = { ...zone }
zones.splice(zoneIndex, 1)
if (orientation === 'horizontal') {
zone1.bottom = position
zone2.top = position
} else {
zone1.right = position
zone2.left = position
}
zones.splice(zoneIndex, 0, zone1)
zones.splice(zoneIndex + 1, 0, zone2)
return zonesToModel(zones)
}
// ---------------------------------------------------------------------------
// Resizer drag (GridData.CanDrag / Drag)
// ---------------------------------------------------------------------------
export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean {
const zones = modelToZones(model)
const resizers = modelToResizers(model)
const resizer = resizers[resizerIndex]
if (!zones || !resizer) {
return false
}
const getSize = (zoneIndex: number) => {
const zone = zones[zoneIndex]
return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top
}
for (const zoneIndex of resizer.positiveSideIndices) {
if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) {
return false
}
}
for (const zoneIndex of resizer.negativeSideIndices) {
if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) {
return false
}
}
return true
}
export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout {
if (!canDrag(model, resizerIndex, delta)) {
return model
}
const zones = modelToZones(model)!
const resizer = modelToResizers(model)[resizerIndex]
for (const zoneIndex of resizer.positiveSideIndices) {
const zone = zones[zoneIndex]
if (resizer.orientation === 'horizontal') {
zone.top += delta
} else {
zone.left += delta
}
}
for (const zoneIndex of resizer.negativeSideIndices) {
const zone = zones[zoneIndex]
if (resizer.orientation === 'horizontal') {
zone.bottom += delta
} else {
zone.right += delta
}
}
return zonesToModel(zones)
}
// ---------------------------------------------------------------------------
// Templates + validation (GridLayoutModel)
// ---------------------------------------------------------------------------
/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */
function evenPercents(count: number): number[] {
const out: number[] = []
for (let i = 0; i < count; i++) {
out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count))
}
return out
}
export function initColumns(count: number): GridLayout {
return {
rows: 1,
columns: count,
rowPercents: [MULTIPLIER],
columnPercents: evenPercents(count),
cellChildMap: [Array.from({ length: count }, (_, i) => i)]
}
}
export function initRows(count: number): GridLayout {
return {
rows: count,
columns: 1,
rowPercents: evenPercents(count),
columnPercents: [MULTIPLIER],
cellChildMap: Array.from({ length: count }, (_, i) => [i])
}
}
export function initGrid(zoneCount: number): GridLayout {
let rows = 1
while (Math.floor(zoneCount / rows) >= rows) {
rows++
}
rows--
let cols = Math.floor(zoneCount / rows)
if (zoneCount % rows !== 0) {
cols++
}
const model: GridLayout = {
rows,
columns: cols,
rowPercents: evenPercents(rows),
columnPercents: evenPercents(cols),
cellChildMap: Array.from({ length: rows }, () => new Array<number>(cols).fill(0))
}
let index = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
model.cellChildMap[row][col] = index++
if (index === zoneCount) {
index--
}
}
}
return model
}
/**
* The "Priority Grid" template (GridLayoutModel._priorityData, decoded from
* its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for
* counts beyond the table, as in the original.
*/
export function initPriorityGrid(zoneCount: number): GridLayout {
if (zoneCount === 2) {
return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] }
}
if (zoneCount === 3) {
return {
rows: 1,
columns: 3,
rowPercents: [MULTIPLIER],
columnPercents: [2500, 5000, 2500],
cellChildMap: [[0, 1, 2]]
}
}
return initGrid(zoneCount)
}
/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */
export function isGridValid(model: unknown): model is GridLayout {
if (!model || typeof model !== 'object') {
return false
}
const m = model as GridLayout
if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) {
return false
}
if (
!Array.isArray(m.rowPercents) ||
!Array.isArray(m.columnPercents) ||
m.rowPercents.length !== m.rows ||
m.columnPercents.length !== m.columns ||
m.rowPercents.some(x => typeof x !== 'number' || x < 1) ||
m.columnPercents.some(x => typeof x !== 'number' || x < 1)
) {
return false
}
if (
!Array.isArray(m.cellChildMap) ||
m.cellChildMap.length !== m.rows ||
m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number'))
) {
return false
}
const rowPrefix = prefixSum(m.rowPercents)
const colPrefix = prefixSum(m.columnPercents)
if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) {
return false
}
return modelToZones(m) !== null
}

View File

@ -0,0 +1,198 @@
/**
* Grid -> tree bridge. A FancyZones grid whose zones can be produced by
* recursive guillotine cuts (every FancyZones template, and almost every
* practical layout) converts exactly to our runtime `LayoutNode` tree:
* full-length cut lines become splits with weights from the cut positions.
* Non-guillotine arrangements (interlocking pinwheels) return null and the
* editor disables Save with an explanation.
*/
import type { GridLayout, GridZone } from './grid-model'
import { modelToZones } from './grid-model'
import { group, type LayoutNode, normalize, split } from './model'
function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] {
const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom])))
// Interior lines only.
return [...coords].sort((a, b) => a - b).slice(1, -1)
}
function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean {
return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at))
}
/**
* Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at
* once so "three columns" becomes one flat 3-child split, not nested pairs.
*/
function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null {
if (zones.length === 1) {
// Zones without panes become EMPTY groups — editor-authored drop targets
// that live until the first structural op prunes them (normalize).
return group(assignPane(zones[0].index))
}
for (const axis of ['x', 'y'] as const) {
const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at))
if (cuts.length === 0) {
continue
}
const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top)))
const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom)))
const lines = [start, ...cuts, end]
const children: LayoutNode[] = []
const weights: number[] = []
for (let i = 0; i < lines.length - 1; i++) {
const lo = lines[i]
const hi = lines[i + 1]
const slice = zones.filter(zone =>
axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi
)
if (slice.length === 0) {
continue
}
const child = cut(slice, assignPane)
if (child) {
children.push(child)
weights.push(hi - lo)
}
}
if (children.length === 0) {
return null
}
if (children.length === 1) {
return children[0]
}
return split(axis === 'x' ? 'row' : 'column', children, weights)
}
// No full-length cut exists on either axis: non-guillotine (pinwheel).
return null
}
export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom'
export interface PlacedPane {
id: string
placement?: PanePlacementHint
}
const CENTER = 5000 // MULTIPLIER / 2
interface ZoneGeo {
index: number
area: number
cx: number
cy: number
}
/**
* Semantic zone assignment: panes claim zones by ROLE, matched on geometry
* `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose
* centroid actually sits on that side (with a small size tiebreak). A hinted
* pane with no acceptable zone left STACKS with its role-mates (or main)
* instead of squatting in some random cell; unhinted panes fill what remains
* biggest-first; extra zones stay empty.
*/
function assignZones(zones: GridZone[], panes: PlacedPane[]): Map<number, string[]> {
const geo: ZoneGeo[] = zones.map(z => ({
index: z.index,
area: (z.right - z.left) * (z.bottom - z.top),
cx: (z.left + z.right) / 2,
cy: (z.top + z.bottom) / 2
}))
const remaining = new Map(geo.map(g => [g.index, g]))
const assignments = new Map<number, string[]>()
const zoneForRole = new Map<string, number>()
// Score = fit for the role; accept = the zone genuinely sits on that side.
const roles: Record<PanePlacementHint, { accept: (g: ZoneGeo) => boolean; score: (g: ZoneGeo) => number }> = {
main: { accept: () => true, score: g => g.area },
left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 },
right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 },
top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 },
bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 }
}
const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => {
const spec = role === '_' ? roles.main : roles[role]
let best: ZoneGeo | null = null
for (const g of remaining.values()) {
if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) {
best = g
}
}
if (best) {
remaining.delete(best.index)
assignments.set(best.index, [pane.id])
zoneForRole.set(role, best.index)
if (role === 'main' || !zoneForRole.has('main')) {
zoneForRole.set('main', zoneForRole.get('main') ?? best.index)
}
return
}
// No acceptable zone left: stack with role-mates, else with main, else last.
const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop()
if (home !== undefined) {
assignments.get(home)?.push(pane.id)
}
}
// Placement priority: main anchors first, then sided panes, then the rest.
const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2)
for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) {
claim(pane, pane.placement ?? '_')
}
return assignments
}
/**
* Convert a grid to a tree. Panes carry placement hints (from their
* contribution's `data.placement`) and land in geometrically fitting zones;
* zones beyond the pane count stay as EMPTY zones.
*/
export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null {
const zones = modelToZones(gridModel)
if (!zones || zones.length === 0 || panes.length === 0) {
return null
}
const assignments = assignZones(zones, panes)
const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? [])
return result ? normalize(result) : null
}
/** True when the grid is expressible as a tree (guillotine-cuttable). */
export function gridIsTreeExpressible(gridModel: GridLayout): boolean {
const zones = modelToZones(gridModel)
if (!zones) {
return false
}
const probe = cut(zones, () => ['probe'])
return probe !== null
}

View File

@ -0,0 +1,111 @@
/**
* Layout presets the FancyZones treatment.
*
* A preset is a CONTRIBUTION (`area: 'layouts'`, `data: LayoutNode`): the app
* registers its bundled presets as `source: 'core'`, plugins register theirs
* exactly the same way, and user-saved presets round-trip through localStorage
* and re-register as `source: 'user'`. The picker (renderer.tsx) reads one
* uniform list via `useContributions('layouts')`.
*/
import { registry } from '@/contrib/registry'
import { readJson, writeJson, writeKey } from '@/lib/storage'
import { isLayoutNode, type LayoutNode } from './model'
import { $layoutTree, applyTree, markActivePreset } from './store'
export const LAYOUTS_AREA = 'layouts'
// v2: v1 presets predate semantic placement (see store.ts) — retire them.
const USER_KEY = 'hermes.desktop.layoutPresets.v2'
writeKey('hermes.desktop.layoutPresets.v1', null)
interface StoredPreset {
name: string
tree: LayoutNode
}
const userDisposers = new Map<string, () => void>()
function loadUserPresets(): Record<string, StoredPreset> {
const parsed = readJson<Record<string, StoredPreset>>(USER_KEY) ?? {}
const out: Record<string, StoredPreset> = {}
for (const [id, preset] of Object.entries(parsed)) {
if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) {
out[id] = preset
}
}
return out
}
function persistUserPresets(presets: Record<string, StoredPreset>) {
writeJson(USER_KEY, presets)
}
function registerUserPreset(id: string, preset: StoredPreset) {
userDisposers.get(id)?.()
userDisposers.set(
id,
registry.register({ id, area: LAYOUTS_AREA, source: 'user', title: preset.name, data: preset.tree })
)
}
// Register persisted user presets at module load.
const userPresets = loadUserPresets()
for (const [id, preset] of Object.entries(userPresets)) {
registerUserPreset(id, preset)
}
/** Save any tree as a named user preset (and make it active). */
export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null {
const trimmed = name.trim()
if (!tree || !trimmed) {
return null
}
const id = `user-${
trimmed
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || Date.now().toString(36)
}`
userPresets[id] = { name: trimmed, tree }
persistUserPresets(userPresets)
registerUserPreset(id, userPresets[id])
markActivePreset(id)
return id
}
/** Save the CURRENT tree as a named user preset (and make it active). */
export function saveCurrentLayoutAs(name: string) {
const tree = $layoutTree.get()
if (tree) {
saveLayoutPresetTree(name, tree)
}
}
export function deleteUserPreset(id: string) {
if (!(id in userPresets)) {
return
}
delete userPresets[id]
persistUserPresets(userPresets)
userDisposers.get(id)?.()
userDisposers.delete(id)
}
export const isUserPreset = (id: string) => id in userPresets
/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */
export function applyLayoutPreset(id: string, tree: LayoutNode) {
applyTree(structuredClone(tree), id)
}

View File

@ -0,0 +1,588 @@
/**
* THE in-app drag primitive. One pointer-capture session (`startDragSession`)
* owns the machinery every in-app drag shares threshold, rAF-coalesced
* moves, cursor/user-select chrome, ghost chip, Esc-as-top-escape-layer,
* hint publishing, teardown and a per-kind RESOLVER supplies the semantics:
* what the pointer is over (`resolveMove` DropHint) and what a release
* does (`onCommit`). Pane/tab drags (below) are the first resolver; the
* sidebar session drag (app/chat/session-drag.ts) is the second. Native
* HTML5 DnD is reserved for true OS boundaries (Finder file drops) in-app
* drags never ride it, so no snap-back animation, no hostile-library
* armor, and Esc aborts synchronously.
*
* Pane drags use the FancyZones engine (zones-engine.ts, ported verbatim):
* sensitivity-radius hit testing, HighlightedZones state machine, Shift =
* select-many (combined zone range), ClosestCenter primary on drop. The
* LAYOUT STAYS FIXED and every zone lights up as a whole-region drop target;
* NOTHING moves until release (tab reorder included the strip shows an
* insertion divider, not a live shuffle). Over a zone's TAB STRIP the drop
* stacks at the divider's slot; elsewhere the radial position picks
* center/edge.
*
* PERFORMANCE CONTRACT: the layout never restructures mid-drag, so every
* rect a resolver needs is snapshotted once at drag start (zones AND tab
* strips) and each pointermove is pure math against those caches no
* elementsFromPoint, no getBoundingClientRect, no style writes unless a
* value actually changed. Moves are coalesced to one hit-test per animation
* frame, with the pending move flushed synchronously on release so the drop
* commits at the exact final position.
*/
import type { PointerEvent as ReactPointerEvent } from 'react'
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder'
import type { DropPosition } from '../model'
import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store'
import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine'
const DRAG_THRESHOLD_PX = 4
/** Normalized radius of the elliptical CENTER region (stack/link). Outside it
* the drop targets the dominant-axis edge the boundary curves with the
* zone's aspect ratio instead of snapping at a rigid pixel band, and corners
* ease into their nearest edge along the quadrant diagonals. */
const CENTER_RADIUS = 0.62
export function snapshotZones(): EngineZone[] {
return [...document.querySelectorAll<HTMLElement>('[data-tree-group]')].map(el => {
const r = el.getBoundingClientRect()
return { id: el.dataset.treeGroup!, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }
})
}
/** Radial drop position within `rect`: inside the center ellipse = the center
* action (stack/link); outside, the dominant axis picks the edge (VS Code
* dock-preview geometry). `centerRadius` sizes the ellipse larger = more
* center, slimmer curved edge bands. */
export function radialPosition(
rect: { left: number; top: number; right: number; bottom: number },
x: number,
y: number,
centerRadius = CENTER_RADIUS
): DropPosition {
// Zone-centered coordinates, ±1 at the edge midpoints.
const dx = ((x - rect.left) / Math.max(1, rect.right - rect.left)) * 2 - 1
const dy = ((y - rect.top) / Math.max(1, rect.bottom - rect.top)) * 2 - 1
if (Math.hypot(dx, dy) < centerRadius) {
return 'center'
}
return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy < 0 ? 'top' : 'bottom')
}
/** Sub-zone drop position within the zone `groupId` (radial hit-testing). */
export function subZonePosition(zones: EngineZone[], groupId: string, x: number, y: number): DropPosition {
const rect = zones.find(zone => zone.id === groupId)?.rect
return rect ? radialPosition(rect, x, y) : 'center'
}
/** One tab's insertion geometry: its pane id + horizontal midpoint. */
interface StripSlot {
id: string
mid: number
}
const stripSlots = (strip: HTMLElement): StripSlot[] =>
[...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')].map(tab => {
const r = tab.getBoundingClientRect()
return { id: tab.dataset.treeTab ?? '', mid: r.left + r.width / 2 }
})
/** Insertion slot from the pointer x against the OTHER tabs' midpoints:
* stack BEFORE the returned pane id (`null` = append). */
export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } {
for (const slot of slots) {
if (slot.id === excludePaneId) {
continue
}
if (x < slot.mid) {
return { before: slot.id }
}
}
return { before: null }
}
/** Drag-start snapshot of one zone's tab strip. Strips never overlap and the
* layout never restructures mid-drag, so rect containment replaces a
* per-move elementsFromPoint hit test. A drop on a strip STACKS at the
* divider's slot the strip is where tabs live, so it wins over the radial
* top-edge band that would otherwise read as "split top". */
export interface StripSnapshot {
groupId: string
rect: ZoneRect
slots: StripSlot[]
}
export function snapshotStrips(): StripSnapshot[] {
return [...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip]')].map(el => {
const r = el.getBoundingClientRect()
return {
groupId: el.dataset.zoneTabstrip!,
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
slots: stripSlots(el)
}
})
}
export const rectContains = (rect: ZoneRect, x: number, y: number, pad = 0) =>
x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad
const sameHint = (a: DropHint | null, b: DropHint | null) =>
a?.groupId === b?.groupId &&
a?.pos === b?.pos &&
a?.stack?.before === b?.stack?.before &&
(a?.stack === undefined) === (b?.stack === undefined) &&
(a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) &&
(a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id)
/** Double-tap detection for drag handles. Pane handles preventDefault
* pointerdown, which suppresses native `dblclick` so rapid same-handle
* taps are detected here instead. */
const DOUBLE_TAP_MS = 400
let lastTap: { key: string; time: number } | null = null
export interface DoubleTapContext {
/** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */
key: string
onDoubleTap: () => void
}
// ---------------------------------------------------------------------------
// The generic drag session (machinery) — resolvers plug in below / elsewhere.
// ---------------------------------------------------------------------------
export interface DragSessionSpec {
/** Movement crossed the drag threshold: snapshot geometry, set the drag
* store(s), dim/mark the source. Runs once. */
onEngage(x: number, y: number): void
/** Per-frame target resolution pure math against drag-start snapshots.
* Returns the hint to publish; `null` = deny area (no-drop cursor, a
* release commits nothing). */
resolveMove(x: number, y: number, shift: boolean): DropHint | null
/** Release over the final published hint (already flushed to the exact
* release position). Only called for engaged drags. */
onCommit(hint: DropHint | null): void
/** Teardown for both commit and abort — undo whatever onEngage marked. */
onEnd?(): void
/** Sub-threshold release = a click on the handle. */
onTap?(): void
double?: DoubleTapContext
/** Floating chip following the pointer for drags whose source doesn't
* stay visibly "held" (a sidebar row, unlike a dimmed tab). */
ghost?: { label: string }
}
/** The engaged drag's ghost chip: plain DOM (no React), themed via the same
* tokens as DropPill, moved with a transform trivially cheap, and removal
* on Esc is synchronous. */
function createGhost(label: string): HTMLElement {
const ghost = document.createElement('div')
ghost.textContent = label
ghost.style.cssText =
'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' +
'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.75rem;border-radius:9999px;' +
'border:1px solid color-mix(in srgb,var(--dt-composer-ring) 45%,transparent);' +
'background:color-mix(in srgb,var(--dt-card) 92%,transparent);color:var(--ui-text-primary);' +
'font-size:0.75rem;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.25);will-change:transform'
document.body.appendChild(ghost)
return ghost
}
/** After an ENGAGED drag, the release still synthesizes a `click` on the
* capture element swallow exactly that one so a drag can never double as
* an activation (row resume, tab close). Committed drags see the click in
* the same task burst as pointerup; an Esc abort's click arrives with the
* eventual release, so the trap disarms right after it. */
function suppressDragClick(committed: boolean) {
const swallow = (ev: MouseEvent) => {
ev.preventDefault()
ev.stopPropagation()
}
window.addEventListener('click', swallow, { capture: true, once: true })
const disarm = () => window.setTimeout(() => window.removeEventListener('click', swallow, true), 0)
if (committed) {
disarm()
} else {
window.addEventListener('pointerup', disarm, { capture: true, once: true })
window.addEventListener('pointercancel', disarm, { capture: true, once: true })
}
}
/**
* Begin a drag session from a handle's pointerdown. A sub-threshold release
* is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's
* resolver owns targeting and the machinery owns everything else. Esc aborts
* instantly: the session registers as the TOP escape layer, tears down
* synchronously, and nothing commits.
*/
export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSessionSpec) {
if (e.button !== 0) {
return
}
const handle = e.currentTarget
const { pointerId } = e
const sx = e.clientX
const sy = e.clientY
const restoreCursor = document.body.style.cursor
const restoreSelect = document.body.style.userSelect
let engaged = false
let releaseEscapeLayer: (() => void) | null = null
let ghost: HTMLElement | null = null
let cursor: string | null = null
// rAF-coalesced move processing: the raw handler only records the latest
// point; all hit testing happens at most once per frame.
let pending: { x: number; y: number; shift: boolean } | null = null
let raf = 0
// Cursor writes are per-frame; only touch the style when the value changes.
const setCursor = (value: string) => {
if (cursor !== value) {
cursor = value
document.body.style.cursor = value
}
}
const publishHint = (next: DropHint | null) => {
if (!sameHint($dropHint.get(), next)) {
if (next?.stack !== undefined && $dropHint.get()?.stack?.before !== next.stack.before) {
reorderStepHaptic()
}
$dropHint.set(next)
}
}
const engage = (x: number, y: number) => {
engaged = true
// Capture only once ENGAGED: pre-threshold pointer events must stay
// untouched so a plain click on the handle (and its children — a row
// body's own onClick) keeps working. Window-level listeners track the
// gesture either way.
try {
handle.setPointerCapture?.(pointerId)
} catch {
// Synthetic events (automation) have no active pointer.
}
setCursor('grabbing')
document.body.style.userSelect = 'none'
// While dragging, Esc belongs to the drag ALONE — lower layers (edit
// mode, overlays) must not also fire on the same press.
releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
if (spec.ghost) {
ghost = createGhost(spec.ghost.label)
}
spec.onEngage(x, y)
}
const processMove = (x: number, y: number, shift: boolean) => {
if (!engaged) {
if (Math.hypot(x - sx, y - sy) < DRAG_THRESHOLD_PX) {
return
}
engage(x, y)
}
if (ghost) {
ghost.style.transform = `translate3d(${x + 14}px, ${y + 12}px, 0)`
}
const hint = spec.resolveMove(x, y, shift)
// Over a deny area (no target — titlebar / statusbar / gutters /
// off-window) the release cancels; the cursor says so up front.
setCursor(hint ? 'grabbing' : 'no-drop')
publishHint(hint)
}
const flushMove = () => {
raf = 0
if (pending) {
const { shift, x, y } = pending
pending = null
processMove(x, y, shift)
}
}
const onMove = (ev: PointerEvent) => {
pending = { shift: ev.shiftKey, x: ev.clientX, y: ev.clientY }
raf ||= requestAnimationFrame(flushMove)
}
const finish = (commit: boolean) => {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
// The drop must land at the FINAL pointer position, not the last painted
// frame's — flush the pending move before reading the hint. An abort
// (Esc / pointercancel) skips it: everything is discarded anyway.
if (commit) {
flushMove()
}
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
ghost?.remove()
ghost = null
releaseEscapeLayer?.()
releaseEscapeLayer = null
try {
handle.releasePointerCapture?.(pointerId)
} catch {
// Mirror of the capture guard.
}
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', onUp, true)
window.removeEventListener('pointercancel', onCancel, true)
window.removeEventListener('keydown', onKey, true)
if (engaged) {
suppressDragClick(commit)
if (commit) {
spec.onCommit($dropHint.get())
}
} else if (commit) {
const now = Date.now()
if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) {
lastTap = null
spec.double.onDoubleTap()
} else {
lastTap = spec.double ? { key: spec.double.key, time: now } : null
spec.onTap?.()
}
}
spec.onEnd?.()
$dropHint.set(null)
$treeDragging.set(null)
}
const onUp = () => finish(true)
const onCancel = () => finish(false)
// Esc aborts the drag — the target selection vanishes and nothing moves,
// the universal "never mind" for an in-flight drag. Capture-phase + stop so
// it doesn't also close a pane/overlay behind the drag (the escape layer
// covers contract-following handlers; the stop covers the rest).
const onKey = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
ev.preventDefault()
ev.stopPropagation()
finish(false)
}
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', onUp, true)
window.addEventListener('pointercancel', onCancel, true)
window.addEventListener('keydown', onKey, true)
}
// ---------------------------------------------------------------------------
// Pane drag — the tree's resolver over the generic session.
// ---------------------------------------------------------------------------
interface ReorderContext {
groupId: string
/** The tab-strip element; tabs carry `data-tree-tab={paneId}`. */
strip: HTMLElement
}
/** How far (px) the pointer may stray from the strip before a tab drag stops
* being a reorder and becomes a zone move (browser-tab tear-off feel). */
const TEAR_OFF_SLACK_PX = 18
/**
* Begin a pane drag from any handle. A sub-threshold release is a click
* (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap`
* instead). With a `reorder` context (tab drags), movement inside the strip
* targets an insertion slot the strip renders a divider at it, NOTHING
* moves until release (placement-on-release, like every other drop); tearing
* away from the strip converts the drag into a zone move. Zone mode: zones
* light up, the target's tab strip stacks at its divider slot, Shift extends
* the highlight range, release drops into the ClosestCenter primary zone.
* Esc aborts either mode.
*/
export function startPaneDrag(
paneId: string,
e: ReactPointerEvent<HTMLElement>,
onTap?: () => void,
reorder?: ReorderContext,
double?: DoubleTapContext
) {
if (e.button !== 0) {
return
}
e.preventDefault()
e.stopPropagation()
const highlighted = new HighlightedZones()
let zones: EngineZone[] = []
let strips: StripSnapshot[] = []
let mode: 'reorder' | 'zone' | null = null
let dimmed: HTMLElement | null = null
const markSource = () => {
// The dragged tab dims for the drag's life — the divider says where it
// GOES, the dim says what MOVES. No live shuffle (placement-on-release).
dimmed ??= reorder?.strip.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null
dimmed?.style.setProperty('opacity', '0.45')
}
const enterZoneMode = () => {
mode = 'zone'
// The layout never restructures mid-drag, so zone/strip rects are stable.
zones = snapshotZones()
strips = snapshotStrips()
$treeDragging.set(paneId)
markSource()
}
// The reorder strip's geometry, snapshotted on first use (same fixed-layout
// guarantee as the zone snapshots).
let reorderSnap: { rect: ZoneRect; slots: StripSlot[] } | null = null
const reorderStrip = () => {
if (!reorderSnap) {
const r = reorder!.strip.getBoundingClientRect()
reorderSnap = {
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
slots: stripSlots(reorder!.strip)
}
}
return reorderSnap
}
const withinStrip = (x: number, y: number) =>
Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX)
startDragSession(e, {
double,
onTap,
onEngage(x, y) {
if (reorder && withinStrip(x, y)) {
mode = 'reorder'
markSource()
} else {
enterZoneMode()
}
},
resolveMove(x, y, shift) {
if (mode === 'reorder') {
if (withinStrip(x, y)) {
return {
kind: 'group',
groupId: reorder!.groupId,
groupIds: [reorder!.groupId],
pos: 'center',
stack: slotBefore(reorderStrip().slots, x, paneId)
}
}
// Tear-off: the tab leaves the strip and becomes a zone move.
enterZoneMode()
}
// The hint updates on highlight-set changes AND on sub-zone position
// changes (center/edge regions within the same primary zone).
const point = { x, y }
highlighted.update(zones, point, shift)
let groupIds = [...highlighted.zones()]
// Spanning multiple zones is EXPLICIT (Shift). Without it, the seam-
// proximity capture (sensitivity radius grabs both neighbors near a
// shared edge) collapses to the primary zone — otherwise a drop near a
// seam silently merges zones the user never asked to merge.
if (!shift && groupIds.length > 1) {
const collapsed = primaryZone(zones, groupIds, point)
groupIds = collapsed ? [collapsed] : []
}
const groupId = groupIds.length > 0 ? (primaryZone(zones, groupIds, point) ?? undefined) : undefined
// Over the target's TAB STRIP the drop stacks at the divider's slot;
// sub-positions only make sense for a single-zone drop (a Shift-span
// always merges, pos ignored).
const strip =
groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null
const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined
const pos: DropPosition = stack
? 'center'
: groupIds.length === 1 && groupId
? subZonePosition(zones, groupId, x, y)
: 'center'
return groupIds.length > 0 ? { kind: 'group', groupId, groupIds, pos, stack } : null
},
onCommit(hint) {
if (mode === 'reorder' && reorder && hint?.stack !== undefined) {
// Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there).
const others = [...reorder.strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
.map(el => el.dataset.treeTab)
.filter((id): id is string => Boolean(id) && id !== paneId)
const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length
if (toIndex >= 0) {
reorderTreePane(reorder.groupId, paneId, toIndex)
reorderCommitHaptic()
}
}
if (mode === 'zone') {
// Drop what the hint SHOWS — the overlay and the commit share one
// truth (the raw highlight set can hold both seam neighbors; the hint
// already collapsed that to the primary unless Shift made the span
// explicit).
const targets = hint?.groupIds ?? []
if (targets.length > 1) {
// Shift-span: merge the highlighted zones, dropping the pane across them.
mergeTreeZones([...targets], paneId, hint?.groupId ?? null)
} else if (hint?.groupId) {
// strip = stack at the divider slot; center = join the stack;
// an edge = split the zone and land there.
moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before })
}
}
},
onEnd() {
dimmed?.style.removeProperty('opacity')
highlighted.reset()
}
})
}

View File

@ -0,0 +1,113 @@
/**
* Edit-mode palette the floating, draggable "Layouts" card shown while
* layout edit mode is on. It hosts the layout picker and the reset/done
* actions; its header doubles as the drag handle. Position survives edit-mode
* toggles within a session.
*/
import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { $bindings, bindingsFor } from '@/store/keybinds'
import { $layoutEditMode } from '../../edit-mode'
import { resetLayoutTree } from '../store'
import { LayoutPicker } from './layout-picker'
// Palette position survives edit-mode toggles within a session; null = centered.
let lastPalettePos: { x: number; y: number } | null = null
export function TreeEditBar() {
const { t } = useI18n()
const editMode = useStore($layoutEditMode)
const bindings = useStore($bindings)
const [pos, setPos] = useState(lastPalettePos)
const cardRef = useRef<HTMLDivElement>(null)
// The toggle is a `keybinds` contribution (`layout.editMode`) — show the
// user's LIVE binding, not a hardcoded chord.
const toggleCombo = bindingsFor('layout.editMode', bindings)[0]
const startMove = useCallback((e: ReactPointerEvent<HTMLElement>) => {
if (e.button !== 0) {
return
}
const card = cardRef.current
const parent = card?.parentElement
if (!card || !parent) {
return
}
e.preventDefault()
const cardRect = card.getBoundingClientRect()
const dx = e.clientX - cardRect.x
const dy = e.clientY - cardRect.y
const onMove = (ev: PointerEvent) => {
const parentRect = parent.getBoundingClientRect()
const next = {
x: Math.max(4, Math.min(parentRect.width - cardRect.width - 4, ev.clientX - parentRect.x - dx)),
y: Math.max(4, Math.min(parentRect.height - 40, ev.clientY - parentRect.y - dy))
}
lastPalettePos = next
setPos(next)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', onUp, true)
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', onUp, true)
}, [])
if (!editMode) {
return null
}
return (
<div
className="absolute z-50 flex w-[26rem] max-w-[calc(100%-2rem)] flex-col rounded-xl border border-(--ui-stroke-secondary) bg-popover text-popover-foreground shadow-2xl [-webkit-app-region:no-drag]"
ref={cardRef}
style={pos ? { left: pos.x, top: pos.y } : { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }}
>
{/* Header doubles as the drag handle (Panel-style title + actions). */}
<header
className="flex shrink-0 cursor-grab select-none items-start justify-between gap-3 px-4 pb-2 pt-3 active:cursor-grabbing"
onPointerDown={startMove}
>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{t.zones.editTitle}</h2>
<p className="text-xs text-muted-foreground/80">
{t.zones.editHint}{' '}
{toggleCombo && (
<kbd className="rounded border border-(--ui-stroke-secondary) bg-foreground/5 px-1 font-mono text-[10px]">
{formatCombo(toggleCombo)}
</kbd>
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5" onPointerDown={e => e.stopPropagation()}>
<Button onClick={resetLayoutTree} size="sm" variant="ghost">
{t.zones.reset}
</Button>
<Button onClick={() => $layoutEditMode.set(false)} size="sm" variant="outline">
{t.common.done}
</Button>
</div>
</header>
<div className="px-4 pb-4">
<LayoutPicker />
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
/**
* Layout tree renderer (root).
*
* - `split` -> flex row/column; 1px seams between siblings double as resize
* sashes (the seam IS the boundary junction-owned, never doubled). See
* tree-split.tsx.
* - `group` -> a ZONE: header strip (tabs when stacked, minimize chevron) +
* the active pane's content, resolved from the contribution registry
* (`area: 'panes'`). Empty zones exist only in editor-authored trees. See
* tree-group.tsx.
*
* Dragging is FancyZones-style (drag-session.ts): the LAYOUT STAYS FIXED and
* every zone lights up as a whole-region drop target; dropping moves the pane
* into that zone (joining its tab stack). Structure changes (splitting/merging/
* resizing zones) belong to the zone editor, not the drag.
*
* This file owns only the composition: the recursive tree, the narrow-viewport
* overlays, the edit palette, and the zone editor. The pieces live in sibling
* modules track-model (sizing), drag-session (drag), tree-split / tree-group
* (nodes), layout-picker + edit-bar (edit mode), narrow-overlays.
*/
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect } from 'react'
import { useLayoutEditHotkey } from '../../edit-mode'
import { publishWorkspaceGeometry } from '../../geometry'
import { $layoutTree, trackActiveTreeGroup } from '../store'
import { ZoneEditor } from '../zone-editor'
import { TreeEditBar } from './edit-bar'
import { NarrowOverlays } from './narrow-overlays'
import { TreeNode } from './tree-node'
export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
const tree = useStore($layoutTree)
useLayoutEditHotkey(true)
// Track the interacted zone so ⌘W closes the right tab even when nothing is
// DOM-focused.
useEffect(trackActiveTreeGroup, [])
// Publish --workspace-left/right so chrome (titlebar title) aligns to the
// main pane's geometry in plain CSS.
useEffect(publishWorkspaceGeometry, [])
if (!tree) {
return null
}
return (
<div className="relative flex min-h-0 min-w-0 flex-1">
{/* ZonesOverlay::GetAnimationAlpha ramp: clamp(t / 200ms, 0.001, 1). */}
<style>{`@keyframes hermes-zone-fade { from { opacity: 0.001 } to { opacity: 1 } }`}</style>
{/* THE SEAM INVARIANT: boundaries are drawn by the tree (one sash
hairline per seam) content mounted in a zone must not paint its
own edge chrome. App components (asides, the shadcn sidebar) carry
edge borders + inset highlights for the OLD shell's geometry; this
neutralizes all of them at the zone boundary, for every current and
future pane, instead of per-pane class surgery. */}
<style>{`
[data-tree-group] :is(aside, [data-slot=sidebar]) {
border-left-width: 0;
border-right-width: 0;
box-shadow: none;
}
/* Old-shell titlebar BANDS (chat's session header et al size to
--titlebar-height, which is 0 inside zones): a zero-height band is
non-functional but still paints its border-b a stray hairline
doubling the zone's top seam. Remove the band entirely. */
[data-tree-group] header[class*="h-(--titlebar-height)"] {
display: none;
}
`}</style>
<TreeNode node={tree} root />
<NarrowOverlays />
<TreeEditBar />
<ZoneEditor />
{children}
</div>
)
}

View File

@ -0,0 +1,203 @@
/**
* Layout picker the preset card grid inside the edit palette. Thumbnails
* are a miniature render of each preset's layout tree; clicking a card
* applies it, and "Save current arrangement" captures the live tree as a
* user preset. The "New grid layout" button opens the zone editor.
*/
import { useStore } from '@nanostores/react'
import { type ReactNode, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { useContributions } from '@/contrib/react/use-contributions'
import type { Contribution } from '@/contrib/types'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import type { LayoutNode } from '../model'
import { isLayoutNode } from '../model'
import { applyLayoutPreset, deleteUserPreset, isUserPreset, LAYOUTS_AREA, saveCurrentLayoutAs } from '../presets'
import { $activePresetId } from '../store'
import { $zoneEditorOpen } from '../zone-editor'
/** Miniature render of a layout tree — the preset card thumbnail. */
function TreeThumbnail({ node }: { node: LayoutNode }) {
if (node.type === 'group') {
return (
// currentColor-derived fill: light zones on dark themes, dark zones on
// light — legible everywhere without leaning on the accent.
<div
className="min-h-0 min-w-0 flex-1 rounded-[2px]"
style={{ background: 'color-mix(in srgb, currentColor 16%, transparent)' }}
/>
)
}
return (
<div className={cn('flex min-h-0 min-w-0 flex-1 gap-px', node.orientation === 'row' ? 'flex-row' : 'flex-col')}>
{node.children.map((child, i) => (
<div
className="flex min-h-0 min-w-0"
key={child.id}
style={{ flex: `${node.weights[i]} ${node.weights[i]} 0px` }}
>
<TreeThumbnail node={child} />
</div>
))}
</div>
)
}
/** Small-caps section heading — the app's SidebarPanelLabel voice. */
function PickerSectionLabel({ children }: { children: ReactNode }) {
return (
<span className="text-[0.6rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-quaternary)">
{children}
</span>
)
}
function PresetCard({ preset }: { preset: Contribution }) {
const { t } = useI18n()
const activeId = useStore($activePresetId)
const tree = isLayoutNode(preset.data) ? preset.data : null
if (!tree) {
return null
}
const active = preset.id === activeId
return (
<div className="group/preset relative">
<button
className={cn(
'flex w-full flex-col gap-1.5 rounded-lg border p-1.5 text-left transition-colors',
active
? 'border-(--ui-accent) bg-(--ui-row-active-background)'
: 'border-(--ui-stroke-secondary) hover:border-(--ui-stroke-primary) hover:bg-(--ui-row-hover-background)'
)}
onClick={() => applyLayoutPreset(preset.id, tree)}
type="button"
>
<div className="flex h-12 w-full">
<TreeThumbnail node={tree} />
</div>
<span
className={cn('truncate text-[0.68rem] font-medium', active ? 'text-foreground' : 'text-muted-foreground/80')}
>
{preset.title ?? preset.id}
</span>
</button>
{isUserPreset(preset.id) && (
<button
aria-label={t.zones.deletePreset(preset.title ?? preset.id)}
// Hover-reveal (opacity, not display) — stays laid out + clickable,
// appears on card hover or keyboard focus.
className="absolute right-1 top-1 z-10 grid size-5 place-items-center rounded-md bg-(--ui-bg-elevated) text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/preset:opacity-100"
onClick={() => deleteUserPreset(preset.id)}
onPointerDown={e => e.stopPropagation()}
type="button"
>
<Codicon name="close" size="0.7rem" />
</button>
)}
</div>
)
}
export function LayoutPicker() {
const { t } = useI18n()
const presets = useContributions(LAYOUTS_AREA)
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
const templates = presets.filter(p => !isUserPreset(p.id) && isLayoutNode(p.data))
const custom = presets.filter(p => isUserPreset(p.id) && isLayoutNode(p.data))
const commitSave = () => {
if (!name.trim()) {
return
}
saveCurrentLayoutAs(name)
setName('')
setSaving(false)
}
return (
<div className="flex flex-col gap-4">
<section className="flex flex-col gap-2">
<PickerSectionLabel>{t.zones.templates}</PickerSectionLabel>
<div className="grid grid-cols-4 gap-2">
{templates.map(preset => (
<PresetCard key={`${preset.source ?? 'core'}:${preset.id}`} preset={preset} />
))}
</div>
</section>
<section className="flex flex-col gap-2">
<PickerSectionLabel>{t.zones.custom}</PickerSectionLabel>
{custom.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{custom.map(preset => (
<PresetCard key={`${preset.source ?? 'core'}:${preset.id}`} preset={preset} />
))}
</div>
)}
<Button
className="h-8 w-full justify-center gap-1.5 border border-dashed border-(--ui-stroke-secondary) text-muted-foreground hover:border-(--ui-stroke-primary) hover:text-foreground"
onClick={() => $zoneEditorOpen.set(true)}
size="sm"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
{t.zones.newGridLayout}
</Button>
</section>
{/* Save-current lives behind a reveal so the raw input doesn't clash
with the card grid until it's actually needed. */}
{saving ? (
<form
className="flex items-center gap-1.5"
onSubmit={e => {
e.preventDefault()
commitSave()
}}
>
<Input
autoFocus
className="h-7 flex-1 text-xs"
onChange={e => setName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') {
setSaving(false)
setName('')
}
}}
placeholder={t.zones.nameLayoutPlaceholder}
value={name}
/>
<Button disabled={!name.trim()} size="sm" type="submit" variant="outline">
{t.common.save}
</Button>
<Button onClick={() => setSaving(false)} size="sm" variant="ghost">
{t.common.cancel}
</Button>
</form>
) : (
<button
className="flex items-center gap-1.5 self-start text-xs text-muted-foreground/80 transition-colors hover:text-foreground"
onClick={() => setSaving(true)}
type="button"
>
<Codicon name="save" size="0.8125rem" />
{t.zones.saveCurrentAs}
</button>
)}
</div>
)
}

View File

@ -0,0 +1,146 @@
/**
* Narrow-viewport edge overlays the tree's take on the app's hover-reveal
* collapse. Collapsible panes leave the grid below the sidebar-collapse
* breakpoint; an edge strip (hover) or PANE_TOGGLE_REVEAL_EVENT (B / G /
* titlebar toggles route here on narrow) slides the pane OVER the layout
* instead of squeezing it. Event reveals pin; hover reveals follow the mouse.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import type { Contribution } from '@/contrib/types'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
import { cn } from '@/lib/utils'
import { PANE_TOGGLE_REVEAL_EVENT } from '../..'
import { allPaneIds } from '../model'
import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store'
import { paneChrome } from './track-model'
export function NarrowOverlays() {
const narrow = useStore($narrowViewport)
const tree = useStore($layoutTree)
const panes = useContributions('panes')
const hiddenPanes = useStore($hiddenTreePanes)
const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null)
// Own an Escape layer only while something is revealed, so Escape closes the
// overlay only when it's the top layer (never under a dialog / edit mode).
const revealActive = reveal !== null
useEffect(() => (revealActive ? pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay) : undefined), [revealActive])
const inTree = useMemo(() => new Set(tree ? allPaneIds(tree) : []), [tree])
const collapsibles = useMemo(
() => panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id)),
[panes, inTree, hiddenPanes]
)
const collapsiblesRef = useRef(collapsibles)
collapsiblesRef.current = collapsibles
// ⌘B / ⌘G's narrow branch dispatches the app's toggle-reveal event with the
// REAL pane id — accept those via each contribution's revealAliases.
useEffect(() => {
if (!narrow) {
setReveal(null)
return
}
const onToggle = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string; mode?: 'close' | 'open' | 'toggle' }>).detail
const id = detail?.id
if (!id) {
return
}
const match = collapsiblesRef.current.find(p => p.id === id || paneChrome(p).revealAliases?.includes(id))
if (!match) {
return
}
// `open`/`close` are explicit intents (programmatic reveal, titlebar show);
// `toggle` (default) is the ⌘B/⌘G flip.
const mode = detail?.mode ?? 'toggle'
setReveal(current => {
if (mode === 'open') {
return { id: match.id, pinned: true }
}
if (mode === 'close') {
return current?.id === match.id ? null : current
}
return current?.id === match.id && current.pinned ? null : { id: match.id, pinned: true }
})
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)) {
return
}
event.preventDefault()
setReveal(null)
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
window.removeEventListener('keydown', onKeyDown)
}
}, [narrow])
if (!narrow || collapsibles.length === 0) {
return null
}
const sideOf = (c: Contribution) => (paneChrome(c).placement === 'left' ? 'left' : 'right')
const revealed = reveal ? collapsibles.find(p => p.id === reveal.id) : undefined
const sides = [...new Set(collapsibles.map(sideOf))]
return (
<>
{/* Hover-intent strips on each edge that has a collapsed pane. */}
{sides.map(side => (
<div
className={cn('absolute inset-y-0 z-30 w-1.5', side === 'left' ? 'left-0' : 'right-0')}
key={side}
onMouseEnter={() => {
const first = collapsibles.find(p => sideOf(p) === side)
if (first) {
setReveal(current => (current?.pinned ? current : { id: first.id, pinned: false }))
}
}}
/>
))}
{revealed && (
<div
className={cn(
'absolute inset-y-0 z-40 flex flex-col overflow-hidden bg-(--ui-sidebar-surface-background) shadow-2xl',
sideOf(revealed) === 'left'
? 'left-0 border-r border-(--ui-stroke-secondary)'
: 'right-0 border-l border-(--ui-stroke-secondary)'
)}
onMouseLeave={() => setReveal(current => (current?.pinned ? current : null))}
// Match the pane's docked width (sessions ~237px, files its rail
// width) instead of a fat fixed 20rem — capped for tiny screens.
style={{ width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` }}
>
<ContribBoundary id={revealed.id}>{revealed.render?.()}</ContribBoundary>
</div>
)}
</>
)
}

View File

@ -0,0 +1,260 @@
/**
* The TRACK MODEL how a layout node resolves its size along a split axis.
*
* A node is a FIXED track when it resolves to a CSS length (sidebars keep
* their declared size) and a FLEX track when it doesn't (weight-shared
* leftover). Everything here is pure geometry over the layout tree + the
* live pane contributions; the React split renderer reads it per render.
*/
import type * as React from 'react'
import type { Contribution } from '@/contrib/types'
import type { GroupNode, LayoutNode } from '../model'
import { allPaneIds } from '../model'
export const MIN_PANE_PX = 80
/** Optional CSS sizing a pane contributes (`data.width` / `data.minWidth`).
* Applied to the pane's GROUP along the axis of the split that contains it
* the same semantics as the app's `Pane width/minWidth/maxWidth` props:
* a `width`/`height` makes the zone a FIXED track (sidebar-style it keeps
* its size and the weighted zones absorb the rest); without one the zone
* shares leftover space by weight. */
export interface PaneSizing {
width?: string
height?: string
minWidth?: string
maxWidth?: string
minHeight?: string
maxHeight?: string
}
/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */
interface PaneChrome {
/** Leaves the grid on narrow viewports; revealed as an edge overlay. */
collapsible?: boolean
/** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane
* ids, e.g. `chat-sidebar` for `sessions`). */
revealAliases?: string[]
placement?: string
/** No Close in the tab menu the one surface the app can't lose (the
* main workspace). Session tiles share `placement: 'main'` but close. */
uncloseable?: boolean
/** Wrap this pane's TAB (e.g. in a domain context menu a session tile's
* pin/branch/rename/archive/delete). The wrapper must render `tab` as its
* interactive child; the zone's own strip menu still owns non-tab space. */
tabWrap?: (tab: React.ReactElement) => React.ReactNode
/** Suppress the zone header while THIS pane is active full-page views
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
* live: the workspace contribution re-registers it on route changes. */
headerVeto?: boolean
}
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome
/** Resolve a computed style length ("237px" / "none" / "auto") to px. */
export function computedPx(value: string, fallback: number): number {
const n = Number.parseFloat(value)
return Number.isFinite(n) ? n : fallback
}
/** Resolve an AUTHORED CSS length ("237px", "38vh", "clamp(18rem,36vw,32rem)")
* to px by measuring a probe inside `container` handles every unit and
* math function the browser does. */
export function resolveCssPx(container: HTMLElement, css: number | string, horizontal: boolean): number | null {
if (typeof css === 'number') {
return css
}
const probe = document.createElement('div')
probe.style.position = 'absolute'
probe.style.visibility = 'hidden'
probe.style.pointerEvents = 'none'
if (horizontal) {
probe.style.width = css
} else {
probe.style.height = css
}
container.appendChild(probe)
const rect = probe.getBoundingClientRect()
probe.remove()
const px = horizontal ? rect.width : rect.height
return Number.isFinite(px) && px > 0 ? px : null
}
/** Everything fixed-track resolution needs about the current view state. */
export interface TrackContext {
paneFor: (id: string) => Contribution | undefined
paneGone: (id: string) => boolean
overrides: Record<string, { widthOverride?: number; heightOverride?: number }>
}
/** A group's panes that are actually on screen (not hidden / narrow-collapsed
* / unregistered). The one place the "shown" filter lives. */
export const shownPaneIds = (group: GroupNode, ctx: TrackContext): string[] =>
group.panes.filter(id => !ctx.paneGone(id))
/** max() of the defined CSS lengths (deduped); undefined when none the
* largest-tenant basis a fixed stack and its clamps both size from. */
export const cssMax = (values: (string | null | undefined)[]): string | undefined => {
const unique = [...new Set(values.filter((v): v is string => Boolean(v)))]
return unique.length === 0 ? undefined : unique.length === 1 ? unique[0] : `max(${unique.join(', ')})`
}
/**
* THE TRACK MODEL. A node's size along `axis` is FIXED when it resolves to a
* CSS length, and FLEX (weight-shared leftover) when null:
*
* - zone: the max() of its shown panes' declared `width`/`height` (a live px
* override from a sash drag wins) sidebars keep their size, main flexes,
* and the zone never resizes when tabs switch or a drop fronts a pane.
* - split ALONG the axis: the sum of its visible children fixed only when
* every child is (one flex child makes the run flex).
* - split ACROSS the axis: the max of its visible fixed children (flex
* children just stretch to the container); flex only when none are fixed.
*
* This is how "two right sidebars over a terminal row" sizes itself from its
* content (237px, or 474px when review is visible) instead of taking a
* fraction of the window.
*/
/** A minimized zone IS its strip: the vertical rail (row) / header (column)
* are both 28px thick. */
export const MINIMIZED_TRACK = '1.75rem'
export function fixedTrackSize(node: LayoutNode, axis: 'row' | 'column', ctx: TrackContext): string | null {
if (node.type === 'group') {
// Ancestor splits must size a minimized zone as its strip, not as its
// panes' declared widths — otherwise the outer track keeps reserving the
// full sidebar width and the collapsed rail floats in a dead column.
if (node.minimized) {
return MINIMIZED_TRACK
}
const overrideKey = axis === 'row' ? 'widthOverride' : 'heightOverride'
const declared = (id: string) => {
const sizing = (ctx.paneFor(id)?.data ?? {}) as PaneSizing
const css = (axis === 'row' ? sizing.width : sizing.height) ?? null
const override = ctx.overrides[id]?.[overrideKey]
// An override only refines a pane that DECLARES a size along this axis
// (sash drags write overrides to fixed zones only). One without a
// declaration is stale data from another surface — honoring it would
// turn a flex-at-heart zone (main!) into a fixed track and hand the
// whole leftover to the run's absorber.
if (css !== null && override !== undefined) {
return `${override}px`
}
return css
}
// Which zones are FIXED tracks:
// - a MAIN-bearing zone (workspace/tile stacked in) is flex-at-heart —
// mixing a sidebar pane into it (files fronted in the Focus mono-stack)
// must NOT snap the whole zone to sidebar width;
// - any other zone stays fixed as long as SOME tenant declares a size —
// dropping a size-less pane (the terminal has height but no width)
// into the 237px files sidebar must not balloon it to a flex track.
const ids = shownPaneIds(node, ctx)
const sizes = ids.map(declared)
const declaredSizes = sizes.filter((size): size is string => size !== null)
if (declaredSizes.length === 0) {
return null
}
if (sizes.length !== declaredSizes.length && ids.some(id => paneChrome(ctx.paneFor(id)).placement === 'main')) {
return null
}
// A STACK sizes to its LARGEST tenant (CSS max()), never the active tab:
// dropping a pane into a zone — the drop fronts it — or switching tabs
// must not resize the container (dropping sessions into a wider fixed
// zone used to snap the whole zone down to sidebar width).
return cssMax(declaredSizes) ?? null
}
const visible = node.children.filter(child => !subtreeGone(child, ctx))
const sizes = visible.map(child => fixedTrackSize(child, axis, ctx))
if (node.orientation === axis) {
if (sizes.length === 0 || sizes.some(size => size === null)) {
return null
}
return sizes.length === 1 ? sizes[0] : `calc(${sizes.join(' + ')})`
}
// Across the axis a flex child just stretches; the fixed ones set the size.
return cssMax(sizes) ?? null
}
/** True when every pane in the subtree is hidden/narrow-collapsed. */
export function subtreeGone(node: LayoutNode, ctx: TrackContext): boolean {
const ids = allPaneIds(node)
return ids.length > 0 && ids.every(ctx.paneGone)
}
/**
* Which chrome toggle owns a root-row child SEMANTIC, not positional:
* B is the sessions/nav column (any `placement: 'left'` pane) wherever a
* flip or drag puts it; J is every other side column. `null` = contains
* the main zone, never side-collapsed. This is what keeps the titlebar
* toggles and reveals 100% main-compatible through \ flips.
*/
export function rootChildSide(
child: LayoutNode,
paneFor: (id: string) => Contribution | undefined
): 'left' | 'right' | null {
const placements = allPaneIds(child).map(id => paneChrome(paneFor(id)).placement)
if (placements.includes('main')) {
return null
}
return placements.includes('left') ? 'left' : 'right'
}
/**
* The FIXED zone that owns `edge` of this subtree along `axis` the zone a
* sash on that boundary actually resizes (dragging the seam between main and
* a nested right section resizes the section's edge sidebar, VS Code-style).
*/
export function edgeFixedZone(
node: LayoutNode,
edge: 'start' | 'end',
axis: 'row' | 'column',
ctx: TrackContext
): GroupNode | null {
if (node.type === 'group') {
return fixedTrackSize(node, axis, ctx) !== null ? node : null
}
const visible = node.children.filter(child => !subtreeGone(child, ctx))
if (node.orientation === axis) {
const child = edge === 'start' ? visible[0] : visible[visible.length - 1]
return child ? edgeFixedZone(child, edge, axis, ctx) : null
}
// Cross-axis: every child touches the edge — the first fixed one owns it.
for (const child of visible) {
const zone = edgeFixedZone(child, edge, axis, ctx)
if (zone) {
return zone
}
}
return null
}

View File

@ -0,0 +1,656 @@
/**
* Group node renderer a ZONE: header strip (tabs when stacked, minimize
* chevron) + the active pane's content, resolved from the contribution
* registry (`area: 'panes'`). Empty zones exist only in editor-authored
* trees (drop targets until the first structural op prunes them).
*
* Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and
* every zone lights up as a whole-region drop target. Right-click opens the
* contextual zone menu (split/move + header/minimize toggles).
*/
import { useStore } from '@nanostores/react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { DecodeText } from '@/components/ui/decode-text'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS, DropPill } from '@/components/ui/drop-affordance'
import { PANE_TAB_STRIP_LINE, PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $layoutEditMode } from '../../edit-mode'
import { useWindowControlsOverlap } from '../../geometry'
import type { DropPosition, GroupNode, RootEdge } from '../model'
import { adjacentGroup } from '../model'
import {
$dropHint,
$hiddenTreePanes,
$layoutTree,
$narrowViewport,
$treeDragging,
activateTreePane,
closeTreePane,
moveTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
splitTreeZone,
toggleTreeGroupMinimized
} from '../store'
import { type DoubleTapContext, startPaneDrag } from './drag-session'
import { paneChrome } from './track-model'
/** A directional action in the zone menu (computed per group state). */
interface ZoneMenuDirection {
side: RootEdge
label: string
run: () => void
}
const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top']
const DIRECTION_ARROW: Record<RootEdge, string> = { bottom: '↓', left: '←', right: '→', top: '↑' }
/** Right-click zone menu: directional actions + header toggle + minimize.
* The directions are CONTEXTUAL (computed by TreeGroup): a stacked group
* offers "Split <dir>" (carve a new zone with the clicked pane VS Code
* split-and-move in one gesture); a single-pane group offers "Move <dir>"
* into the zone actually sitting on that side directions with no visible
* neighbor aren't offered, so no action ever appears to do nothing. */
function ZoneMenu({
children,
closable,
minimizable = true,
directions,
headerHidden,
minimized,
nodeId
}: {
children: ReactNode
/** The pane the menu closes (the right-clicked chip / the active pane);
* undefined = not closable (the main zone). */
closable?: () => string | undefined
/** False for the zone hosting the uncloseable workspace collapsing the
* MAIN pane strands the app behind a strip. */
minimizable?: boolean
directions: ZoneMenuDirection[]
headerHidden?: boolean
minimized?: boolean
nodeId: string
}) {
const { t } = useI18n()
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{directions.map(direction => (
<ContextMenuItem key={direction.side} onSelect={direction.run}>
{direction.label}
</ContextMenuItem>
))}
<ContextMenuItem onSelect={() => setTreeGroupHeaderHidden(nodeId, !headerHidden)}>
{headerHidden ? t.zones.showHeader : t.zones.hideHeader}
</ContextMenuItem>
{minimizable && (
<ContextMenuItem onSelect={() => toggleTreeGroupMinimized(nodeId, !minimized)}>
{minimized ? t.zones.restore : t.zones.minimize}
</ContextMenuItem>
)}
{/* Resolved at render: the menu mounts on open, after the right-click
set menuPane so an uncloseable target hides the item instead
of offering a dead action. */}
{closable?.() !== undefined && (
<ContextMenuItem
onSelect={() => {
const paneId = closable?.()
if (paneId) {
closeTreePane(paneId)
}
}}
>
{t.common.close}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
}
export function TreeGroup({
node,
parentAxis,
railSide = 'left'
}: {
node: GroupNode
parentAxis?: 'column' | 'row'
railSide?: 'left' | 'right'
}) {
const { t } = useI18n()
const ref = useRef<HTMLDivElement>(null)
const stripRef = useRef<HTMLDivElement>(null)
// The chip under the last right-click — the pane the zone menu's Split
// actions carry into the new zone (header background = the active pane).
// STATE, not a ref: the menu items (incl. Close's visibility) are JSX
// evaluated during THIS component's render — a ref write on right-click
// doesn't re-render, so the menu showed the PREVIOUS target's items (Close
// missing on an inactive tile tab whose zone-active was the uncloseable
// workspace).
const [menuPane, setMenuPane] = useState<string | undefined>(undefined)
const panes = useContributions('panes')
// Coarse drag flag only (set once at drag start/end). The per-frame drop
// HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny
// overlay, not every zone's header/body (and not the menuDirections walk).
const dragging = useStore($treeDragging)
const editMode = useStore($layoutEditMode)
const wcOverlap = useWindowControlsOverlap(ref, true)
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
const paneFor = (id: string) => panes.find(p => p.id === id)
// Unregistered (plugin not loaded), chrome-toggled-off, and narrow-collapsed
// panes drop out of the header; the active pane falls back to the first
// shown one (render-side — the tree keeps `active`).
// Edit mode forces toggle-hidden panes visible so they can be rearranged
// (mirrors tree-split's paneGone) — restores itself on exit.
const paneShown = (id: string) =>
Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible)
const shown = node.panes.filter(paneShown)
const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active)
const active = paneFor(activeId)
const isEmpty = node.panes.length === 0
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
// chips. EXCEPTION: a lone TILE (closeable, placement 'main' — a session/page
// split) always shows its header, so it has a tab + close X — a tile in its
// own zone was otherwise unclosable (the "3rd tile has no tab" trap). Chrome
// panes (sessions/files/terminal…) and the uncloseable workspace keep the
// clean no-tab default. Double-click toggles it either way; a minimized
// group always shows its header (it IS the header).
const hasLoneTile = shown.some(id => {
const chrome = paneChrome(paneFor(id))
return !chrome.uncloseable && chrome.placement === 'main'
})
// A full-page view (headerVeto) suppresses the strip while it's the active
// pane — a page is not a tab-able surface; the bar returns with the chat.
const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !hasLoneTile))
// A group collapses ALONG its parent split's axis. In a row that means the
// WIDTH collapses — a full-width horizontal header would strand a tall
// empty column, so the minimized form is a narrow vertical rail instead
// (tabs reading top-to-bottom). In a column (stacked zones) the horizontal
// header IS the collapsed form, exactly as before.
const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden)
// Drag handles preventDefault pointerdown (no native dblclick), so the
// header + chips share a synthesized double-tap: restore if collapsed
// (undoing the first tap's minimize toggle) and hide the chrome.
const hideHeaderDoubleTap: DoubleTapContext = {
key: `hide-header-${node.id}`,
onDoubleTap: () => {
toggleTreeGroupMinimized(node.id, false)
setTreeGroupHeaderHidden(node.id, true)
}
}
const dirWord: Record<RootEdge, string> = {
bottom: t.zones.dirDown,
left: t.zones.dirLeft,
right: t.zones.dirRight,
top: t.zones.dirUp
}
// Zone-menu directions, contextual to this group's state:
// - stacked panes -> "Split <dir>": carve a new zone on that side with the
// right-clicked chip's pane in it (split + move, one gesture);
// - a single pane -> "Move <dir>": join the zone visually adjacent on that
// side (splitting here would only make an invisible empty zone). Sides
// with no visible neighbor are omitted entirely.
const tree = useStore($layoutTree)
const menuDirections: ZoneMenuDirection[] =
shown.length > 1
? DIRECTION_ORDER.map(side => ({
side,
label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => splitTreeZone(node.id, side, menuPane ?? activeId)
}))
: DIRECTION_ORDER.flatMap(side => {
const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null
if (!neighbor || neighbor.id === node.id) {
return []
}
return [
{
side,
label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`,
run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' })
}
]
})
// Close targets the right-clicked chip (falling back to the active pane);
// only panes that declare `uncloseable` (the main workspace) are exempt.
const closable = () => {
const paneId = menuPane ?? activeId
return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId
}
// The zone hosting the uncloseable workspace never minimizes — collapsing
// MAIN strands the whole app behind a strip.
const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable)
// Same menu on the header strip and the edit veil — one prop bag.
const zoneMenu = {
closable,
directions: menuDirections,
headerHidden,
minimizable,
minimized: node.minimized,
nodeId: node.id
}
// NO body double-click toggle: virtualized content (the thread) recreates
// its nodes between clicks, so the gesture was hopelessly unreliable. The
// bar's lifecycle is explicit instead — gaining a tab sticky-shows it
// (insertAtGroup pins headerHidden false), the main tab's context menu
// hides it, and full-page views veto it via paneChrome.headerVeto.
return (
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-bg-editor)"
data-tree-group={node.id}
// Advertises the visible tab strip so panes can drop their own
// self-naming labels (see [data-pane-self-label] in styles.css).
data-zone-header={headerVisible || undefined}
ref={ref}
style={wcOverlap ? { paddingTop: wcOverlap.y + wcOverlap.height } : undefined}
>
{wcOverlap && (
<div
aria-hidden="true"
className="pointer-events-none absolute z-10 [-webkit-app-region:drag]"
style={{ height: wcOverlap.height, left: wcOverlap.x, top: wcOverlap.y, width: wcOverlap.width }}
/>
)}
{/* Minimized in a ROW: a narrow vertical rail same PaneTab shell as
the horizontal strip, just `vertical`. Click a tab to restore +
activate; click anywhere else on the rail to restore. */}
{verticalCollapse && (
<ZoneMenu {...zoneMenu}>
<div
className={cn(
'flex h-full w-7 shrink-0 cursor-pointer select-none flex-col items-stretch bg-(--pane-tab-strip-bg) [--pane-tab-strip-bg:var(--theme-card-seed)]',
// Strip line faces the content the zone collapsed away from.
railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT
)}
onClick={() => toggleTreeGroupMinimized(node.id, false)}
title={t.zones.restore}
>
<div
className="flex min-h-0 flex-col overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{shown.map(paneId => {
const closeable = !paneChrome(paneFor(paneId)).uncloseable
const title = paneFor(paneId)?.title ?? paneId
return (
<PaneTab
// Match the horizontal minimized strip: no tab is "active"
// while collapsed (there's no content surface to merge into).
aria-selected={paneId === activeId}
data-tree-tab={paneId}
key={paneId}
onClick={event => {
event.stopPropagation()
toggleTreeGroupMinimized(node.id, false)
activateTreePane(node.id, paneId)
}}
onClose={closeable ? () => closeTreePane(paneId) : undefined}
role="tab"
side={railSide}
vertical
>
<PaneTabLabel>{title}</PaneTabLabel>
</PaneTab>
)
})}
</div>
</div>
</ZoneMenu>
)}
{/* Header: the file-preview tab strip (PaneTab), one shared component. */}
{headerVisible && (
<ZoneMenu {...zoneMenu}>
<div
// Active = sidebar surface (merges into body). Strip =
// `--theme-card-seed` (VS Code `tab.inactiveBackground`). Line =
// PANE_TAB_STRIP_LINE; active tab cuts through it.
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
className={cn(
'group/pane-header relative flex h-7 shrink-0 select-none bg-(--pane-tab-strip-bg) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)] [--pane-tab-strip-bg:var(--theme-card-seed)]',
PANE_TAB_STRIP_LINE
)}
data-zone-tabstrip={node.id}
onContextMenu={e => {
setMenuPane((e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined)
}}
onPointerDown={e =>
// Tap the header to collapse to it / expand back — the DetailPane
// / sidebar-section gesture (never for the main zone). Double-tap
// hides the header entirely. Drag still moves the pane.
startPaneDrag(
activeId,
e,
() => minimizable && toggleTreeGroupMinimized(node.id, !node.minimized),
undefined,
hideHeaderDoubleTap
)
}
ref={stripRef}
style={{ cursor: 'grab' }}
>
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{shown.map(paneId => {
const isActive = paneId === activeId && !node.minimized
const chrome = paneChrome(paneFor(paneId))
const closeable = !chrome.uncloseable
const title = paneFor(paneId)?.title ?? paneId
const tab = (
<PaneTab
active={isActive}
aria-selected={isActive}
data-tree-tab={paneId}
key={paneId}
onClose={closeable ? () => closeTreePane(paneId) : undefined}
onPointerDown={e =>
startPaneDrag(
paneId,
e,
() => {
// Tabs ACTIVATE (restoring a collapsed group).
// Minimize lives on the chevron / single-pane label
// — overloading the active tab made double-click a
// minimize/restore/hide lottery.
if (node.minimized) {
toggleTreeGroupMinimized(node.id, false)
}
activateTreePane(node.id, paneId)
},
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
hideHeaderDoubleTap
)
}
role="tab"
style={{ cursor: 'grab' }}
>
<PaneTabLabel>{title}</PaneTabLabel>
</PaneTab>
)
// A pane may wrap ITS tab in a domain menu (session verbs on a
// tile tab); the wrapper needs the key since it's the root.
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
})}
</div>
{minimizable && (
<button
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
className="mx-1 grid size-5 shrink-0 place-items-center self-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
onClick={() => toggleTreeGroupMinimized(node.id, !node.minimized)}
onPointerDown={e => e.stopPropagation()}
type="button"
>
<Codicon name={node.minimized ? 'chevron-down' : 'chevron-up'} size="0.75rem" />
</button>
)}
<StripDropCaret groupId={node.id} stripRef={stripRef} />
</div>
</ZoneMenu>
)}
{/* Body: the active pane's contributed content, or the empty zone. */}
{!node.minimized && (
<div className="relative min-h-0 min-w-0 flex-1 overflow-auto">
{isEmpty ? (
<div className="grid h-full place-items-center">
{/* Same decode primitive as the CONNECTING boot overlay. */}
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
) : active?.render ? (
<ContribBoundary id={active.id}>{active.render()}</ContribBoundary>
) : (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div>
)}
</div>
)}
{/* Edit-mode veil: the BODY is a drag handle for the active pane. It
starts below the header so tabs/headers stay directly interactive
(drag any tab, right-click for the zone menu). */}
{editMode && !dragging && !isEmpty && !node.minimized && (
<ZoneMenu {...zoneMenu}>
<div
// z-50: pane CONTENT may carry its own stacked chrome (the
// terminal rail is z-40) — the edit veil must cover all of it.
// The scrim mixes the accent over the CHROME BG (not transparent)
// so it properly dims content in dark themes instead of leaving a
// barely-tinted wash; the light blur reads as "edit mode" the same
// way the zone editor's backdrop does.
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
onPointerDown={e => startPaneDrag(activeId, e)}
style={{
top: headerVisible ? 28 : 0,
background:
'color-mix(in srgb, var(--ui-accent) 6%, color-mix(in srgb, var(--ui-bg-chrome) 55%, transparent))',
outlineColor: 'color-mix(in srgb, var(--ui-accent) 55%, transparent)'
}}
>
<span className="flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-md border border-(--ui-stroke-secondary) bg-popover px-2 py-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-secondary)">
<Codicon className="shrink-0" name="gripper" size="0.8125rem" />
<span className="min-w-0 truncate">{active?.title ?? activeId}</span>
</span>
</div>
</ZoneMenu>
)}
{/* FancyZones drop overlay its own component so the per-frame drop
hint re-renders only this (tiny) node, not the whole zone. */}
<ZoneDropOverlay isEmpty={isEmpty} node={node} />
</div>
)
}
// ---------------------------------------------------------------------------
// Tab-strip insertion caret
// ---------------------------------------------------------------------------
/**
* The insertion divider for a stack drop: a 2px vertical line at the slot the
* dragged tab will land in (before `stack.before`, or after the last tab).
* Absolute over the strip pure overlay, zero layout shift. #000 on light,
* #FFF on dark. Split out so per-pointermove `$dropHint` churn re-renders
* only this node (same isolation contract as ZoneDropOverlay).
*/
function StripDropCaret({ groupId, stripRef }: { groupId: string; stripRef: RefObject<HTMLDivElement | null> }) {
const hint = useStore($dropHint)
const strip = stripRef.current
const stack = hint?.groupId === groupId ? hint.stack : undefined
if (stack === undefined || !strip) {
return null
}
// Slot x: the before-tab's left edge, or the last tab's right edge.
const tabs = [...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
const target = stack.before ? tabs.find(el => el.dataset.treeTab === stack.before) : tabs.at(-1)
if (!target) {
return null
}
const stripRect = strip.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const x = (stack.before ? targetRect.left : targetRect.right) - stripRect.left
// A short centered tick (~60% of the tab), not a full-height wall — reads
// as an insertion point between labels, browser-tab style.
return (
<span
aria-hidden
className="pointer-events-none absolute z-50 w-px -translate-x-1/2 bg-black dark:bg-white"
style={{
height: targetRect.height * 0.6,
left: x,
top: targetRect.top - stripRect.top + targetRect.height * 0.2
}}
/>
)
}
// ---------------------------------------------------------------------------
// FancyZones drop overlay
// ---------------------------------------------------------------------------
/** Overlay entry fade. FancyZones ships 200ms (FADE_IN_DURATION_MILLIS in
* zones-engine); on a drag that starts under the cursor that ramp reads as
* lag, so the sheets snap in far faster same softening, instant feel. */
const OVERLAY_FADE_MS = 80
/** Sheet inset from the zone edge (px). */
const REGION_PAD = 6
/** The sheet's box per drop position longhand insets so CSS transitions can
* interpolate the px% change: the target GLIDES between the full zone and
* the hovered half instead of snapping (VS Code dock preview). */
const REGION: Record<DropPosition, CSSProperties> = {
bottom: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: '50%' },
center: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: REGION_PAD },
left: { bottom: REGION_PAD, left: REGION_PAD, right: '50%', top: REGION_PAD },
right: { bottom: REGION_PAD, left: '50%', right: REGION_PAD, top: REGION_PAD },
top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }
}
const EDGE_ARROW: Record<Exclude<DropPosition, 'center'>, string> = {
bottom: 'arrow-down',
left: 'arrow-left',
right: 'arrow-right',
top: 'arrow-up'
}
/**
* The FancyZones drop overlay for one zone. Split out of TreeGroup so the
* per-pointermove `$dropHint` churn re-renders only this lightweight node
* the zone's header, body, and menu-direction walk stay put during a drag.
*
* ONE dashed sheet per zone, in the attachment dropzone's design language
* (DROP_SHEET_CLASS + DropPill the composer drop and the zone targets speak
* identically): a quiet outline over every eligible zone, accent-lit over the
* target, morphing to the hovered half for an edge split. The pill names the
* outcome; edges get their arrow.
*/
function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode }) {
const { t } = useI18n()
const dragging = useStore($treeDragging)
const hint = useStore($dropHint)
if (dragging === null) {
return null
}
// A session drag (sidebar row) reuses this exact overlay — over ANY zone
// now (stack into its tabs / split its edges); only a CHAT zone's center is
// a link-to-chat (the composer overlay owns that visual).
const sessionDrag = dragging === SESSION_TILE_DRAG
const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:'))
const isDragSource = node.panes.includes(dragging)
// The source zone, when it holds only the dragged pane, has nothing to drop.
if (isDragSource && node.panes.length === 1) {
return null
}
const primary = hint?.groupId === node.id
// Hovering the target's TAB STRIP: the insertion caret (StripDropCaret)
// owns the affordance — the zone sheet stands down so the two never stack.
if (primary && hint?.stack !== undefined) {
return null
}
const active = hint?.groupIds?.includes(node.id) ?? false
const multi = (hint?.groupIds?.length ?? 0) > 1
// Sub-positions only exist for a single-zone target (a Shift-span merges).
const pos = primary && !multi ? (hint?.pos ?? 'center') : 'center'
// Session drag over a CHAT zone's CENTER: the "link to chat" overlay inside
// the surface (ChatDropOverlay — the same sheet + pill) owns that region;
// this sheet fades out so the two never stack. A non-chat zone's center has
// no chat to link, so it shows the normal stack sheet. Edges act like a tab.
const centerLink = sessionDrag && primary && pos === 'center' && chatZone
const pill =
!primary || centerLink
? null
: multi
? { icon: 'combine', label: t.zones.spanHere }
: pos !== 'center'
? { icon: EDGE_ARROW[pos], label: sessionDrag ? t.zones.openHere : t.zones.splitHere }
: isDragSource
? { icon: 'discard', label: t.zones.staysHere }
: { icon: 'layers', label: isEmpty ? t.zones.moveHere : t.zones.stackHere }
return (
<div
className="pointer-events-none absolute inset-0 z-40"
style={{ animation: `hermes-zone-fade ${OVERLAY_FADE_MS}ms linear both` }}
>
<div
className={cn(
DROP_SHEET_CLASS,
// Transition ONLY the box + colors. `transition-all` also animated
// backdrop-filter, and a blur interpolating while the insets glide
// re-blurs half a zone every frame — the single most expensive
// paint in the whole drag.
'absolute flex items-center justify-center transition-[top,right,bottom,left,background-color,border-color,opacity] duration-150 ease-out',
// Blur only the live target — idle outlines must not fog the app.
active && !centerLink && DROP_SHEET_BLUR_CLASS,
centerLink && 'opacity-0'
)}
style={{
...REGION[pos],
// Accent over a card wash so the fill dims content on dark themes
// (a bare accent alpha disappears there).
background: active
? 'color-mix(in srgb, var(--ui-accent) 18%, color-mix(in srgb, var(--dt-card) 55%, transparent))'
: 'color-mix(in srgb, var(--ui-accent) 5%, color-mix(in srgb, var(--dt-card) 25%, transparent))',
borderColor: `color-mix(in srgb, var(--ui-accent) ${active ? 75 : 28}%, transparent)`
}}
>
{pill && <DropPill icon={pill.icon}>{pill.label}</DropPill>}
</div>
</div>
)
}

View File

@ -0,0 +1,28 @@
import type { LayoutNode } from '../model'
import { TreeGroup } from './tree-group'
import { TreeSplit } from './tree-split'
/** Dispatch a layout node to its renderer the split/group recursion point.
* `root` marks the tree's top split (side collapse applies only there).
* `parentAxis` is the containing split's orientation a group collapses
* ALONG that axis, so it picks the minimized form (row vertical rail,
* column horizontal header). `railSide` is which half of that row the
* child sits in the rail's divider stroke faces the content side. */
export function TreeNode({
node,
parentAxis,
railSide,
root
}: {
node: LayoutNode
parentAxis?: 'column' | 'row'
railSide?: 'left' | 'right'
root?: boolean
}) {
return node.type === 'split' ? (
<TreeSplit node={node} root={root} />
) : (
<TreeGroup node={node} parentAxis={parentAxis} railSide={railSide} />
)
}

View File

@ -0,0 +1,518 @@
/**
* Split node renderer a flex row/column whose 1px seams double as resize
* sashes (the seam IS the boundary junction-owned, never doubled). Sizing
* is the TRACK MODEL (track-model.ts): fixed tracks keep their declared size,
* flex tracks share the leftover by weight, and an all-fixed run lets its
* last track absorb the slack (VS Code style).
*/
import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
import { useContributions } from '@/contrib/react/use-contributions'
import { cn } from '@/lib/utils'
import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
import { $layoutEditMode } from '../../edit-mode'
import type { LayoutNode, SplitNode } from '../model'
import { allPaneIds } from '../model'
import {
$collapsedTreeSides,
$hiddenTreePanes,
$narrowViewport,
persistTree,
presetSplitWeights,
setTreeSplitWeights
} from '../store'
import {
computedPx,
cssMax,
edgeFixedZone,
fixedTrackSize,
MIN_PANE_PX,
paneChrome,
type PaneSizing,
resolveCssPx,
rootChildSide,
shownPaneIds,
subtreeGone,
type TrackContext
} from './track-model'
import { TreeNode } from './tree-node'
/**
* The size overrides for a fixed set of panes, referentially stable until one
* of THEM changes. Sash drags churn `$paneStates` every frame; subscribing the
* whole map would re-render every split this narrows each split to its own
* subtree via a signature-gated snapshot.
*/
function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['overrides'] {
const key = paneIds.join(',')
const cache = useRef<{ sig: string; value: Record<string, PaneStateSnapshot> }>({ sig: '\0', value: {} })
const snapshot = useCallback(() => {
const all = $paneStates.get()
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
if (cache.current.sig !== sig) {
cache.current = { sig, value: Object.fromEntries(paneIds.flatMap(id => (all[id] ? [[id, all[id]]] : []))) }
}
return cache.current.value
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key])
return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot)
}
export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const panes = useContributions('panes')
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
// Scoped to THIS subtree's panes: a sash drag writes size overrides on every
// pointermove, but only the splits whose subtree actually resized should
// re-render — not every split in the tree.
const overrides = useSubtreeOverrides(useMemo(() => allPaneIds(node), [node]))
const editMode = useStore($layoutEditMode)
const collapsedSides = useStore($collapsedTreeSides)
const horizontal = node.orientation === 'row'
const axis = node.orientation
// A pane leaves the grid when its contribution isn't registered (yet) — a
// runtime plugin's pane collapses until the plugin loads, then appears; no
// placeholder flash — when a chrome toggle hides it, or when the viewport
// is narrow and the pane is collapsible (edge overlay instead).
const paneFor = (id: string) => panes.find(p => p.id === id)
// Layout-edit mode forces toggle-hidden panes (terminal off, review/preview
// closed) visible so they're rearrangeable — only truly-absent (unregistered)
// or narrow-collapsed panes stay gone. Restores itself on exit (render-only).
const paneGone = (id: string) =>
!paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible))
const trackCtx: TrackContext = { paneFor, paneGone, overrides }
// Chrome-toggle collapse: a subtree whose every pane is gone renders
// display:none (content stays MOUNTED — toggling back is instant), and its
// siblings absorb the space. Narrow-collapse UNMOUNTS instead, so the edge
// overlay owns the single live instance of the pane's content.
// EMPTY zones only exist in editor-authored trees (normalize prunes them on
// every structural op) — they take space in edit mode as drop targets.
const isEmptyZone = (child: LayoutNode) => child.type === 'group' && child.panes.length === 0
const isCollapsed = (child: LayoutNode) => subtreeGone(child, trackCtx) || (isEmptyZone(child) && !editMode)
// Min/max clamps come from a direct GROUP child's panes (the same clamps
// the app's Pane props express) — but ONLY when they can speak for the
// zone: a fixed track (pure sidebar stack) or a single-pane zone. A sidebar
// pane fronted in a mixed flex stack must not cap it. A fixed STACK
// aggregates its panes' clamps (largest-tenant semantics, mirroring the
// max() track basis) — the active tab's caps must never resize the zone.
const sizingFor = (child: LayoutNode, track: string | null): PaneSizing | null => {
if (child.type !== 'group' || child.panes.length === 0) {
return null
}
const shownIds = shownPaneIds(child, trackCtx)
if (track === null && shownIds.length !== 1) {
return null
}
if (shownIds.length <= 1) {
return (paneFor(shownIds[0])?.data as PaneSizing | undefined) ?? null
}
// Fixed STACK: floors take the largest declared min; caps stay unbounded
// unless EVERY pane declares one (a single uncapped tenant uncaps the
// zone). Same largest-tenant basis as the track size — never per-tab.
const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing)
const cap = (pick: (s: PaneSizing) => string | undefined) =>
all.every(pick) ? cssMax(all.map(pick)) : undefined
return {
minWidth: cssMax(all.map(s => s.minWidth)),
maxWidth: cap(s => s.maxWidth),
minHeight: cssMax(all.map(s => s.minHeight)),
maxHeight: cap(s => s.maxHeight)
}
}
// Sashes pair each visible child with its nearest visible PREVIOUS sibling
// (`aIndex`/`bIndex`), not blindly `i-1`/`i` — a collapsed zone in between
// (e.g. the closed preview pane parked between main and the right rail)
// must not swallow the seam its visible neighbors share.
const startSash = useCallback(
(aIndex: number, bIndex: number, e: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
if (!container || e.button !== 0) {
return
}
e.preventDefault()
const handle = e.currentTarget
const { pointerId } = e
const rect = container.getBoundingClientRect()
const totalPx = horizontal ? rect.width : rect.height
const totalWeight = node.weights.reduce((a, b) => a + b, 0) || 1
const pxPerWeight = totalPx / totalWeight
const start = horizontal ? e.clientX : e.clientY
const restoreCursor = document.body.style.cursor
const restoreSelect = document.body.style.userSelect
// Each side of the seam resolves to a RESIZE TARGET: a fixed zone (the
// sash writes its px override — sidebar semantics) or the flex run
// (the sash writes weights). Sizes/clamps read from the live DOM of
// whichever element actually owns the boundary.
const sizeOf = (el: HTMLElement) => {
const r = el.getBoundingClientRect()
return horizontal ? r.width : r.height
}
const sideFor = (child: LayoutNode, wrapper: HTMLElement, edge: 'start' | 'end') => {
const fixed = fixedTrackSize(child, axis, trackCtx) !== null
const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null
const zoneEl = zone ? container.querySelector<HTMLElement>(`[data-tree-group="${zone.id}"]`) : null
// Clamps live on the zone's split-child WRAPPER (where we render them).
const el = zoneEl?.parentElement ?? wrapper
const cs = window.getComputedStyle(el)
return {
// EVERY shown pane of the zone: the zone's track is the max() of its
// panes' sizes, so the sash writes the same px to all of them —
// writing only the active pane would leave the zone pinned at a
// larger sibling's width.
paneIds: zone ? shownPaneIds(zone, trackCtx) : [],
fixed: Boolean(zone),
size: sizeOf(zoneEl ?? wrapper),
min: Math.max(MIN_PANE_PX, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)),
max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY)
}
}
const kidA = container.children[aIndex] as HTMLElement | undefined
const kidB = container.children[bIndex] as HTMLElement | undefined
if (!kidA || !kidB) {
return
}
const a = sideFor(node.children[aIndex], kidA, 'end')
const b = sideFor(node.children[bIndex], kidB, 'start')
const a0px = a.fixed ? a.size : sizeOf(kidA)
const b0px = b.fixed ? b.size : sizeOf(kidB)
const lo = Math.max(a.min - a0px, b0px - b.max)
const hi = Math.min(a.max - a0px, b0px - b.min)
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
try {
handle.setPointerCapture?.(pointerId)
} catch {
// Synthetic events.
}
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
const onMove = (ev: PointerEvent) => {
const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
if (a.fixed) {
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
}
if (b.fixed) {
b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx)))
}
if (!a.fixed && !b.fixed) {
const weights = [...node.weights]
// Convert the CLAMPED pixel sizes back to weights so the persisted
// weights always agree with what's on screen.
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
setTreeSplitWeights(node.id, weights)
}
}
const cleanup = () => {
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
try {
handle.releasePointerCapture?.(pointerId)
} catch {
// Mirror.
}
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', cleanup, true)
window.removeEventListener('pointercancel', cleanup, true)
persistTree()
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', cleanup, true)
window.addEventListener('pointercancel', cleanup, true)
},
// trackCtx is derived state rebuilt per render; the drag captures it once.
// eslint-disable-next-line react-hooks/exhaustive-deps
[axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes]
)
// Double-click a sash: every neighbor returns to its DEFAULT size.
// - fixed zones (sidebar stacks): clear the drag override -> the declared
// width (237px etc.) comes back;
// - flex zones fronted by a size-declaring pane (a sidebar in a mixed
// stack): pin the weight so the zone lands EXACTLY on that size;
// - everything else: the preset's weights for this split (rearranging
// panes keeps the applied preset's split ids), else even distribution.
const resetBoundary = useCallback(
(aIndex: number, bIndex: number) => {
const container = containerRef.current
if (!container) {
return
}
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
for (const [child, edge] of [
[node.children[aIndex], 'end'],
[node.children[bIndex], 'start']
] as const) {
const zone = edgeFixedZone(child, edge, axis, trackCtx)
for (const paneId of zone ? shownPaneIds(zone, trackCtx) : []) {
setOverride(paneId, undefined)
}
}
const preset = presetSplitWeights(node.id, node.weights.length)
const weights = preset ?? [...node.weights]
const rect = container.getBoundingClientRect()
const totalPx = horizontal ? rect.width : rect.height
let pinned = false
for (const i of [aIndex, bIndex]) {
const child = node.children[i]
// Fixed tracks size themselves from the declared width (override
// cleared above) — weights only matter for FLEX zones.
if (child.type !== 'group' || fixedTrackSize(child, axis, trackCtx) !== null) {
continue
}
// The zone's natural default = the largest size any of its panes
// declares along this axis (a sessions+terminal stack is still a
// 237px sidebar at heart, whichever chip is fronted).
let px: number | null = null
for (const paneId of shownPaneIds(child, trackCtx)) {
const sizing = (paneFor(paneId)?.data ?? {}) as PaneSizing
const css = horizontal ? sizing.width : sizing.height
const resolved = css ? resolveCssPx(container, css, horizontal) : null
if (resolved !== null) {
px = Math.max(px ?? 0, resolved)
}
}
if (px === null || px <= 0 || px >= totalPx) {
continue
}
const others = weights.reduce((sum, w, j) => (j === i ? sum : sum + w), 0)
if (others > 0) {
weights[i] = (px * others) / (totalPx - px)
pinned = true
}
}
setTreeSplitWeights(node.id, !preset && !pinned ? weights.map(() => 1) : weights)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes]
)
// A run of ONLY fixed tracks can't fill the container (grow-0 all around
// leaves dead space — e.g. terminal + logs split into two 38vh zones with
// the rail above them collapsed). The LAST visible track absorbs the
// leftover, VS Code style.
const isMinimized = (child: LayoutNode) => child.type === 'group' && Boolean(child.minimized)
// SEMANTIC side collapse (titlebar toggles / ⌘B / ⌘J): at the ROOT row,
// ⌘B owns the sessions column and ⌘J the other side columns — by pane
// placement, NOT position, so a ⌘\ flip moves the columns without
// rewiring the toggles (main parity). In edit mode sides stay visible.
const semanticSides = root && horizontal && collapsedSides.size > 0 && !editMode
const sideGone = (i: number) => {
if (!semanticSides) {
return false
}
const side = rootChildSide(node.children[i], paneFor)
return side !== null && collapsedSides.has(side)
}
// One pass per child: collapse/minimize state, resolved fixed track, clamps,
// and narrow-unmount flag. fixedTrackSize + subtreeGone each re-walk the
// subtree, so resolve them ONCE here instead of per read below.
const tracks = node.children.map((child, i) => {
const minimized = isMinimized(child)
const collapsed = isCollapsed(child) || sideGone(i)
const track = minimized || collapsed ? null : fixedTrackSize(child, axis, trackCtx)
const sizing = minimized || collapsed ? null : sizingFor(child, track)
// Narrow-collapse UNMOUNTS (the edge overlay owns the live instance) — but
// only for panes the breakpoint collapsed, not ones a chrome toggle hid.
const narrowCollapsed = narrow && collapsed && allPaneIds(child).some(id => !hiddenPanes.has(id))
return { child, collapsed, minimized, narrowCollapsed, sizing, track }
})
const growable = tracks.map((_, i) => i).filter(i => !tracks[i].collapsed && !tracks[i].minimized)
const allFixed = growable.length > 0 && growable.every(i => tracks[i].track !== null)
const absorberIndex = allFixed ? growable[growable.length - 1] : -1
// Weights are RATIOS, but CSS flex-grow is absolute: a run whose grows sum
// below 1 fills only that fraction of the leftover (normalize's flatten
// scales weights into the parent slot — a dock-split nested into an
// existing column can leave grow 0.5, i.e. dead space). Renormalize the
// flex run so its grows always sum to 1.
const flexTotal = growable.reduce((sum, i) => sum + (tracks[i].track === null ? node.weights[i] : 0), 0)
const grow = (i: number) => node.weights[i] / (flexTotal || 1)
// The seam partner for a visible child: the nearest VISIBLE previous
// sibling. Collapsed zones (a hidden pane parked mid-row) are skipped, so
// their visible neighbors keep a shared, draggable boundary.
const seamPartner = (i: number): number => {
for (let j = i - 1; j >= 0; j--) {
if (!tracks[j].collapsed) {
return j
}
}
return -1
}
// Which half of this row a visible child sits in — a minimized zone's rail
// hugs the app edge it collapsed toward, so its divider stroke must face
// the content side (left rail → stroke right, right rail → stroke left).
const visibleOrder = tracks.map((t, j) => (t.collapsed ? -1 : j)).filter(j => j >= 0)
const railSideFor = (i: number): 'left' | 'right' => {
const pos = visibleOrder.indexOf(i)
return pos >= 0 && (pos + 0.5) / visibleOrder.length > 0.5 ? 'right' : 'left'
}
return (
<div
className={cn('flex min-h-0 min-w-0 flex-1', horizontal ? 'flex-row' : 'flex-col')}
data-tree-split={node.id}
ref={containerRef}
>
{tracks.map(({ child, collapsed, minimized, narrowCollapsed, sizing, track }, i) => {
const partner = collapsed ? -1 : seamPartner(i)
return (
<div
className="relative flex min-h-0 min-w-0"
key={child.id}
style={
collapsed
? { display: 'none' }
: minimized
? { flex: '0 0 auto' }
: {
// One flexbox formula for everything: a sized zone is
// grow-0 shrink-1 from its preferred basis (it yields
// gracefully on tight windows, floored by min-width);
// everything else splits the leftover by weight. In an
// all-fixed run the last track grows into the leftover.
flex: track
? `${i === absorberIndex ? 1 : 0} 1 ${track}`
: `${grow(i)} ${grow(i)} 0px`,
// Pane-declared clamps apply along THIS split's axis only
// (a rail's width clamp shouldn't constrain its height).
// The absorber drops its max clamp — it exists to fill
// the leftover, and clamping would recreate the gap.
minWidth: (horizontal && sizing?.minWidth) || 0,
maxWidth: horizontal && i !== absorberIndex ? sizing?.maxWidth : undefined,
minHeight: (!horizontal && sizing?.minHeight) || 0,
maxHeight: horizontal || i === absorberIndex ? undefined : sizing?.maxHeight
}
}
>
{partner >= 0 && (
<Sash
disabled={minimized || tracks[partner].minimized}
horizontal={horizontal}
onDoubleClick={() => resetBoundary(partner, i)}
onPointerDown={e => startSash(partner, i, e)}
/>
)}
{!narrowCollapsed && (
<TreeNode node={child} parentAxis={axis} railSide={horizontal ? railSideFor(i) : undefined} />
)}
</div>
)
})}
</div>
)
}
function Sash({
disabled,
horizontal,
onDoubleClick,
onPointerDown
}: {
disabled?: boolean
horizontal: boolean
onDoubleClick?: () => void
onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void
}) {
return (
<div
className={cn(
'group absolute z-20 [-webkit-app-region:no-drag]',
horizontal ? 'inset-y-0 left-0 w-[9px] -translate-x-1/2' : 'inset-x-0 top-0 h-[9px] -translate-y-1/2',
disabled ? 'pointer-events-none' : horizontal ? 'cursor-col-resize' : 'cursor-row-resize'
)}
onDoubleClick={disabled ? undefined : onDoubleClick}
onPointerDown={disabled ? undefined : onPointerDown}
role="separator"
>
{/* Persistent hairline: same token as PaneShell's divider sash
(--ui-stroke-secondary) so every seam vertical or horizontal
reads identically. */}
<span
className={cn(
'absolute bg-(--ui-stroke-secondary)',
horizontal ? 'inset-y-0 left-1/2 w-px -translate-x-1/2' : 'inset-x-0 top-1/2 h-px -translate-y-1/2'
)}
/>
{!disabled && (
<span
className={cn(
'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100',
horizontal
? 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2'
: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2'
)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,507 @@
/**
* Zone editor the FancyZones grid editor experience, ported from PowerToys'
* `GridEditor.xaml.cs` interactions onto the verbatim model port (grid-model.ts):
*
* - A full-screen canvas of numbered translucent zones.
* - A splitter line follows the cursor inside the hovered zone; CLICK splits
* at that position. Hold SHIFT to flip the line horizontal (row split).
* - DRAG across zones to rubber-band select; the selection expands to its
* rectangular closure (ComputeClosure) and a Merge button appears.
* - Shared zone edges are draggable resizer thumbs (multi-zone edges move
* together, min-size clamped) GridData.Drag semantics.
* - Templates: Columns / Rows / Grid / Priority with a zone-count stepper.
*
* Saving converts the grid to our runtime layout tree (guillotine cuts) and
* registers it as a user preset; non-guillotine arrangements (pinwheels)
* disable Save with an explanation.
*/
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { registry } from '@/contrib/registry'
import { useI18n } from '@/i18n'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
import { cn } from '@/lib/utils'
import {
canSplit,
doMerge,
dragResizer,
type GridLayout,
type GridResizer,
type GridZone,
initColumns,
initGrid,
initPriorityGrid,
initRows,
mergeClosureIndices,
modelToResizers,
modelToZones,
MULTIPLIER,
splitZone
} from './grid-model'
import { gridIsTreeExpressible, gridToTree, type PanePlacementHint } from './grid-to-tree'
import { allPaneIds } from './model'
import { applyLayoutPreset, saveLayoutPresetTree } from './presets'
import { $layoutTree } from './store'
export const $zoneEditorOpen = atom(false)
const SPLIT_SNAP = 50 // model units (0.5%)
const pct = (v: number) => `${(v / MULTIPLIER) * 100}%`
interface SplitPreview {
zoneIndex: number
orientation: 'horizontal' | 'vertical'
position: number
}
interface SelectBox {
x0: number
y0: number
x1: number
y1: number
}
/** Resizer screen geometry derived from its zones (model units). */
function resizerGeometry(resizer: GridResizer, zones: GridZone[]) {
const all = [...resizer.negativeSideIndices, ...resizer.positiveSideIndices].map(i => zones[i])
if (resizer.orientation === 'horizontal') {
return {
at: zones[resizer.positiveSideIndices[0]].top,
from: Math.min(...all.map(z => z.left)),
to: Math.max(...all.map(z => z.right))
}
}
return {
at: zones[resizer.positiveSideIndices[0]].left,
from: Math.min(...all.map(z => z.top)),
to: Math.max(...all.map(z => z.bottom))
}
}
export function ZoneEditor() {
const { t } = useI18n()
const open = useStore($zoneEditorOpen)
const [model, setModel] = useState<GridLayout>(() => initPriorityGrid(3))
const [templateCount, setTemplateCount] = useState(3)
const [shift, setShift] = useState(false)
const [splitPreview, setSplitPreview] = useState<SplitPreview | null>(null)
const [selectBox, setSelectBox] = useState<SelectBox | null>(null)
const [selection, setSelection] = useState<number[]>([])
const [mergeAt, setMergeAt] = useState<{ x: number; y: number } | null>(null)
const [name, setName] = useState('')
const canvasRef = useRef<HTMLDivElement>(null)
const zones = modelToZones(model) ?? []
const resizers = modelToResizers(model)
const treeExpressible = gridIsTreeExpressible(model)
// Shift flips the splitter orientation (FancyZones behavior); Esc closes.
useEffect(() => {
if (!open) {
return
}
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor)
const down = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setShift(true)
}
// Skip when a nested field (the name Input) already handled Escape, or a
// higher layer owns it.
if (e.key === 'Escape' && !e.defaultPrevented && isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)) {
e.preventDefault()
$zoneEditorOpen.set(false)
}
}
const up = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setShift(false)
}
}
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
return () => {
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
releaseLayer()
}
}, [open])
const toModelPoint = useCallback((clientX: number, clientY: number) => {
const rect = canvasRef.current!.getBoundingClientRect()
return {
x: Math.round(((clientX - rect.x) / rect.width) * MULTIPLIER),
y: Math.round(((clientY - rect.y) / rect.height) * MULTIPLIER)
}
}, [])
if (!open) {
return null
}
const zoneAt = (x: number, y: number) =>
zones.find(z => x >= z.left && x < z.right && y >= z.top && y < z.bottom) ?? null
const updateSplitPreview = (clientX: number, clientY: number) => {
const p = toModelPoint(clientX, clientY)
const zone = zoneAt(p.x, p.y)
if (!zone) {
setSplitPreview(null)
return
}
const orientation = shift ? 'horizontal' : 'vertical'
const raw = orientation === 'horizontal' ? p.y : p.x
const position = Math.round(raw / SPLIT_SNAP) * SPLIT_SNAP
setSplitPreview(
canSplit(model, zone.index, position, orientation) ? { zoneIndex: zone.index, orientation, position } : null
)
}
const onCanvasPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || (e.target as HTMLElement).dataset.resizer !== undefined) {
return
}
e.preventDefault()
setMergeAt(null)
setSelection([])
const start = toModelPoint(e.clientX, e.clientY)
let dragged = false
const onMove = (ev: PointerEvent) => {
const cur = toModelPoint(ev.clientX, ev.clientY)
if (!dragged && Math.hypot(cur.x - start.x, cur.y - start.y) < 60) {
return
}
dragged = true
const box = {
x0: Math.min(start.x, cur.x),
y0: Math.min(start.y, cur.y),
x1: Math.max(start.x, cur.x),
y1: Math.max(start.y, cur.y)
}
setSelectBox(box)
// Zones intersecting the rubber band, expanded to rectangular closure.
const picked = zones
.filter(z => z.left < box.x1 && z.right > box.x0 && z.top < box.y1 && z.bottom > box.y0)
.map(z => z.index)
setSelection(mergeClosureIndices(model, picked))
}
const onUp = (ev: PointerEvent) => {
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', onUp, true)
setSelectBox(null)
if (!dragged) {
// Plain click: split at the previewed line.
if (splitPreview) {
setModel(m => splitZone(m, splitPreview.zoneIndex, splitPreview.position, splitPreview.orientation))
setSplitPreview(null)
}
setSelection([])
return
}
const rect = canvasRef.current!.getBoundingClientRect()
setMergeAt({ x: ev.clientX - rect.x, y: ev.clientY - rect.y })
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', onUp, true)
}
const startResizerDrag = (index: number, e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const rect = canvasRef.current!.getBoundingClientRect()
const resizer = resizers[index]
const horizontal = resizer.orientation === 'horizontal'
const start = horizontal ? e.clientY : e.clientX
let applied = 0
const onMove = (ev: PointerEvent) => {
const px = (horizontal ? ev.clientY : ev.clientX) - start
const total = Math.round((px / (horizontal ? rect.height : rect.width)) * MULTIPLIER)
const step = total - applied
if (step !== 0) {
setModel(m => {
const next = dragResizer(m, index, step)
if (next !== m) {
applied = total
}
return next
})
}
}
const onUp = () => {
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', onUp, true)
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', onUp, true)
}
const merge = () => {
setModel(m => doMerge(m, selection))
setSelection([])
setMergeAt(null)
}
const save = () => {
const paneIds = allPaneIds($layoutTree.get() ?? { type: 'group', id: 'tmp', panes: [], active: '' })
// Placement hints ride on the pane contributions (`data.placement`), so
// zones are assigned by ROLE (main/left/right/bottom), not index order.
const contributions = registry.getArea('panes')
const placed = paneIds.map(id => ({
id,
placement: (contributions.find(c => c.id === id)?.data as { placement?: PanePlacementHint } | undefined)
?.placement
}))
const tree = gridToTree(model, placed)
if (!tree) {
return
}
const id = saveLayoutPresetTree(name || t.zones.customZoneName(zones.length), tree)
if (id) {
applyLayoutPreset(id, tree)
}
$zoneEditorOpen.set(false)
}
const templates = [
{ label: t.zones.templateColumns, make: initColumns },
{ label: t.zones.templateRows, make: initRows },
{ label: t.zones.templateGrid, make: initGrid },
{ label: t.zones.templatePriority, make: initPriorityGrid }
]
return (
<div className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]" style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}>
{/* Toolbar — Panel-style title + hint, template chooser on the right. */}
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{t.zones.zoneEditorTitle}</h2>
<p className="text-xs text-muted-foreground/80">
{t.zones.editorHintPre}
<kbd className="rounded border border-(--ui-stroke-secondary) bg-foreground/5 px-1 font-mono text-[10px]">
</kbd>
{t.zones.editorHintPost}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{templates.map(t => (
<Button
key={t.label}
onClick={() => {
setSelection([])
setMergeAt(null)
setModel(t.make(templateCount))
}}
size="sm"
variant="outline"
>
{t.label}
</Button>
))}
<Input
className="h-7 w-14 text-center text-xs"
max={8}
min={1}
onChange={e => setTemplateCount(Math.max(1, Math.min(8, Number(e.target.value) || 1)))}
type="number"
value={templateCount}
/>
</div>
</div>
{/* Canvas */}
<div
className="relative min-h-0 flex-1 cursor-crosshair overflow-hidden rounded-lg border border-(--ui-stroke-secondary)"
onPointerDown={onCanvasPointerDown}
onPointerLeave={() => setSplitPreview(null)}
onPointerMove={e => updateSplitPreview(e.clientX, e.clientY)}
ref={canvasRef}
>
{zones.map(zone => {
const selected = selection.includes(zone.index)
return (
<div
className="absolute flex items-center justify-center rounded-[3px] border transition-colors"
key={zone.index}
style={{
left: pct(zone.left),
top: pct(zone.top),
width: pct(zone.right - zone.left),
height: pct(zone.bottom - zone.top),
// Accent over an elevated base — zones stay legible on dark
// themes where a bare accent wash sinks into the canvas.
background: `color-mix(in srgb, var(--ui-accent) ${selected ? 34 : 12}%, var(--ui-bg-elevated))`,
borderColor: `color-mix(in srgb, var(--ui-accent) ${selected ? 90 : 40}%, transparent)`
}}
>
{/* Quiet zone tag the app's small-caps label voice, not a
billboard number. */}
<span className="select-none text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-tertiary)">
{t.zones.zoneTag(zone.index + 1)}
</span>
</div>
)
})}
{/* Splitter preview line following the cursor. */}
{splitPreview &&
(() => {
const zone = zones[splitPreview.zoneIndex]
if (!zone) {
return null
}
const horizontal = splitPreview.orientation === 'horizontal'
return (
<div
className="pointer-events-none absolute"
style={{
left: horizontal ? pct(zone.left) : pct(splitPreview.position),
top: horizontal ? pct(splitPreview.position) : pct(zone.top),
width: horizontal ? pct(zone.right - zone.left) : 2,
height: horizontal ? 2 : pct(zone.bottom - zone.top),
background: 'var(--ui-accent)'
}}
/>
)
})()}
{/* Rubber-band selection box. */}
{selectBox && (
<div
className="pointer-events-none absolute border"
style={{
left: pct(selectBox.x0),
top: pct(selectBox.y0),
width: pct(selectBox.x1 - selectBox.x0),
height: pct(selectBox.y1 - selectBox.y0),
background: 'color-mix(in srgb, var(--ui-accent) 10%, transparent)',
borderColor: 'var(--ui-accent)'
}}
/>
)}
{/* Resizer thumbs on shared edges. */}
{resizers.map((resizer, i) => {
const geo = resizerGeometry(resizer, zones)
const horizontal = resizer.orientation === 'horizontal'
return (
<div
className={cn(
'absolute z-10 flex items-center justify-center',
horizontal ? 'h-[10px] -translate-y-1/2 cursor-row-resize' : 'w-[10px] -translate-x-1/2 cursor-col-resize'
)}
data-resizer={i}
key={`r-${i}`}
onPointerDown={e => startResizerDrag(i, e)}
style={
horizontal
? { top: pct(geo.at), left: pct(geo.from), width: pct(geo.to - geo.from) }
: { left: pct(geo.at), top: pct(geo.from), height: pct(geo.to - geo.from) }
}
>
<span
className={cn('pointer-events-none rounded-full', horizontal ? 'h-[4px] w-10' : 'h-10 w-[4px]')}
style={{ background: 'color-mix(in srgb, var(--ui-text-primary) 55%, transparent)' }}
/>
</div>
)
})}
{/* Merge affordance at drag-release point. */}
{mergeAt && selection.length > 1 && (
<div className="absolute z-20 flex gap-1" style={{ left: mergeAt.x, top: mergeAt.y }}>
<Button
className="shadow-lg"
onClick={merge}
onPointerDown={e => e.stopPropagation()}
size="sm"
variant="outline"
>
{t.zones.mergeZones(selection.length)}
</Button>
</div>
)}
</div>
{/* Footer: save / cancel. */}
<div className="flex items-center gap-1.5">
<Input
className="h-7 w-64 text-xs"
onChange={e => setName(e.target.value)}
// Escape while typing clears the field and yields — it must not bubble
// up to close the whole editor and lose the name.
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
setName('')
e.currentTarget.blur()
}
}}
placeholder={t.zones.layoutNamePlaceholder(t.zones.customZoneName(zones.length))}
value={name}
/>
<Button disabled={!treeExpressible} onClick={save} size="sm" variant="outline">
{t.zones.saveApply}
</Button>
<Button onClick={() => $zoneEditorOpen.set(false)} size="sm" variant="ghost">
{t.common.cancel}
</Button>
{!treeExpressible && <span className="text-xs text-muted-foreground/80">{t.zones.notExpressible}</span>}
<span className="ml-auto text-xs text-muted-foreground/60">{t.zones.zoneCount(zones.length)}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,367 @@
/**
* FancyZones runtime engine verbatim port of the drag/highlight internals
* from PowerToys `FancyZonesLib` (microsoft/PowerToys, MIT):
*
* - `Layout.cpp` -> `zonesFromPoint` (sensitivity-radius capture, the
* "captured but not strictly captured" rejection, the overlap resolution
* algorithms incl. ClosestCenter with OVERLAPPING_CENTERS_SENSITIVITY),
* `getCombinedZoneRange`, `getCombinedZonesRect`.
* - `HighlightedZones.cpp` -> the `HighlightedZones` update state machine
* (initial-zone latch + combined range while "select many" is held).
* - `ZonesOverlay.cpp` -> animation timing: FadeInDurationMillis = 200,
* FlashZonesDurationMillis = 700, alpha = clamp(t/200, 0.001, 1), and the
* fill-alpha rule (highlightOpacity applies to BOTH inactive + highlight).
* - `Colors.cpp` -> ZoneColors resolution (system-theme mode maps accent to
* border+highlight and background to primary; number color auto-contrasts).
*/
// ---------------------------------------------------------------------------
// Constants (ZonesOverlay.cpp / Layout.cpp / FancyZones defaults)
// ---------------------------------------------------------------------------
export const FADE_IN_DURATION_MILLIS = 200
export const FLASH_ZONES_DURATION_MILLIS = 700
/** LayoutDefaultSettings::DefaultSensitivityRadius. */
export const DEFAULT_SENSITIVITY_RADIUS = 20
/** ZoneSelectionAlgorithms::OVERLAPPING_CENTERS_SENSITIVITY. */
const OVERLAPPING_CENTERS_SENSITIVITY = 75
export type OverlappingZonesAlgorithm = 'Smallest' | 'Largest' | 'Positional' | 'ClosestCenter'
export interface ZoneRect {
left: number
top: number
right: number
bottom: number
}
export interface EngineZone {
id: string
rect: ZoneRect
}
interface Point {
x: number
y: number
}
const zoneArea = (z: EngineZone) => Math.max(0, z.rect.right - z.rect.left) * Math.max(0, z.rect.bottom - z.rect.top)
// ---------------------------------------------------------------------------
// ZonesOverlay::GetAnimationAlpha (verbatim ramp)
// ---------------------------------------------------------------------------
export function getAnimationAlpha(startedAtMs: number, nowMs: number, autoHide: boolean): number {
const millis = nowMs - startedAtMs
if (autoHide && millis > FLASH_ZONES_DURATION_MILLIS) {
return 0
}
// Return a positive value to avoid hiding.
return Math.min(Math.max(millis / FADE_IN_DURATION_MILLIS, 0.001), 1)
}
// ---------------------------------------------------------------------------
// Colors (Colors.cpp + FancyZones settings defaults)
// ---------------------------------------------------------------------------
export interface ZoneColors {
primaryColor: string
borderColor: string
highlightColor: string
numberColor: string
/** 0..100, applied as fill alpha to BOTH primary and highlight fills. */
highlightOpacity: number
}
/** FancyZonesSettings defaults (custom-color mode). */
export const FANCYZONES_DEFAULT_COLORS: ZoneColors = {
primaryColor: '#F5FCFF',
borderColor: '#FFFFFF',
highlightColor: '#008CFF',
numberColor: '#000000',
highlightOpacity: 50
}
/**
* Colors::GetZoneColors systemTheme branch, mapped to our design tokens:
* accent -> border + highlight, surface -> primary, number auto-contrast
* (CSS light-dark handles what the C++ does with the black-background check).
*/
export function systemThemeZoneColors(): ZoneColors {
return {
primaryColor: 'var(--ui-bg-editor)',
borderColor: 'var(--ui-accent)',
highlightColor: 'var(--ui-accent)',
numberColor: 'var(--ui-text-primary)',
highlightOpacity: 50
}
}
// ---------------------------------------------------------------------------
// ZoneSelectionAlgorithms (Layout.cpp, verbatim)
// ---------------------------------------------------------------------------
function zoneSelectPriority(
zones: Map<string, EngineZone>,
capturedZones: string[],
compare: (a: EngineZone, b: EngineZone) => boolean
): string[] {
let chosen = 0
for (let i = 1; i < capturedZones.length; i++) {
if (compare(zones.get(capturedZones[i])!, zones.get(capturedZones[chosen])!)) {
chosen = i
}
}
return [capturedZones[chosen]]
}
function zoneSelectSubregion(
zones: Map<string, EngineZone>,
capturedZones: string[],
pt: Point,
sensitivityRadius: number
): string[] {
const expand = (rect: ZoneRect): ZoneRect => ({
top: rect.top - sensitivityRadius / 2,
bottom: rect.bottom + sensitivityRadius / 2,
left: rect.left - sensitivityRadius / 2,
right: rect.right + sensitivityRadius / 2
})
// Compute the overlapped rectangle.
let overlap = expand(zones.get(capturedZones[0])!.rect)
for (let i = 1; i < capturedZones.length; i++) {
const current = expand(zones.get(capturedZones[i])!.rect)
overlap = {
top: Math.max(overlap.top, current.top),
left: Math.max(overlap.left, current.left),
bottom: Math.min(overlap.bottom, current.bottom),
right: Math.min(overlap.right, current.right)
}
}
// Avoid division by zero.
const width = Math.max(overlap.right - overlap.left, 1)
const height = Math.max(overlap.bottom - overlap.top, 1)
const verticalSplit = height > width
let zoneIndex: number
if (verticalSplit) {
zoneIndex = Math.floor(((pt.y - overlap.top) * capturedZones.length) / height)
} else {
zoneIndex = Math.floor(((pt.x - overlap.left) * capturedZones.length) / width)
}
zoneIndex = Math.min(Math.max(zoneIndex, 0), capturedZones.length - 1)
return [capturedZones[zoneIndex]]
}
function zoneSelectClosestCenter(zones: Map<string, EngineZone>, capturedZones: string[], pt: Point): string[] {
const getCenter = (zone: EngineZone): Point => ({
x: (zone.rect.right + zone.rect.left) / 2,
y: (zone.rect.top + zone.rect.bottom) / 2
})
const pointDifference = (a: Point, b: Point) => (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)
const distanceFromCenter = (zone: EngineZone) => pointDifference(getCenter(zone), pt)
const closerToCenter = (zone1: EngineZone, zone2: EngineZone) => {
if (pointDifference(getCenter(zone1), getCenter(zone2)) > OVERLAPPING_CENTERS_SENSITIVITY) {
return distanceFromCenter(zone1) < distanceFromCenter(zone2)
}
return zoneArea(zone1) < zoneArea(zone2)
}
return zoneSelectPriority(zones, capturedZones, closerToCenter)
}
// ---------------------------------------------------------------------------
// Layout::ZonesFromPoint (verbatim)
// ---------------------------------------------------------------------------
export function zonesFromPoint(
zoneList: EngineZone[],
pt: Point,
sensitivityRadius = DEFAULT_SENSITIVITY_RADIUS,
overlappingAlgorithm: OverlappingZonesAlgorithm = 'Smallest'
): string[] {
const zones = new Map(zoneList.map(z => [z.id, z]))
const capturedZones: string[] = []
const strictlyCapturedZones: string[] = []
for (const zone of zoneList) {
const zoneRect = zone.rect
if (
zoneRect.left - sensitivityRadius <= pt.x &&
pt.x <= zoneRect.right + sensitivityRadius &&
zoneRect.top - sensitivityRadius <= pt.y &&
pt.y <= zoneRect.bottom + sensitivityRadius
) {
capturedZones.push(zone.id)
}
if (zoneRect.left <= pt.x && pt.x < zoneRect.right && zoneRect.top <= pt.y && pt.y < zoneRect.bottom) {
strictlyCapturedZones.push(zone.id)
}
}
// If only one zone is captured, but it's not strictly captured
// don't consider it as captured.
if (capturedZones.length === 1 && strictlyCapturedZones.length === 0) {
return []
}
// If captured zones do not overlap, return all of them.
// Otherwise, return one of them based on the chosen selection algorithm.
let overlap = false
outer: for (let i = 0; i < capturedZones.length; i++) {
for (let j = i + 1; j < capturedZones.length; j++) {
const rectI = zones.get(capturedZones[i])!.rect
const rectJ = zones.get(capturedZones[j])!.rect
if (
Math.max(rectI.top, rectJ.top) + sensitivityRadius < Math.min(rectI.bottom, rectJ.bottom) &&
Math.max(rectI.left, rectJ.left) + sensitivityRadius < Math.min(rectI.right, rectJ.right)
) {
overlap = true
break outer
}
}
}
if (overlap) {
switch (overlappingAlgorithm) {
case 'Smallest':
return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) < zoneArea(b))
case 'Largest':
return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) > zoneArea(b))
case 'Positional':
return zoneSelectSubregion(zones, capturedZones, pt, sensitivityRadius)
case 'ClosestCenter':
return zoneSelectClosestCenter(zones, capturedZones, pt)
}
}
return capturedZones
}
/** The single zone a drop lands in: ClosestCenter among the highlighted set. */
export function primaryZone(zoneList: EngineZone[], highlighted: string[], pt: Point): string | null {
if (highlighted.length === 0) {
return null
}
if (highlighted.length === 1) {
return highlighted[0]
}
const zones = new Map(zoneList.map(z => [z.id, z]))
return zoneSelectClosestCenter(zones, highlighted, pt)[0] ?? highlighted[0]
}
// ---------------------------------------------------------------------------
// Layout::GetCombinedZoneRange (verbatim)
// ---------------------------------------------------------------------------
export function getCombinedZoneRange(zoneList: EngineZone[], initialZones: string[], finalZones: string[]): string[] {
const zones = new Map(zoneList.map(z => [z.id, z]))
const combinedZones = [...new Set([...initialZones, ...finalZones])]
let boundingRect: ZoneRect | null = null
for (const zoneId of combinedZones) {
const zone = zones.get(zoneId)
if (zone) {
const rect = zone.rect
if (!boundingRect) {
boundingRect = { ...rect }
} else {
boundingRect.left = Math.min(boundingRect.left, rect.left)
boundingRect.top = Math.min(boundingRect.top, rect.top)
boundingRect.right = Math.max(boundingRect.right, rect.right)
boundingRect.bottom = Math.max(boundingRect.bottom, rect.bottom)
}
}
}
const result: string[] = []
if (boundingRect) {
for (const zone of zoneList) {
const rect = zone.rect
if (
boundingRect.left <= rect.left &&
rect.right <= boundingRect.right &&
boundingRect.top <= rect.top &&
rect.bottom <= boundingRect.bottom
) {
result.push(zone.id)
}
}
}
return result
}
// ---------------------------------------------------------------------------
// HighlightedZones (HighlightedZones.cpp, verbatim state machine)
// ---------------------------------------------------------------------------
export class HighlightedZones {
private highlightZone: string[] = []
private initialHighlightZone: string[] = []
zones(): readonly string[] {
return this.highlightZone
}
empty(): boolean {
return this.highlightZone.length === 0
}
/** Returns true when the highlight set changed. */
update(zoneList: EngineZone[], point: Point, selectManyZones: boolean): boolean {
let highlightZone = zonesFromPoint(zoneList, point)
if (selectManyZones) {
if (this.initialHighlightZone.length === 0) {
// First time.
this.initialHighlightZone = highlightZone
} else {
highlightZone = getCombinedZoneRange(zoneList, this.initialHighlightZone, highlightZone)
}
} else {
this.initialHighlightZone = []
}
const updated =
highlightZone.length !== this.highlightZone.length || highlightZone.some((z, i) => z !== this.highlightZone[i])
this.highlightZone = highlightZone
return updated
}
reset(): void {
this.highlightZone = []
this.initialHighlightZone = []
}
}

View File

@ -0,0 +1,138 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */
export const PANE_TAB_STRIP_LINE = 'shadow-[inset_0_-1px_0_var(--ui-stroke-tertiary)]'
/** Inset stroke for a vertical tab rail — content-facing edge. */
export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]'
export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]'
const TAB =
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]'
const TAB_HORIZONTAL =
'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
const TAB_VERTICAL =
'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]'
const TAB_ACTIVE =
'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover),
// not an opaque recolor — and never touch borders.
const TAB_IDLE =
'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,var(--ui-base)_4%,transparent)] hover:text-(--ui-text-secondary)'
interface PaneTabProps extends React.ComponentProps<'div'> {
active?: boolean
dirty?: boolean
/** Middle-click close (no hover X — too easy to hit on small tabs). */
onClose?: () => void
/** Vertical rail form (collapsed sidebar zones). */
vertical?: boolean
/** Content-facing edge of a vertical rail — the strip line the active tab cuts. */
side?: 'left' | 'right'
}
/**
* Editor tab shell preview rail + zone headers + collapsed vertical rails.
*
* Strip sets `--pane-tab-active-bg` (content surface) and `--pane-tab-strip-bg`
* (gutter; prefer `--theme-card-seed` = VS Code `tab.inactiveBackground`).
* Active merges into content; inactive sits flush in the gutter.
*/
export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function PaneTab(
{
active = false,
dirty = false,
onClose,
onAuxClick,
onMouseDown,
vertical = false,
side = 'left',
children,
className,
...props
},
ref
) {
// Content-facing edge: horizontal cuts the bottom strip line; vertical cuts
// the side that faces the editor (left rail → right edge, right rail → left).
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : 'border-b'
return (
<div
className={cn(
TAB,
vertical ? TAB_VERTICAL : TAB_HORIZONTAL,
edge,
active ? TAB_ACTIVE : cn(TAB_IDLE, `${edge}-(--ui-stroke-tertiary)`),
className
)}
data-active={active}
data-vertical={vertical || undefined}
onAuxClick={event => {
// Middle-click closes (browser/IDE). Swallow mousedown so Chromium
// doesn't autoscroll.
if (onClose && event.button === 1) {
event.preventDefault()
onClose()
}
onAuxClick?.(event)
}}
onMouseDown={event => {
if (onClose && event.button === 1) {
event.preventDefault()
}
onMouseDown?.(event)
}}
ref={ref}
{...props}
>
{children}
{dirty && (
<span
aria-hidden
className={cn(
'pointer-events-none absolute grid size-4 place-items-center',
vertical ? 'bottom-1.5 left-1/2 -translate-x-1/2' : 'right-1.5 top-1/2 -translate-y-1/2'
)}
>
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
</span>
)}
</div>
)
})
interface PaneTabLabelProps extends React.ComponentProps<'button'> {
/** `button` when the label is the activation target (preview rail);
* default `span` defers to the shell (zone drag/activate). */
as?: 'button' | 'span'
}
/** Truncating label inside a `PaneTab`. `className` merges into the text span
* (e.g. `normal-case tracking-normal` for filenames). */
export const PaneTabLabel = React.forwardRef<HTMLElement, PaneTabLabelProps>(function PaneTabLabel(
{ as = 'span', className, children, ...props },
ref
) {
const Comp = as as React.ElementType
return (
<Comp
className="flex h-full min-w-0 max-w-full items-center overflow-hidden px-2 text-left outline-none group-data-[vertical]/tab:h-auto group-data-[vertical]/tab:w-full group-data-[vertical]/tab:justify-center group-data-[vertical]/tab:py-2"
ref={ref}
{...props}
>
<span className={cn('block min-w-0 truncate text-[9px] font-medium tracking-wide uppercase', className)}>
{children}
</span>
</Comp>
)
})