154 lines
6.7 KiB
TypeScript
154 lines
6.7 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import type { CSSProperties, ReactNode } from 'react'
|
|
import { useSyncExternalStore } from 'react'
|
|
|
|
import { Backdrop } from '@/components/Backdrop'
|
|
import { PaneShell } from '@/components/pane-shell'
|
|
import { SidebarProvider } from '@/components/ui/sidebar'
|
|
import {
|
|
$fileBrowserOpen,
|
|
$sidebarOpen,
|
|
FILE_BROWSER_DEFAULT_WIDTH,
|
|
FILE_BROWSER_PANE_ID,
|
|
setSidebarOpen
|
|
} from '@/store/layout'
|
|
import { $paneWidthOverride } from '@/store/panes'
|
|
import { $connection } from '@/store/session'
|
|
|
|
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
|
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
|
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
|
|
|
interface AppShellProps {
|
|
children: ReactNode
|
|
leftStatusbarItems?: readonly StatusbarItem[]
|
|
leftTitlebarTools?: readonly TitlebarTool[]
|
|
onOpenSettings: () => void
|
|
overlays?: ReactNode
|
|
statusbarItems?: readonly StatusbarItem[]
|
|
titlebarTools?: readonly TitlebarTool[]
|
|
}
|
|
|
|
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
|
|
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
|
|
function subscribeWindowSize(cb: () => void) {
|
|
window.addEventListener('resize', cb)
|
|
window.addEventListener('fullscreenchange', cb)
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', cb)
|
|
window.removeEventListener('fullscreenchange', cb)
|
|
}
|
|
}
|
|
|
|
const viewportIsFullscreen = () =>
|
|
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
|
|
|
|
export function AppShell({
|
|
children,
|
|
leftStatusbarItems,
|
|
leftTitlebarTools,
|
|
onOpenSettings,
|
|
overlays,
|
|
statusbarItems,
|
|
titlebarTools
|
|
}: AppShellProps) {
|
|
const sidebarOpen = useStore($sidebarOpen)
|
|
const fileBrowserOpen = useStore($fileBrowserOpen)
|
|
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
|
const connection = useStore($connection)
|
|
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
|
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
|
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
|
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
|
// on macOS, where window controls sit on the left and are reported via
|
|
// windowButtonPosition instead). The right tool cluster has to clear them.
|
|
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
|
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
|
|
|
const titlebarContentInset = sidebarOpen
|
|
? 0
|
|
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
|
|
|
// The static system cluster (file-browser, haptics, settings) is hardcoded
|
|
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
|
|
// separate cluster anchored further left.
|
|
//
|
|
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
|
|
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
|
|
// between the pane-tool cluster and the system cluster so they don't sit
|
|
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
|
|
// to keep the formula generic for any pane-tool count.
|
|
const SYSTEM_TOOL_COUNT = 4
|
|
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
|
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
|
|
|
|
const fileBrowserWidth =
|
|
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
|
|
|
|
// Where the pane-tool cluster's right edge sits, measured from the inner
|
|
// titlebar padding (--titlebar-tools-right). Two anchors:
|
|
// - file-browser closed → flush against static cluster's left edge
|
|
// - file-browser open → flush against the file-browser pane's left edge
|
|
// (= preview pane's right edge)
|
|
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
|
|
|
|
// Used by the drag region to know where the rightmost interactive element
|
|
// ends. When pane tools are present, that's `gap + paneCount * controlSize
|
|
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
|
|
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
|
|
// is enough.
|
|
const titlebarToolsWidth =
|
|
paneToolCount > 0
|
|
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
|
|
: systemToolsWidth
|
|
|
|
return (
|
|
<SidebarProvider
|
|
className="h-screen min-h-0 bg-background"
|
|
onOpenChange={setSidebarOpen}
|
|
open={sidebarOpen}
|
|
style={
|
|
{
|
|
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
|
|
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
|
|
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
|
|
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
|
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
|
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
|
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
|
'--titlebar-tools-right': titlebarToolsRight,
|
|
'--titlebar-tools-width': titlebarToolsWidth,
|
|
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
|
// Sourced from the layout store rather than the PaneShell-emitted
|
|
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
|
// and CSS variables resolve at the consumer's scope.
|
|
'--shell-preview-toolbar-gap': previewToolbarGap
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
|
|
|
<Backdrop />
|
|
<main className="relative z-3 flex h-screen w-full flex-col overflow-hidden pr-0.75 pt-0.75 transition-none">
|
|
<PaneShell className="min-h-0 flex-1">
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
|
/>
|
|
|
|
{children}
|
|
</PaneShell>
|
|
|
|
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
|
</main>
|
|
|
|
{overlays}
|
|
</SidebarProvider>
|
|
)
|
|
}
|