129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
/**
|
|
* Main Layout Component
|
|
* TitleBar at top, then sidebar + content below.
|
|
*/
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { LockKeyhole, Settings2 } from 'lucide-react';
|
|
import { Sidebar } from './Sidebar';
|
|
import { TitleBar } from './TitleBar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { getAiModuleForPath } from '@/lib/ai-modules';
|
|
import { useOpencodeStore } from '@/stores/opencode';
|
|
import { useProjectConfigStore } from '@/stores/project-config';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
import { cn } from '@/lib/utils';
|
|
import type { SidebarPeekSource } from './sidebar-peek';
|
|
|
|
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
|
|
|
|
export function MainLayout() {
|
|
const activeProject = useOpencodeStore((state) => state.activeProject);
|
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
|
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
|
|
const load = useProjectConfigStore((state) => state.load);
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [sidebarPeekOpen, setSidebarPeekOpen] = useState(false);
|
|
const sidebarPeekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const sidebarPeekSourcesRef = useRef<Record<SidebarPeekSource, boolean>>({
|
|
toggle: false,
|
|
focus: false,
|
|
sidebar: false,
|
|
titlebar: false,
|
|
});
|
|
const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting';
|
|
const isChatWorkspace = location.pathname === '/opencode-chat';
|
|
const isInitializationSafeRoute = location.pathname === '/project-config' || isPaintingModule;
|
|
|
|
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
|
if (!sidebarCollapsed) return;
|
|
|
|
const sources = sidebarPeekSourcesRef.current;
|
|
if (sources[source] === open) return;
|
|
|
|
if (sidebarPeekCloseTimerRef.current) {
|
|
clearTimeout(sidebarPeekCloseTimerRef.current);
|
|
sidebarPeekCloseTimerRef.current = null;
|
|
}
|
|
|
|
sources[source] = open;
|
|
|
|
if (open) {
|
|
setSidebarPeekOpen(true);
|
|
return;
|
|
}
|
|
|
|
if (Object.values(sources).some(Boolean)) return;
|
|
|
|
sidebarPeekCloseTimerRef.current = setTimeout(() => {
|
|
if (Object.values(sidebarPeekSourcesRef.current).some(Boolean)) return;
|
|
setSidebarPeekOpen(false);
|
|
sidebarPeekCloseTimerRef.current = null;
|
|
}, SIDEBAR_PEEK_CLOSE_DELAY_MS);
|
|
}, [sidebarCollapsed]);
|
|
|
|
useEffect(() => {
|
|
sidebarPeekSourcesRef.current = { toggle: false, focus: false, sidebar: false, titlebar: false };
|
|
// This transient state mirrors the external persisted sidebar preference;
|
|
// reset it when that preference changes so a stale preview cannot reopen.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setSidebarPeekOpen(false);
|
|
if (sidebarPeekCloseTimerRef.current) {
|
|
clearTimeout(sidebarPeekCloseTimerRef.current);
|
|
sidebarPeekCloseTimerRef.current = null;
|
|
}
|
|
}, [sidebarCollapsed]);
|
|
|
|
useEffect(() => () => {
|
|
if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (activeProject && !isPaintingModule) void load(activeProject.id).catch(() => undefined);
|
|
}, [activeProject, isPaintingModule, load]);
|
|
|
|
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
|
|
return (
|
|
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
|
|
{/* Title bar: drag region on macOS, icon + controls on Windows */}
|
|
<TitleBar
|
|
integrated
|
|
workspaceLayout={isChatWorkspace}
|
|
overlay={isPaintingModule}
|
|
sidebarPeekOpen={sidebarPeekOpen}
|
|
onSidebarPeekChange={handleSidebarPeekChange}
|
|
/>
|
|
|
|
{/* Below the title bar: sidebar + content */}
|
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
<Sidebar
|
|
workspaceLayout={isChatWorkspace}
|
|
sidebarPeekOpen={sidebarPeekOpen}
|
|
onSidebarPeekChange={handleSidebarPeekChange}
|
|
/>
|
|
<main
|
|
data-testid="main-content"
|
|
className={cn(
|
|
'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background',
|
|
isPaintingModule ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
|
|
isChatWorkspace && !isPaintingModule && 'basis-0 overflow-hidden p-0',
|
|
)}
|
|
>
|
|
<Outlet />
|
|
{initializationBlocked ? (
|
|
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
|
|
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
|
|
<LockKeyhole className="mx-auto h-10 w-10" />
|
|
<h1 className="mt-4 text-2xl font-semibold">项目尚未初始化</h1>
|
|
<p className="mt-3 text-sm font-medium text-muted-foreground">请先在项目配置中完成至少一个手动 Agent 的名称、模型和职责设置。</p>
|
|
<Button onClick={() => navigate('/project-config')} className="mt-6 border border-brand/20 bg-brand font-semibold text-primary-foreground"><Settings2 className="mr-2 h-4 w-4" />打开项目配置</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|