301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
|
* TitleBar Component
|
|
* macOS: light application toolbar with native traffic lights overlaid by the
|
|
* full-size hidden title bar.
|
|
* Windows: drag region with custom minimize/maximize/close controls.
|
|
* Linux: use native window chrome (no custom title bar).
|
|
*/
|
|
import { useState, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Copy, FolderOpen, Minus, PanelLeft, PanelLeftClose, Square, X } from 'lucide-react';
|
|
import { invokeIpc } from '@/lib/api-client';
|
|
import { cn } from '@/lib/utils';
|
|
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
|
|
import type { SidebarPeekSource } from './sidebar-peek';
|
|
|
|
type SidebarPeekChange = (open: boolean, source: SidebarPeekSource) => void;
|
|
|
|
// Three 44px buttons plus the control container's 8px padding on each side.
|
|
const WINDOWS_TITLEBAR_CONTROLS_WIDTH = 148;
|
|
|
|
type TitleBarProps = {
|
|
integrated?: boolean;
|
|
workspaceLayout?: boolean;
|
|
overlay?: boolean;
|
|
pageTitle?: string;
|
|
sidebarPeekOpen?: boolean;
|
|
onSidebarPeekChange?: SidebarPeekChange;
|
|
};
|
|
|
|
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
|
const platform = window.electron?.platform;
|
|
|
|
if (platform === 'darwin') {
|
|
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
|
}
|
|
|
|
// Linux keeps the native frame/title bar for better IME compatibility.
|
|
if (platform !== 'win32') {
|
|
return null;
|
|
}
|
|
|
|
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
|
}
|
|
|
|
function ProductTitleBar({
|
|
integrated,
|
|
workspaceLayout = false,
|
|
overlay = false,
|
|
pageTitle,
|
|
children,
|
|
windowControlsWidth = 0,
|
|
nativeTrafficLights = false,
|
|
sidebarPeekOpen = false,
|
|
onSidebarPeekChange,
|
|
}: {
|
|
integrated: boolean;
|
|
workspaceLayout?: boolean;
|
|
overlay?: boolean;
|
|
pageTitle?: string;
|
|
children?: React.ReactNode;
|
|
windowControlsWidth?: number;
|
|
nativeTrafficLights?: boolean;
|
|
sidebarPeekOpen?: boolean;
|
|
onSidebarPeekChange?: SidebarPeekChange;
|
|
}) {
|
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
|
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
|
const toolbarButtonClass = 'no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground';
|
|
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
|
const toggleSidebar = () => setSidebarCollapsed(!sidebarCollapsed);
|
|
const handleSidebarPeekEnter = (source: SidebarPeekSource) => {
|
|
if (sidebarCollapsed) onSidebarPeekChange?.(true, source);
|
|
};
|
|
const handleSidebarPeekLeave = (source: SidebarPeekSource) => {
|
|
if (sidebarCollapsed) onSidebarPeekChange?.(false, source);
|
|
};
|
|
// The project sidebar preview is rendered below the title bar, so mirror
|
|
// its full width in the title bar for ordinary pages. The chat workspace
|
|
// has an independent conversation rail and must keep its pinned columns.
|
|
const sidebarPreviewTitlebarVisible = sidebarCollapsed && sidebarPeekOpen && !workspaceLayout && !overlay;
|
|
const sidebarTitlebarHidden = sidebarCollapsed && !sidebarPreviewTitlebarVisible;
|
|
const conversationSurfaceOffset = sidebarTitlebarHidden
|
|
? (nativeTrafficLights ? '-132px' : '-52px')
|
|
: '0px';
|
|
const sidebarToggle = (
|
|
<button
|
|
type="button"
|
|
data-testid="titlebar-sidebar-toggle"
|
|
aria-label={sidebarCollapsed ? (sidebarPeekOpen ? '固定侧栏' : '展开侧栏') : '折叠侧栏'}
|
|
aria-expanded={!sidebarCollapsed || sidebarPeekOpen}
|
|
title={sidebarCollapsed ? (sidebarPeekOpen ? '固定侧栏' : '展开侧栏') : '折叠侧栏'}
|
|
className={cn(
|
|
toolbarButtonClass,
|
|
'pointer-events-auto absolute top-1 z-[401]',
|
|
nativeTrafficLights ? 'left-[88px]' : 'left-2',
|
|
)}
|
|
onClick={toggleSidebar}
|
|
onPointerEnter={() => handleSidebarPeekEnter('toggle')}
|
|
onPointerLeave={() => handleSidebarPeekLeave('toggle')}
|
|
onFocus={() => handleSidebarPeekEnter('focus')}
|
|
onBlur={() => handleSidebarPeekLeave('focus')}
|
|
>
|
|
{sidebarCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
|
</button>
|
|
);
|
|
const sidebarTogglePortal = (
|
|
<div
|
|
data-testid="titlebar-sidebar-toggle-layer"
|
|
className={cn(
|
|
'no-drag pointer-events-auto fixed left-0 top-0 z-[400] h-10',
|
|
nativeTrafficLights ? 'w-[132px]' : 'w-[52px]',
|
|
)}
|
|
>
|
|
{sidebarToggle}
|
|
</div>
|
|
);
|
|
const titlebarLogo = (
|
|
<div
|
|
data-testid="titlebar-logo"
|
|
className={cn(
|
|
'pointer-events-none absolute top-1/2 z-10 h-6 w-24 -translate-y-1/2 overflow-hidden',
|
|
overlay ? 'bg-transparent' : 'bg-background',
|
|
)}
|
|
style={{
|
|
right: `${windowControlsWidth}px`,
|
|
}}
|
|
>
|
|
<img
|
|
src={logoWordmarkSource}
|
|
alt="Makelore logo"
|
|
data-logo-variant="wordmark"
|
|
data-logo-source="original-wordmark"
|
|
className="h-full w-full object-cover object-center mix-blend-multiply"
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
data-testid="product-titlebar"
|
|
className={cn(
|
|
overlay ? 'absolute' : 'relative',
|
|
'z-30 flex h-10 shrink-0 text-foreground',
|
|
overlay && 'pointer-events-none inset-x-0 top-0 z-[300] bg-transparent',
|
|
(!nativeTrafficLights || !integrated) && 'drag-region',
|
|
!workspaceLayout && !overlay && 'glass-surface',
|
|
integrated && !overlay && (workspaceLayout ? 'bg-transparent' : sidebarTitlebarHidden ? 'bg-background' : 'bg-background/70'),
|
|
integrated && !overlay && sidebarTitlebarHidden && !workspaceLayout && 'border-b border-border/80',
|
|
)}
|
|
>
|
|
{integrated ? (
|
|
<>
|
|
<div
|
|
data-testid="titlebar-sidebar-surface"
|
|
className={cn(
|
|
'flex h-full shrink-0 items-center gap-1 px-2',
|
|
sidebarTitlebarHidden
|
|
? nativeTrafficLights
|
|
? 'w-[132px]'
|
|
: 'w-[52px]'
|
|
: workspaceLayout
|
|
? 'w-[256px] min-w-[256px] basis-[256px]'
|
|
: 'w-64',
|
|
overlay
|
|
? 'pointer-events-none border-r-0 bg-transparent'
|
|
: sidebarTitlebarHidden
|
|
? 'border-r-0 bg-background'
|
|
: cn(
|
|
sidebarPreviewTitlebarVisible ? 'sidebar-peek-surface' : 'sidebar-glass-surface',
|
|
'border-r border-border/80',
|
|
),
|
|
nativeTrafficLights && 'pl-[88px]',
|
|
)}
|
|
onPointerEnter={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekEnter('titlebar') : undefined}
|
|
onPointerLeave={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekLeave('titlebar') : undefined}
|
|
/>
|
|
{typeof document !== 'undefined' ? createPortal(sidebarTogglePortal, document.body) : sidebarTogglePortal}
|
|
</>
|
|
) : null}
|
|
{integrated ? (
|
|
<div
|
|
data-testid="titlebar-main-surface"
|
|
className={cn(
|
|
'relative flex min-w-0 flex-1 bg-background',
|
|
overlay && 'pointer-events-none border-b-0 bg-transparent',
|
|
nativeTrafficLights && 'drag-region',
|
|
!overlay && (workspaceLayout || sidebarTitlebarHidden ? 'border-b-0' : 'border-b border-border/80'),
|
|
)}
|
|
style={{
|
|
marginRight: 'var(--agent-browser-panel-width, 0px)',
|
|
}}
|
|
>
|
|
{workspaceLayout ? (
|
|
<div
|
|
data-testid="titlebar-conversation-surface"
|
|
className="pointer-events-none absolute inset-y-0 z-0 w-[256px] border-r border-border/80 bg-surface-tertiary"
|
|
style={{ left: conversationSurfaceOffset }}
|
|
/>
|
|
) : null}
|
|
{pageTitle ? (
|
|
<h1
|
|
data-testid="titlebar-page-title"
|
|
className="flex min-w-0 flex-1 items-center truncate px-3 text-sm font-semibold tracking-[-0.02em]"
|
|
>
|
|
{pageTitle}
|
|
</h1>
|
|
) : !workspaceLayout && !overlay ? (
|
|
<div
|
|
data-testid="titlebar-project-context"
|
|
className={cn(
|
|
'flex min-w-0 flex-1 items-center gap-2 px-3',
|
|
)}
|
|
>
|
|
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
<span className="truncate text-sm font-semibold">{activeProject?.name ?? 'Makelore 工作台'}</span>
|
|
</div>
|
|
) : null}
|
|
{!overlay ? titlebarLogo : null}
|
|
<div
|
|
className={cn(
|
|
'no-drag absolute inset-y-0 right-0 z-10 flex items-center justify-end gap-1 px-2',
|
|
overlay && 'pointer-events-auto',
|
|
)}
|
|
style={windowControlsWidth ? { width: `${windowControlsWidth}px` } : undefined}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 px-2">{children}</div>
|
|
)}
|
|
{integrated && overlay ? titlebarLogo : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
|
const [maximized, setMaximized] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Check initial state
|
|
invokeIpc('window:isMaximized').then((val) => {
|
|
setMaximized(val as boolean);
|
|
});
|
|
}, []);
|
|
|
|
const handleMinimize = () => {
|
|
invokeIpc('window:minimize');
|
|
};
|
|
|
|
const handleMaximize = () => {
|
|
invokeIpc('window:maximize').then(() => {
|
|
invokeIpc('window:isMaximized').then((val) => {
|
|
setMaximized(val as boolean);
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleClose = () => {
|
|
invokeIpc('window:close');
|
|
};
|
|
|
|
return (
|
|
<ProductTitleBar
|
|
integrated={integrated}
|
|
workspaceLayout={workspaceLayout}
|
|
overlay={overlay}
|
|
pageTitle={pageTitle}
|
|
windowControlsWidth={WINDOWS_TITLEBAR_CONTROLS_WIDTH}
|
|
sidebarPeekOpen={sidebarPeekOpen}
|
|
onSidebarPeekChange={onSidebarPeekChange}
|
|
>
|
|
<div className="no-drag pointer-events-auto flex h-full">
|
|
<button
|
|
onClick={handleMinimize}
|
|
className="motion-press flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-surface-subtle"
|
|
title="Minimize"
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleMaximize}
|
|
className="motion-press flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-surface-subtle"
|
|
title={maximized ? 'Restore' : 'Maximize'}
|
|
>
|
|
{maximized ? <Copy className="h-3.5 w-3.5" /> : <Square className="h-3.5 w-3.5" />}
|
|
</button>
|
|
<button
|
|
onClick={handleClose}
|
|
className="motion-press flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent hover:text-primary-foreground"
|
|
title="Close"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</ProductTitleBar>
|
|
);
|
|
}
|