* refactor(desktop): dock terminal under chat and simplify file rail
Keep the right rail focused on file browsing while moving the persistent terminal into the chat column bottom slot, and make terminal colors follow the active light/dark mode instead of a fixed Solarized palette.
* fix(desktop): make the terminal a resizable, themed side pane
- Move the terminal into a resizable pane (viewport-% widths) that shares
<main>'s stacking context, so its drag handle no longer sits under the
fixed terminal overlay; works on either rail side.
- Restore +x on node-pty's spawn-helper before the first spawn to fix
"posix_spawnp failed" on macOS prebuilds (real cause; drop the redundant
shell-candidate retry loop).
- Gate terminal open/fit/start on document.fonts.ready and strip leading
blank rows (re-armed before the resize Ctrl-L redraw) so the prompt sits
flush at the top with no starship add_newline gap.
- Inherit the app editor-surface color as the terminal background.
- Bind Ctrl+` (⌃` on macOS) to toggle the terminal; add a palette entry.
* feat(desktop): show platform hotkey hints in the command palette
- Render each palette item's live binding as a <KbdGroup> hint via a new
comboTokens() helper (mac shows ⌘/⌃/⌥/⇧, every other platform shows
Ctrl/Alt/Shift — never a ⌘ on PC).
- Default the terminal toggle to ⌘` / Ctrl+` (the ~ key) on both platforms.
- Drop the hardcoded (⌘⏎) baked into the composer steer tooltip; render it
platform-aware with formatCombo instead.
* fix(desktop): drop the active check on the command-palette terminal item
* fix(desktop): remove active/check states from the command palette
* fix(desktop): allow ⌥/Shift-drag selection over mouse-mode TUIs
Full-screen apps (hermes --tui, vim) enable mouse reporting, so a plain
drag can't select text and ⌘/Ctrl+L (add-selection-to-chat) had nothing
to send. Enable macOptionClickForcesSelection so ⌥-drag on macOS (Shift
elsewhere) forces a native selection over mouse-mode apps.
* feat(desktop): tell the in-pane agent it's embedded in the GUI
Set HERMES_DESKTOP_TERMINAL=1 on the terminal pane's shell env and surface
it in build_environment_hints, so a hermes/--tui launched inside the pane
knows it's next to the GUI chat and that ⌥/Shift-drag + ⌘/Ctrl+L sends a
selection to the composer. Distinct from HERMES_DESKTOP (agent backend).
* refactor(desktop): drop the redundant Ctrl+` terminal-toggle fallback
The toggle now ships as mod+` on both platforms, so the standard combo
index handles it — the bespoke fallback (and its stale 'old default'
comment) is dead weight.
* fix(desktop): read live terminal selection for ⌘/Ctrl+L
A redraw-heavy TUI (spinners/clocks) outruns onSelectionChange, leaving the
React selection state empty so the state-gated shortcut listener never
attached and ⌘L no-op'd. Always listen and read xterm's live selection (with
a native fallback) at press time; only swallow the key when there's text to
send. Drops the now-redundant custom key handler.
* feat(desktop): make any agent aware it's in the Hermes desktop GUI
Generalize the runtime-surface hint: fire for HERMES_DESKTOP (the backend
powering the GUI chat) as well as HERMES_DESKTOP_TERMINAL (a hermes in the
embedded terminal pane), so it's about being inside the desktop GUI, not
about being a TUI. The terminal-pane selection note stays pane-specific.
* feat(desktop): give the GUI agent a read_terminal tool
The in-app terminal buffer lives in the renderer (xterm), so expose it to the
chat agent over the same blocking bridge clarify uses: read_terminal emits
terminal.read.request, the renderer serializes the buffer (visible screen by
default, or a start_line/count range against total_lines) and answers
terminal.read.respond. Gated to the GUI via HERMES_DESKTOP.
Also restores the flipped-layout titlebar inset fix (app-shell +
desktop-controller) for terminal/preview rails at the window's left edge.
* chore(desktop): trim read_terminal comments
* feat(desktop): add a terminal toggle to the statusbar
The file rail lost its terminal icon, leaving ⌘` and the command palette
as the only ways in. Add a one-click toggle to the statusbar's left
cluster, mirroring the command-center item: it reads $terminalTakeover so
it lights up while the pane is open and stays in sync with the hotkey, and
is gated to chat view (the only place the pane can show).
* fix(desktop): relabel the terminal header button to what it does
The in-pane button claimed a focus/split fullscreen toggle ("Focus
terminal view" / "Return to split view", screen-full/normal icons), but
the terminal is just a resizable side pane — there's no fullscreen. The
button only mounts while the pane is open, so the focus branch was dead
and clicking it merely closed the terminal. Relabel to "Hide terminal"
with a close icon, drop the dead conditional and the now-unused takeover
read.
* fix(desktop): move the terminal toggle next to the version item
Relocate it from the left cluster to the right of the statusbar, just
left of the client version item.
* feat(desktop): default the terminal to PowerShell on Windows
Prefer pwsh (7+) then Windows PowerShell 5.1 over cmd.exe, falling back to
comspec only when neither is present. -NoLogo drops the startup banner so
the prompt sits flush like the POSIX shells.
* feat(desktop): show a persistent divider on the terminal pane
The resize sash only painted on hover, so the terminal/chat boundary was
invisible at rest. Add an opt-in `divider` prop to Pane that paints a thin
resting hairline on the resize edge (side-aware, so it tracks the rail when
the layout flips) and enable it on the terminal pane.
* refactor(desktop): resolve the terminal shell instead of hardcoding it
Make shell selection a real resolver: an explicit override wins
(HERMES_DESKTOP_SHELL on both platforms, $SHELL on POSIX), otherwise
auto-detect the best installed shell — pwsh > Windows PowerShell 5.1 > cmd
on Windows, zsh > bash > sh on POSIX. A shared shellSpecFor() picks the
interactive flags by family, so an overridden bash/pwsh/cmd all launch
correctly.
* fix(desktop): repaint the terminal on light/dark switch
Setting term.options.theme updated colors for the DOM renderer but not the
WebGL one, which caches glyph colors in a texture atlas — so already-drawn
cells kept their old palette after a mode switch. Hold the WebglAddon in a
ref and clear its atlas when the theme changes.
* fix(desktop): match the terminal palette to VS Code Light+/Dark+
Adopt VS Code's exact default ANSI palette (the terminalColorRegistry
defaults), enable minimumContrastRatio: 4.5 so foregrounds are clamped
against the background the way the integrated terminal does, and key the
light/dark choice off renderedMode (the painted surface) instead of
resolvedMode so it can't invert. The canvas + inset paint the live skin
surface (--ui-editor-surface-background) so the terminal blends with the
app and follows light/dark, while the contrast clamp keeps colors crisp.
* fix(desktop): tighten command palette search to substring matching
cmdk's default fuzzy scorer matched anything with the query letters
scattered across an item, so e.g. "color" never narrowed to color
entries. Add a substring filter: every typed word must literally appear
in an item's value/keywords, keeping results tight and predictable.
* fix(desktop): blend the terminal header into the skin surface
The persistent-terminal overlay painted the static palette background
(#1e1e1e/#ffffff), so the transparent header strip revealed a near-black
slab above the surface-colored body. Paint the overlay with the live
--ui-editor-surface-background so header and body read as one pane.
* fix(desktop): re-resolve the terminal surface on skin switch
The canvas surface only re-resolved on light/dark change, so switching
skins at the same mode left the WebGL canvas painted with the old tint
until reload. Key the resolve off themeName too. Also trim the palette
comments.
* chore(desktop): drop redundant terminal theming header comment
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
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, 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
|
|
/** 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 {
|
|
defaultOpen: boolean
|
|
disabled: boolean
|
|
forceCollapsed: boolean
|
|
id: string
|
|
resizable: boolean
|
|
side: PaneSide
|
|
width: string
|
|
}
|
|
|
|
const DEFAULT_WIDTH = '16rem'
|
|
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
|
|
|
// 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.
|
|
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
|
const HOVER_REVEAL_EDGE_GUTTER = 6
|
|
|
|
// 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)
|
|
|
|
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
|
|
// pixels for drag clamping. Viewport units resolve against the current window width.
|
|
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|%)?$/)
|
|
|
|
if (!match) {
|
|
return undefined
|
|
}
|
|
|
|
const n = Number.parseFloat(match[1])
|
|
|
|
switch (match[2]) {
|
|
case 'rem':
|
|
return n * remPx()
|
|
|
|
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 = {
|
|
defaultOpen: props.defaultOpen ?? true,
|
|
disabled: props.disabled ?? false,
|
|
forceCollapsed: props.forceCollapsed ?? false,
|
|
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 }
|
|
}
|
|
|
|
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
|
|
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
|
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
|
|
|
|
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 }
|
|
}
|
|
|
|
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
|
|
|
|
for (const pane of left) {
|
|
const { open, track } = trackForPane(pane, paneStates)
|
|
tracks.push(track)
|
|
paneById.set(pane.id, { column, open, side: 'left' })
|
|
cssVars[`--pane-${pane.id}-width`] = track
|
|
column++
|
|
}
|
|
|
|
tracks.push('minmax(0,1fr)')
|
|
const mainColumn = column++
|
|
|
|
for (const pane of right) {
|
|
const { open, track } = trackForPane(pane, paneStates)
|
|
tracks.push(track)
|
|
paneById.set(pane.id, { column, open, side: 'right' })
|
|
cssVars[`--pane-${pane.id}-width`] = track
|
|
column++
|
|
}
|
|
|
|
return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & {
|
|
cssVars: Record<string, string>
|
|
gridTemplate: string
|
|
}
|
|
}, [left, paneStates, right])
|
|
|
|
const composedStyle = useMemo<CSSProperties>(
|
|
() => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }),
|
|
[ctxValue.cssVars, ctxValue.gridTemplate, style]
|
|
)
|
|
|
|
return (
|
|
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
|
|
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
|
|
{children}
|
|
</div>
|
|
</PaneShellContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function Pane({
|
|
children,
|
|
className,
|
|
defaultOpen = true,
|
|
divider = false,
|
|
disabled = false,
|
|
hoverReveal = false,
|
|
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
|
|
const overlayWidth = 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 canResize = open && resizable
|
|
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
|
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
|
|
|
const startResize = useCallback(
|
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
|
|
|
|
if (!canResize || paneWidth <= 0) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
|
|
const handle = event.currentTarget
|
|
const { pointerId, clientX: startX } = event
|
|
const dir = side === 'left' ? 1 : -1
|
|
const restoreCursor = document.body.style.cursor
|
|
const restoreSelect = document.body.style.userSelect
|
|
|
|
handle.setPointerCapture?.(pointerId)
|
|
document.body.style.cursor = 'col-resize'
|
|
document.body.style.userSelect = 'none'
|
|
|
|
const onMove = (e: PointerEvent) => {
|
|
const next = paneWidth + (e.clientX - startX) * dir
|
|
setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next))))
|
|
}
|
|
|
|
const 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, id, lo, 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 row-start-1 min-w-0', className)}
|
|
data-forced={forced ? '' : undefined}
|
|
data-pane-hover-reveal={forced ? 'open' : 'closed'}
|
|
data-pane-id={id}
|
|
data-pane-open="false"
|
|
data-pane-side={side}
|
|
ref={paneRef}
|
|
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
|
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 row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
|
data-pane-id={id}
|
|
data-pane-open={open ? 'true' : 'false'}
|
|
data-pane-side={slot.side}
|
|
ref={paneRef}
|
|
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
|
>
|
|
{canResize && (
|
|
<div
|
|
aria-label={`Resize ${id}`}
|
|
aria-orientation="vertical"
|
|
className={cn(
|
|
'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]',
|
|
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
|
|
)}
|
|
onPointerDown={startResize}
|
|
role="separator"
|
|
tabIndex={0}
|
|
>
|
|
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
|
|
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
|
</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('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
|
data-pane-main="true"
|
|
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'
|