import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleKeybindPanel } from '@/store/keybinds'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
toggleSidebarOpen
} from '@/store/layout'
import { appViewForPath, isOverlayView } from '../routes'
import { titlebarButtonClass } from './titlebar'
export interface TitlebarTool {
id: string
label: string
active?: boolean
className?: string
disabled?: boolean
hidden?: boolean
href?: string
icon: ReactNode
onSelect?: () => void
title?: string
to?: string
}
export type TitlebarToolSide = 'left' | 'right'
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
tools?: readonly TitlebarTool[]
onOpenSettings: () => void
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const toggleHaptics = () => {
if (!hapticsMuted) {
triggerHaptic('tap')
}
toggleHapticsMuted()
if (hapticsMuted) {
window.requestAnimationFrame(() => triggerHaptic('success'))
}
}
// Each titlebar button controls the pane physically on its side, so a flip
// swaps which pane each one toggles. Default: sessions left, file browser
// right. Flipped: file browser left, sessions right. Sidebar toggles never
// carry an active highlight — they're plain show/hide affordances.
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
const leftToolbarTools: TitlebarTool[] = [
{
icon: ,
id: 'sidebar',
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
}
},
{
icon: ,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
},
title: t.titlebar.swapSidebarSidesTitle
},
...leftTools
]
const rightSidebarTool: TitlebarTool = {
icon: ,
id: 'right-sidebar',
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
}
}
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
active: hapticsMuted,
icon: ,
id: 'haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
icon: ,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
toggleKeybindPanel()
}
},
{
icon: ,
id: 'settings',
label: t.titlebar.openSettings,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
}
]
// While a full-screen overlay (settings, command center, …) is open it should
// visually own the window. These control clusters are `fixed` at a higher
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
// and let the overlay's own chrome (close button, drag region) take over.
if (isOverlayView(appViewForPath(location.pathname))) {
return null
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
const visiblePaneTools = tools.filter(tool => !tool.hidden)
return (
<>
{leftToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
))}
{/*
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
to either the static cluster's width (file-browser closed → cluster
sits flush against system tools) or the file-browser pane's width
(file-browser open → cluster sits flush against the file-browser pane,
i.e. at the preview pane's right edge). No margin hacks needed.
*/}
{visiblePaneTools.length > 0 && (
{visiblePaneTools.map(tool => (
))}
)}
{visibleSystemToolsBeforeSettings.map(tool => (
))}
{settingsTool && }
>
)
}
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType; tool: TitlebarTool }) {
// Titlebar actions never show an active background — state reads from the
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
// for a11y.
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
if (tool.href) {
return (
event.stopPropagation()}
rel="noreferrer"
target="_blank"
>
{tool.icon}
)
}
return (
{
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
type="button"
variant="ghost"
>
{tool.icon}
)
}