mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
179 lines
7.0 KiB
TypeScript
179 lines
7.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { View, PanResponder, Platform } from 'react-native'
|
|
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
|
import { colors } from '../../src/theme/mobile-theme'
|
|
import { themedStyleSheet } from '../../src/theme/themed-styles'
|
|
import { useMobileColorScheme } from '../../src/theme/use-mobile-color-scheme'
|
|
import { useResponsiveLayout } from '../../src/layout/responsive-layout'
|
|
import {
|
|
defaultHostSidebarWidth,
|
|
SPLIT_DETAIL_MIN_WIDTH
|
|
} from '../../src/layout/responsive-layout-metrics'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { useReleasedSideRail } from '../../src/layout/side-safe-area'
|
|
import {
|
|
HOST_SIDEBAR_MAX_WIDTH,
|
|
HOST_SIDEBAR_MIN_WIDTH,
|
|
loadHostSidebarWidth,
|
|
saveHostSidebarWidth
|
|
} from '../../src/storage/preferences'
|
|
import { HostProtocolGate } from '../../src/components/HostProtocolGate'
|
|
import { HostScreen } from '../../src/host-screen/HostScreen'
|
|
import { HostStack } from '../../src/navigation/host-stack'
|
|
import { HostSidebarRevealContext } from '../../src/layout/host-sidebar-reveal'
|
|
import { HostAreaServingContext } from '../../src/mobile-web-shell/host-area-serving'
|
|
import { usePageOwnsHostArea } from '../../src/mobile-web-shell/page-owns-host-area'
|
|
import { useReportedPageLocation } from '../../src/mobile-web-shell/page-location-report'
|
|
import { isOverlayPageRoute } from '../../src/new-workspace/overlay-page-route'
|
|
|
|
const RESIZE_EDGE_WIDTH = 24
|
|
|
|
// Clamp restored sidebar width to current window so a smaller device retains usable detail space.
|
|
function clampSidebarToWindow(width: number, windowWidth: number): number {
|
|
const hardMax = Math.max(
|
|
HOST_SIDEBAR_MIN_WIDTH,
|
|
Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - SPLIT_DETAIL_MIN_WIDTH)
|
|
)
|
|
return Math.min(hardMax, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width)))
|
|
}
|
|
|
|
export default function HostGroupLayout() {
|
|
useMobileColorScheme()
|
|
// Wide layout = tablet/foldable canvas (see responsive-layout-metrics).
|
|
const { isWideLayout, width: windowWidth } = useResponsiveLayout()
|
|
const { hostId } = useGlobalSearchParams<{ hostId?: string }>()
|
|
const pathname = usePathname()
|
|
const overlayRoute = isOverlayPageRoute(pathname)
|
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
// Keep the dragged width and clamp per render so a fold cannot shrink it for good.
|
|
const [savedSidebarWidth, setSidebarWidth] = useState<number | null>(null)
|
|
const sidebarWidth = clampSidebarToWindow(
|
|
savedSidebarWidth ?? defaultHostSidebarWidth(windowWidth),
|
|
windowWidth
|
|
)
|
|
|
|
// Refs keep once-created PanResponder handlers reading current widths.
|
|
const widthRef = useRef(sidebarWidth)
|
|
widthRef.current = sidebarWidth
|
|
const windowWidthRef = useRef(windowWidth)
|
|
windowWidthRef.current = windowWidth
|
|
const dragStartRef = useRef(sidebarWidth)
|
|
|
|
useEffect(() => {
|
|
let stale = false
|
|
void loadHostSidebarWidth().then((saved) => {
|
|
if (!stale) {
|
|
setSidebarWidth(saved)
|
|
}
|
|
})
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [])
|
|
|
|
// Host screens are pages that draw in the Duo's status-bar rail; the native sidebar pads itself.
|
|
useReleasedSideRail(Platform.OS !== 'web')
|
|
const railLeft = useSafeAreaInsets().left
|
|
const hideSidebar = useCallback(() => setSidebarOpen(false), [])
|
|
const revealSidebar = useCallback(() => setSidebarOpen(true), [])
|
|
const [hostAreaServing, setHostAreaServing] = useState(false)
|
|
const pageOwnsHostArea = usePageOwnsHostArea()
|
|
useReportedPageLocation()
|
|
// One owner: the page only with its init fact; natively all but a host route its page is serving.
|
|
const sidebarDrawnHere =
|
|
Platform.OS === 'web' ? pageOwnsHostArea : !(pathname === `/h/${hostId}` && hostAreaServing)
|
|
const showSidebar = isWideLayout && !!hostId && sidebarDrawnHere
|
|
const detailHasContent = !!hostId && pathname !== `/h/${hostId}`
|
|
// Only the page's session header can reveal it again; a native sidebar beside a page detail could never come back.
|
|
const canCollapseSidebar = Platform.OS === 'web' && showSidebar && detailHasContent
|
|
|
|
// Base host detail is a placeholder; hiding its sidebar would leave nothing useful.
|
|
useEffect(() => {
|
|
if (showSidebar && !detailHasContent) {
|
|
setSidebarOpen(true)
|
|
}
|
|
}, [detailHasContent, showSidebar])
|
|
|
|
// Use an overlaid leaf drag handle: Android ScrollView/FlatList captures touches before a parent PanResponder.
|
|
// Claim start/capture because nothing lies beneath the handle; keep it off the sidebar container.
|
|
const resizer = useRef(
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onStartShouldSetPanResponderCapture: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponderCapture: () => true,
|
|
onPanResponderTerminationRequest: () => false,
|
|
onPanResponderGrant: () => {
|
|
dragStartRef.current = widthRef.current
|
|
},
|
|
onPanResponderMove: (_evt, g) => {
|
|
setSidebarWidth(clampSidebarToWindow(dragStartRef.current + g.dx, windowWidthRef.current))
|
|
},
|
|
onPanResponderRelease: () => {
|
|
void saveHostSidebarWidth(widthRef.current)
|
|
},
|
|
onPanResponderTerminate: () => {
|
|
void saveHostSidebarWidth(widthRef.current)
|
|
}
|
|
})
|
|
).current
|
|
|
|
// Keep detail Stack at a stable tree position so fold/rotation cannot remount navigation.
|
|
return (
|
|
<HostProtocolGate hostId={hostId} transparentWhilePending={overlayRoute}>
|
|
<View style={[styles.row, overlayRoute && styles.transparent]}>
|
|
{showSidebar && sidebarOpen ? (
|
|
<View style={[styles.sidebar, { width: sidebarWidth, paddingLeft: railLeft }]}>
|
|
<HostScreen
|
|
embedded
|
|
hostId={hostId}
|
|
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
|
|
actionsInRail={!detailHasContent}
|
|
/>
|
|
{/* Edge drag handle captures Android gestures above the list. */}
|
|
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
|
</View>
|
|
) : null}
|
|
<View style={styles.detail}>
|
|
<HostAreaServingContext.Provider value={setHostAreaServing}>
|
|
<HostSidebarRevealContext.Provider
|
|
value={showSidebar && !sidebarOpen ? revealSidebar : null}
|
|
>
|
|
<HostStack animation={showSidebar ? 'none' : 'default'} />
|
|
</HostSidebarRevealContext.Provider>
|
|
</HostAreaServingContext.Provider>
|
|
</View>
|
|
</View>
|
|
</HostProtocolGate>
|
|
)
|
|
}
|
|
|
|
const styles = themedStyleSheet(() => ({
|
|
row: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
backgroundColor: colors.bgBase
|
|
},
|
|
transparent: {
|
|
backgroundColor: 'transparent'
|
|
},
|
|
sidebar: {
|
|
borderRightWidth: 1,
|
|
borderRightColor: colors.borderSubtle
|
|
},
|
|
// Elevated edge strip must sit above list to own Android drags.
|
|
resizeHandle: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
right: 0,
|
|
width: RESIZE_EDGE_WIDTH,
|
|
zIndex: 20,
|
|
elevation: 20
|
|
},
|
|
detail: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
}
|
|
}))
|