mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Add right-sidebar edge peek (extracted from #7423)
Arc-style hover peek: when the right sidebar is closed, hovering the window's right edge reveals it as a floating overlay without reflowing the editor; moving away dismisses it, and an explicit open pins it. Extracted unchanged from PR #7423 so it can be reviewed on its own. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
committed by
brennanb2025
co-authored by
Orca
parent
41edc493b8
commit
ea9ffbaea5
@@ -55,6 +55,7 @@ import {
|
||||
useSystemPrefersDark
|
||||
} from './components/terminal-pane/use-system-prefers-dark'
|
||||
import RightSidebar from './components/right-sidebar'
|
||||
import { RightSidebarEdgePeekZone } from './components/right-sidebar/right-sidebar-edge-peek'
|
||||
import { StarNagCard } from './components/StarNagCard'
|
||||
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
|
||||
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
|
||||
@@ -2211,7 +2212,10 @@ function App(): React.JSX.Element {
|
||||
'The app is still running. Retry the shell or use the menu to report the crash details.'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{/* Why: relative anchors the right-sidebar edge-peek overlay to
|
||||
this row, so a peek spans the same height as the docked sidebar
|
||||
and ends above the status bar. */}
|
||||
<div className="relative flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{/* Why: the non-workspace titlebar lives inside this left+center
|
||||
wrapper so it does not span over the right-sidebar column —
|
||||
when the right sidebar is open, its own header anchors at the
|
||||
@@ -2428,6 +2432,7 @@ function App(): React.JSX.Element {
|
||||
Its heavy panels disconnect while closed so workspace wake stays
|
||||
responsive. Unmount on the tasks view since that surface is
|
||||
intentionally distraction-free. */}
|
||||
{showRightSidebarControls ? <RightSidebarEdgePeekZone /> : null}
|
||||
{showRightSidebarControls ? (
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="right-sidebar"
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from './right-sidebar-width'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
|
||||
import { useRightSidebarEdgePeekDismiss } from './right-sidebar-edge-peek'
|
||||
import { useMeasuredWidth } from './right-sidebar-measured-width'
|
||||
import { normalizeRightSidebarRoute } from '@/store/right-sidebar-route'
|
||||
import { AgentSessionHistoryIcon } from './agent-session-history-icon'
|
||||
@@ -62,6 +63,8 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
const checksShortcut = useShortcutLabel('sidebar.checks.toggle')
|
||||
const portsShortcut = useShortcutLabel('sidebar.ports.toggle')
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
|
||||
const setRightSidebarPeek = useAppStore((s) => s.setRightSidebarPeek)
|
||||
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
|
||||
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
|
||||
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
|
||||
@@ -69,11 +72,17 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const showRightSidebarFiles = useAppStore((s) => s.showRightSidebarFiles)
|
||||
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
|
||||
const checksStatus = useAppStore((s) => (s.rightSidebarOpen ? getActiveChecksStatus(s) : null))
|
||||
// An edge-hover peek shows the same content as a pinned-open sidebar; only
|
||||
// the positioning differs (floating overlay vs in-flow panel).
|
||||
const isPeeking = rightSidebarPeek && !rightSidebarOpen
|
||||
const sidebarVisible = rightSidebarOpen || isPeeking
|
||||
const checksStatus = useAppStore((s) =>
|
||||
s.rightSidebarOpen || s.rightSidebarPeek ? getActiveChecksStatus(s) : null
|
||||
)
|
||||
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
|
||||
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
|
||||
const [topActivityStripWidth, setTopActivityStripWidth] = useState<number | null>(null)
|
||||
const activeWorktreeId = useAppStore((s) => (rightSidebarOpen ? s.activeWorktreeId : null))
|
||||
const activeWorktreeId = useAppStore((s) => (sidebarVisible ? s.activeWorktreeId : null))
|
||||
// Why: source control and checks are meaningless for non-git folders.
|
||||
// Hide those tabs so the activity bar only shows relevant actions.
|
||||
const activeWorktree = useAppStore((s) =>
|
||||
@@ -204,8 +213,8 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
windowWidth,
|
||||
activityBarSideWidth
|
||||
)
|
||||
const { containerRef, onResizeStart } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: rightSidebarOpen,
|
||||
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: sidebarVisible,
|
||||
width: renderedRightSidebarWidth,
|
||||
minWidth: RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
maxWidth,
|
||||
@@ -213,9 +222,15 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
renderedExtraWidth: activityBarSideWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
})
|
||||
useRightSidebarEdgePeekDismiss({
|
||||
isPeeking,
|
||||
isResizing,
|
||||
setPeek: setRightSidebarPeek,
|
||||
overlayRef: containerRef
|
||||
})
|
||||
const topActivityStripRef = useMeasuredWidth(setTopActivityStripWidth)
|
||||
|
||||
const panelContent = rightSidebarOpen ? (
|
||||
const panelContent = sidebarVisible ? (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
|
||||
{/* Why: sidebar panels no longer use key={activeWorktreeId} because
|
||||
the full unmount/remount cycle on every worktree switch triggered
|
||||
@@ -228,7 +243,7 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
property) rather than in a bottom-docked dashboard panel that
|
||||
competed with file Explorer/Search for vertical space. The right
|
||||
sidebar is back to tab-only content. */}
|
||||
<RightSidebarPanelContent effectiveTab={effectiveTab} rightSidebarOpen={rightSidebarOpen} />
|
||||
<RightSidebarPanelContent effectiveTab={effectiveTab} rightSidebarOpen={sidebarVisible} />
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@@ -248,7 +263,9 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
/>
|
||||
))
|
||||
|
||||
const closeButton = rightSidebarOpen ? (
|
||||
// During a peek the same button pins the sidebar open (toggle clears the
|
||||
// peek flag alongside flipping open).
|
||||
const closeButton = sidebarVisible ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -277,20 +294,25 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 flex flex-row',
|
||||
'flex-shrink-0 flex flex-row',
|
||||
// Why: overflow-visible is needed when open so the resize handle
|
||||
// on the left edge remains interactive. When closed (width 0),
|
||||
// switch to overflow-hidden so the activity bar icons and panel
|
||||
// content don't leak past the 0-width boundary (the component
|
||||
// stays mounted for performance — see App.tsx).
|
||||
rightSidebarOpen ? 'overflow-visible' : 'overflow-hidden'
|
||||
sidebarVisible ? 'overflow-visible' : 'overflow-hidden',
|
||||
// A peek floats over the content row (no reflow) with the
|
||||
// styleguide's floating-surface shadow; pinned-open stays in flow.
|
||||
isPeeking
|
||||
? 'absolute inset-y-0 right-0 z-30 shadow-[0_10px_24px_rgba(0,0,0,0.18)] animate-in slide-in-from-right duration-200 motion-reduce:animate-none'
|
||||
: 'relative'
|
||||
)}
|
||||
>
|
||||
{/* Panel content area */}
|
||||
<div
|
||||
className="flex flex-col flex-1 min-w-0 bg-sidebar overflow-hidden"
|
||||
style={{
|
||||
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
|
||||
borderLeft: sidebarVisible ? '1px solid var(--sidebar-border)' : 'none'
|
||||
}}
|
||||
>
|
||||
{activityBarPosition === 'top' ? (
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, fireEvent, render, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
PEEK_CLOSE_DELAY_MS,
|
||||
PEEK_OPEN_DELAY_MS,
|
||||
RightSidebarEdgePeekZone,
|
||||
useRightSidebarEdgePeekDismiss
|
||||
} from './right-sidebar-edge-peek'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
|
||||
function makeOverlayRef(left: number): React.RefObject<HTMLElement | null> {
|
||||
return makeMovingOverlayRef(() => left)
|
||||
}
|
||||
|
||||
// Overlay whose measured left edge changes between reads (e.g. the slide-in
|
||||
// entrance transform still translating the panel when first measured).
|
||||
function makeMovingOverlayRef(readLeft: () => number): React.RefObject<HTMLElement | null> {
|
||||
return {
|
||||
current: { getBoundingClientRect: () => ({ left: readLeft() }) } as unknown as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const RIGHT_EDGE_X = window.innerWidth - 1
|
||||
const BELOW_TITLEBAR_Y = 100
|
||||
|
||||
function moveMouse(clientX: number, clientY: number): void {
|
||||
act(() => {
|
||||
fireEvent.mouseMove(window, { clientX, clientY })
|
||||
})
|
||||
}
|
||||
|
||||
describe('RightSidebarEdgePeekZone', () => {
|
||||
it('renders nothing (no DOM strip that could swallow edge clicks)', () => {
|
||||
const { container } = render(<RightSidebarEdgePeekZone />)
|
||||
expect(container.firstElementChild).toBeNull()
|
||||
})
|
||||
|
||||
it('arms the peek after the pointer hovers the right edge for the open delay', () => {
|
||||
render(<RightSidebarEdgePeekZone />)
|
||||
|
||||
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels an armed peek when the pointer leaves the edge before the delay', () => {
|
||||
render(<RightSidebarEdgePeekZone />)
|
||||
|
||||
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS - 1)
|
||||
moveMouse(RIGHT_EDGE_X - 200, BELOW_TITLEBAR_Y)
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
|
||||
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores the top titlebar zone so window controls stay clickable', () => {
|
||||
render(<RightSidebarEdgePeekZone />)
|
||||
|
||||
moveMouse(RIGHT_EDGE_X, 10)
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
|
||||
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
|
||||
})
|
||||
|
||||
it('does not arm while the sidebar is already open', () => {
|
||||
useAppStore.setState({ rightSidebarOpen: true })
|
||||
render(<RightSidebarEdgePeekZone />)
|
||||
|
||||
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
|
||||
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
|
||||
})
|
||||
|
||||
it('clears an active peek when the zone unmounts on a view change', () => {
|
||||
const { unmount } = render(<RightSidebarEdgePeekZone />)
|
||||
|
||||
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(true)
|
||||
|
||||
unmount()
|
||||
|
||||
// A surviving flag would render a ghost peek when the user returns to a
|
||||
// sidebar-capable view.
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
|
||||
})
|
||||
|
||||
it('clears an armed timer if the sidebar opens before it fires', () => {
|
||||
const { rerender } = render(<RightSidebarEdgePeekZone />)
|
||||
|
||||
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
|
||||
act(() => {
|
||||
useAppStore.setState({ rightSidebarOpen: true })
|
||||
})
|
||||
rerender(<RightSidebarEdgePeekZone />)
|
||||
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
|
||||
|
||||
// The pending open timer must not flip peek on after the sidebar opened.
|
||||
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRightSidebarEdgePeekDismiss', () => {
|
||||
it('dismisses after the pointer stays left of the overlay for the close delay', () => {
|
||||
const setPeek = vi.fn()
|
||||
renderHook(() =>
|
||||
useRightSidebarEdgePeekDismiss({
|
||||
isPeeking: true,
|
||||
isResizing: false,
|
||||
setPeek,
|
||||
overlayRef: makeOverlayRef(1000)
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.mouseMove(window, { clientX: 400 })
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
|
||||
|
||||
expect(setPeek).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('keeps the peek when the pointer returns to the overlay before the delay', () => {
|
||||
const setPeek = vi.fn()
|
||||
renderHook(() =>
|
||||
useRightSidebarEdgePeekDismiss({
|
||||
isPeeking: true,
|
||||
isResizing: false,
|
||||
setPeek,
|
||||
overlayRef: makeOverlayRef(1000)
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.mouseMove(window, { clientX: 400 })
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS - 1)
|
||||
fireEvent.mouseMove(window, { clientX: 1100 })
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
|
||||
|
||||
expect(setPeek).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss when the boundary was measured mid entrance animation', () => {
|
||||
const setPeek = vi.fn()
|
||||
// First read happens during the slide-in: the overlay still sits near the
|
||||
// window's right edge. By the time the close timer re-measures, the
|
||||
// animation has settled at its real position.
|
||||
let overlayLeft = 1590
|
||||
renderHook(() =>
|
||||
useRightSidebarEdgePeekDismiss({
|
||||
isPeeking: true,
|
||||
isResizing: false,
|
||||
setPeek,
|
||||
overlayRef: makeMovingOverlayRef(() => overlayLeft)
|
||||
})
|
||||
)
|
||||
|
||||
// The pointer moves into the settled overlay area (left of the stale
|
||||
// mid-animation boundary), which schedules a close against stale geometry.
|
||||
fireEvent.mouseMove(window, { clientX: 1200 })
|
||||
overlayLeft = 1000
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
|
||||
|
||||
// The re-measure inside the close timer sees the settled boundary and
|
||||
// keeps the peek open.
|
||||
expect(setPeek).not.toHaveBeenCalled()
|
||||
|
||||
// The refreshed cache now classifies the same position as inside.
|
||||
fireEvent.mouseMove(window, { clientX: 1200 })
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
|
||||
expect(setPeek).not.toHaveBeenCalled()
|
||||
|
||||
// A genuine exit still dismisses.
|
||||
fireEvent.mouseMove(window, { clientX: 400 })
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
|
||||
expect(setPeek).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('does not dismiss while a resize drag travels past the overlay edge', () => {
|
||||
const setPeek = vi.fn()
|
||||
renderHook(() =>
|
||||
useRightSidebarEdgePeekDismiss({
|
||||
isPeeking: true,
|
||||
isResizing: true,
|
||||
setPeek,
|
||||
overlayRef: makeOverlayRef(1000)
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.mouseMove(window, { clientX: 100 })
|
||||
vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS)
|
||||
|
||||
expect(setPeek).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses immediately when the window loses focus', () => {
|
||||
const setPeek = vi.fn()
|
||||
renderHook(() =>
|
||||
useRightSidebarEdgePeekDismiss({
|
||||
isPeeking: true,
|
||||
isResizing: false,
|
||||
setPeek,
|
||||
overlayRef: makeOverlayRef(1000)
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.blur(window)
|
||||
|
||||
expect(setPeek).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
// Arc-style edge peek: hovering the window's right edge while the sidebar is
|
||||
// closed reveals it as a floating overlay instead of reflowing the editor.
|
||||
// The enter delay keeps edge scrollbar grabs from flashing the sidebar open.
|
||||
export const PEEK_OPEN_DELAY_MS = 250
|
||||
export const PEEK_CLOSE_DELAY_MS = 300
|
||||
// The rightmost band that arms the peek, and the top zone it excludes.
|
||||
const PEEK_EDGE_TRIGGER_PX = 6
|
||||
const PEEK_TITLEBAR_ZONE_PX = 36
|
||||
|
||||
/**
|
||||
* Arms the edge peek while the sidebar is fully hidden. Detection is
|
||||
* geometry-based (a window mousemove comparing clientX against the right edge)
|
||||
* rather than an invisible DOM strip: a strip would swallow every mousedown in
|
||||
* the rightmost pixels, blocking scrollbar drags and clicks flush against the
|
||||
* window edge. Renders nothing.
|
||||
*/
|
||||
export function RightSidebarEdgePeekZone(): React.JSX.Element | null {
|
||||
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
|
||||
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
|
||||
const setRightSidebarPeek = useAppStore((s) => s.setRightSidebarPeek)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: the zone unmounts when the active view loses sidebar controls; a
|
||||
// peek flag surviving that would render a ghost peek (no hover behind it)
|
||||
// when the user returns to a sidebar-capable view.
|
||||
return () => setRightSidebarPeek(false)
|
||||
}, [setRightSidebarPeek])
|
||||
|
||||
useEffect(() => {
|
||||
// The revealed overlay covers the edge, so arming is only needed while the
|
||||
// sidebar is fully hidden. Skipping the listener here also guarantees an
|
||||
// armed timer can't fire after the sidebar opens (the cleanup clears it).
|
||||
if (rightSidebarOpen || rightSidebarPeek) {
|
||||
return
|
||||
}
|
||||
let openTimer: number | null = null
|
||||
const clearOpenTimer = (): void => {
|
||||
if (openTimer !== null) {
|
||||
window.clearTimeout(openTimer)
|
||||
openTimer = null
|
||||
}
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent): void => {
|
||||
// Why: exclude the top 36px titlebar row so the peek never arms over
|
||||
// window controls (Windows/Linux custom chrome).
|
||||
const inEdgeBand =
|
||||
event.clientY >= PEEK_TITLEBAR_ZONE_PX &&
|
||||
event.clientX >= window.innerWidth - PEEK_EDGE_TRIGGER_PX
|
||||
if (!inEdgeBand) {
|
||||
clearOpenTimer()
|
||||
return
|
||||
}
|
||||
if (openTimer === null) {
|
||||
openTimer = window.setTimeout(() => {
|
||||
openTimer = null
|
||||
setRightSidebarPeek(true)
|
||||
}, PEEK_OPEN_DELAY_MS)
|
||||
}
|
||||
}
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
return () => {
|
||||
clearOpenTimer()
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
}
|
||||
}, [rightSidebarOpen, rightSidebarPeek, setRightSidebarPeek])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses an active peek once the pointer moves left of the revealed
|
||||
* overlay. Dismissal is geometry-based (window mousemove) rather than
|
||||
* mouseleave-based: tooltips and menus portal outside the overlay element,
|
||||
* and a mouseleave dismiss would close the peek under an open popover.
|
||||
*/
|
||||
export function useRightSidebarEdgePeekDismiss(args: {
|
||||
isPeeking: boolean
|
||||
isResizing: boolean
|
||||
setPeek: (peek: boolean) => void
|
||||
overlayRef: React.RefObject<HTMLElement | null>
|
||||
}): void {
|
||||
const { isPeeking, isResizing, setPeek, overlayRef } = args
|
||||
// Why: the dismiss watcher reads resize state inside window listeners; a ref
|
||||
// avoids re-subscribing them on every drag frame.
|
||||
const isResizingRef = useRef(isResizing)
|
||||
isResizingRef.current = isResizing
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPeeking) {
|
||||
return
|
||||
}
|
||||
let closeTimer: number | null = null
|
||||
let lastClientX: number | null = null
|
||||
// Why: measuring the overlay on every mousemove forces layout. Cache its
|
||||
// left edge and invalidate only when it can move (a resize drag or a window
|
||||
// resize), so the common per-frame move reads no geometry.
|
||||
let cachedOverlayLeft: number | null = null
|
||||
const invalidateOverlayLeft = (): void => {
|
||||
cachedOverlayLeft = null
|
||||
}
|
||||
const cancelClose = (): void => {
|
||||
if (closeTimer !== null) {
|
||||
window.clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
}
|
||||
const scheduleClose = (): void => {
|
||||
if (closeTimer === null) {
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
// Why: the cached boundary may have been measured mid slide-in (the
|
||||
// entrance transform puts the rect near the window edge). Re-measure
|
||||
// and re-check here so a stale cache self-heals instead of
|
||||
// dismissing the peek under the cursor.
|
||||
const overlayLeft = overlayRef.current?.getBoundingClientRect().left ?? null
|
||||
cachedOverlayLeft = overlayLeft
|
||||
if (overlayLeft !== null && lastClientX !== null && lastClientX >= overlayLeft) {
|
||||
return
|
||||
}
|
||||
setPeek(false)
|
||||
}, PEEK_CLOSE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent): void => {
|
||||
lastClientX = event.clientX
|
||||
// A resize drag legitimately travels left of the overlay edge.
|
||||
if (isResizingRef.current) {
|
||||
invalidateOverlayLeft()
|
||||
cancelClose()
|
||||
return
|
||||
}
|
||||
if (cachedOverlayLeft === null) {
|
||||
cachedOverlayLeft = overlayRef.current?.getBoundingClientRect().left ?? null
|
||||
}
|
||||
if (cachedOverlayLeft === null || event.clientX >= cachedOverlayLeft) {
|
||||
cancelClose()
|
||||
} else {
|
||||
scheduleClose()
|
||||
}
|
||||
}
|
||||
const onWindowBlur = (): void => setPeek(false)
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
window.addEventListener('resize', invalidateOverlayLeft)
|
||||
return () => {
|
||||
cancelClose()
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
window.removeEventListener('resize', invalidateOverlayLeft)
|
||||
}
|
||||
}, [isPeeking, overlayRef, setPeek])
|
||||
}
|
||||
@@ -430,6 +430,9 @@ export type EditorSlice = {
|
||||
|
||||
// Right sidebar
|
||||
rightSidebarOpen: boolean
|
||||
// Transient edge-hover reveal (Arc-style). Never persisted: a peek is a
|
||||
// pointer gesture, not a saved layout choice like rightSidebarOpen.
|
||||
rightSidebarPeek: boolean
|
||||
rightSidebarWidth: number
|
||||
rightSidebarTab: ActiveRightSidebarTab
|
||||
rightSidebarExplorerView: RightSidebarExplorerView
|
||||
@@ -439,6 +442,7 @@ export type EditorSlice = {
|
||||
activityBarPosition: ActivityBarPosition
|
||||
toggleRightSidebar: () => void
|
||||
setRightSidebarOpen: (open: boolean) => void
|
||||
setRightSidebarPeek: (peek: boolean) => void
|
||||
setRightSidebarWidth: (width: number) => void
|
||||
setRightSidebarTab: (tab: ActiveRightSidebarTab) => void
|
||||
setRightSidebarExplorerView: (view: RightSidebarExplorerView) => void
|
||||
@@ -1439,6 +1443,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
|
||||
// Right sidebar
|
||||
rightSidebarOpen: false,
|
||||
rightSidebarPeek: false,
|
||||
rightSidebarWidth: 280,
|
||||
rightSidebarTab: 'explorer',
|
||||
rightSidebarExplorerView: 'files',
|
||||
@@ -1446,8 +1451,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
rightSidebarTabByWorktree: {},
|
||||
rightSidebarExplorerViewByWorktree: {},
|
||||
activityBarPosition: 'top',
|
||||
toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })),
|
||||
setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }),
|
||||
// Why: an explicit open/close supersedes a hover peek — clearing the flag
|
||||
// here keeps "toggle while peeking" meaning "pin it open".
|
||||
toggleRightSidebar: () =>
|
||||
set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen, rightSidebarPeek: false })),
|
||||
setRightSidebarOpen: (open) => set({ rightSidebarOpen: open, rightSidebarPeek: false }),
|
||||
setRightSidebarPeek: (peek) => set({ rightSidebarPeek: peek }),
|
||||
setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }),
|
||||
setRightSidebarTab: (tab) =>
|
||||
set((s) => ({
|
||||
|
||||
Reference in New Issue
Block a user