重构项目详情页面
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
Hash,
|
||||
Command,
|
||||
PanelLeftOpen,
|
||||
PanelRightOpen
|
||||
PanelRightOpen,
|
||||
ArrowUp,
|
||||
ArrowDown
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -41,7 +43,13 @@ import { FileEditorEnhanced } from "./FileEditorEnhanced";
|
||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks";
|
||||
import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout";
|
||||
// import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout";
|
||||
|
||||
// 新增布局组件导入
|
||||
import { FlexLayoutContainer } from "@/components/layout/FlexLayoutContainer";
|
||||
import { MainContentArea } from "@/components/layout/MainContentArea";
|
||||
import { SidePanel } from "@/components/layout/SidePanel";
|
||||
import { ChatView } from "@/components/layout/ChatView";
|
||||
|
||||
interface ClaudeCodeSessionProps {
|
||||
/**
|
||||
@@ -94,8 +102,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
toggleTimeline,
|
||||
setPanelWidth,
|
||||
setSplitPosition: setLayoutSplitPosition,
|
||||
getGridTemplateColumns,
|
||||
getResponsiveClasses
|
||||
getResponsiveClasses,
|
||||
openFileEditor,
|
||||
closeFileEditor,
|
||||
openPreview: openLayoutPreview,
|
||||
closePreview: closeLayoutPreview
|
||||
} = layoutManager;
|
||||
|
||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||
@@ -118,17 +129,30 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
// Queued prompts state
|
||||
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
|
||||
|
||||
// New state for preview feature
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
// 使用布局管理器的预览功能
|
||||
const handleOpenPreview = useCallback((url: string) => {
|
||||
openLayoutPreview(url);
|
||||
setShowPreviewPrompt(false);
|
||||
}, [openLayoutPreview]);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
closeLayoutPreview();
|
||||
setIsPreviewMaximized(false);
|
||||
}, [closeLayoutPreview]);
|
||||
|
||||
// 添加临时状态用于预览提示
|
||||
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
|
||||
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
||||
const [showScrollButtons, setShowScrollButtons] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(true);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
// Add collapsed state for queued prompts
|
||||
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
|
||||
|
||||
// File editor state
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||
// 移除重复的状态,使用 layout 中的状态
|
||||
// const [editingFile, setEditingFile] = useState<string | null>(null); // 移除,使用 layout.editingFile
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const unlistenRefs = useRef<UnlistenFn[]>([]);
|
||||
@@ -287,13 +311,30 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isLoading, claudeSessionId);
|
||||
}, [isLoading, claudeSessionId, onStreamingChange]);
|
||||
|
||||
// 滚动到顶部
|
||||
const scrollToTop = useCallback(() => {
|
||||
if (parentRef.current) {
|
||||
parentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (parentRef.current) {
|
||||
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (displayableMessages.length > 0) {
|
||||
rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' });
|
||||
// 使用setTimeout确保DOM更新后再滚动
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [displayableMessages.length, rowVirtualizer]);
|
||||
}, [displayableMessages.length, scrollToBottom]);
|
||||
|
||||
// Calculate total tokens from messages
|
||||
useEffect(() => {
|
||||
@@ -1067,32 +1108,51 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle URL detection from terminal output
|
||||
// 处理URL检测
|
||||
const handleLinkDetected = (url: string) => {
|
||||
if (!showPreview && !showPreviewPrompt) {
|
||||
setPreviewUrl(url);
|
||||
if (!layout.previewUrl && !showPreviewPrompt) {
|
||||
openLayoutPreview(url);
|
||||
setShowPreviewPrompt(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setShowPreview(false);
|
||||
setIsPreviewMaximized(false);
|
||||
// Keep the previewUrl so it can be restored when reopening
|
||||
};
|
||||
|
||||
const handlePreviewUrlChange = (url: string) => {
|
||||
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
||||
setPreviewUrl(url);
|
||||
};
|
||||
|
||||
|
||||
// 监听滚动位置
|
||||
useEffect(() => {
|
||||
const scrollContainer = parentRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
setIsAtTop(scrollTop < 10);
|
||||
setIsAtBottom(scrollTop + clientHeight >= scrollHeight - 10);
|
||||
setShowScrollButtons(scrollHeight > clientHeight);
|
||||
};
|
||||
|
||||
handleScroll(); // 初始检查
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
|
||||
// 监听内容变化
|
||||
const observer = new ResizeObserver(handleScroll);
|
||||
observer.observe(scrollContainer);
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTogglePreviewMaximize = () => {
|
||||
setIsPreviewMaximized(!isPreviewMaximized);
|
||||
// Reset split position when toggling maximize
|
||||
// 重置分割位置
|
||||
if (isPreviewMaximized) {
|
||||
setLayoutSplitPosition(50);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewUrlChange = (url: string) => {
|
||||
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
||||
openLayoutPreview(url);
|
||||
};
|
||||
|
||||
// Cleanup event listeners and track mount state
|
||||
useEffect(() => {
|
||||
@@ -1150,20 +1210,23 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
const messagesList = (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="flex-1 overflow-y-auto relative pb-24"
|
||||
style={{
|
||||
contain: 'strict',
|
||||
}}
|
||||
className="h-full overflow-y-auto relative pb-2"
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-5xl mx-auto px-4 pt-8 pb-4"
|
||||
className="relative w-full max-w-5xl mx-auto px-4 pt-3 pb-2"
|
||||
style={{
|
||||
height: `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
|
||||
height: displayableMessages.length === 0 ? '100%' : `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
|
||||
minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
{displayableMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
|
||||
<Terminal className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">开始对话或等待消息加载...</p>
|
||||
</div>
|
||||
) : (
|
||||
rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const message = displayableMessages[virtualItem.index];
|
||||
return (
|
||||
<motion.div
|
||||
@@ -1174,7 +1237,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-x-4 pb-4"
|
||||
className="absolute inset-x-4 pb-3"
|
||||
style={{
|
||||
top: virtualItem.start,
|
||||
}}
|
||||
@@ -1186,7 +1249,8 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -1195,7 +1259,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-center py-4 mb-40"
|
||||
className="flex items-center justify-center py-2 mb-4"
|
||||
>
|
||||
<div className="rotating-symbol text-primary" />
|
||||
</motion.div>
|
||||
@@ -1206,11 +1270,62 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive mb-40 w-full max-w-5xl mx-auto"
|
||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive w-full max-w-5xl mx-auto mb-4"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 滚动按钮 */}
|
||||
<AnimatePresence>
|
||||
{showScrollButtons && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="fixed bottom-20 right-6 z-40 flex flex-col gap-2"
|
||||
>
|
||||
{!isAtTop && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={scrollToTop}
|
||||
className="h-9 w-9 rounded-full shadow-lg bg-background/95 backdrop-blur"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>滚动到顶部</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!isAtBottom && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={scrollToBottom}
|
||||
className="h-9 w-9 rounded-full shadow-lg bg-background/95 backdrop-blur"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>滚动到底部</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1246,7 +1361,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
);
|
||||
|
||||
// If preview is maximized, render only the WebviewPreview in full screen
|
||||
if (showPreview && isPreviewMaximized) {
|
||||
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
@@ -1257,7 +1372,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<WebviewPreview
|
||||
initialUrl={previewUrl}
|
||||
initialUrl={layout.previewUrl || ''}
|
||||
onClose={handleClosePreview}
|
||||
isMaximized={isPreviewMaximized}
|
||||
onToggleMaximize={handleTogglePreviewMaximize}
|
||||
@@ -1300,6 +1415,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Token计数器 */}
|
||||
{totalTokens > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs bg-muted/50 rounded-full px-2.5 py-1">
|
||||
<Hash className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono">{totalTokens.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">tokens</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Explorer Toggle */}
|
||||
{projectPath && (
|
||||
<TooltipProvider>
|
||||
@@ -1460,319 +1584,194 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Content Area with Grid Layout */}
|
||||
<GridLayoutContainer
|
||||
{/* 使用新的 FlexLayoutContainer 替代 GridLayoutContainer */}
|
||||
<FlexLayoutContainer
|
||||
className="flex-1 overflow-hidden"
|
||||
gridTemplateColumns={getGridTemplateColumns()}
|
||||
isMobile={breakpoints.isMobile}
|
||||
isTablet={breakpoints.isTablet}
|
||||
showFileExplorer={layout.showFileExplorer}
|
||||
showGitPanel={layout.showGitPanel}
|
||||
showTimeline={layout.showTimeline}
|
||||
>
|
||||
{/* File Explorer Panel */}
|
||||
{layout.showFileExplorer && (
|
||||
<ResponsivePanel
|
||||
isVisible={layout.showFileExplorer}
|
||||
position="left"
|
||||
width={layout.fileExplorerWidth}
|
||||
isMobile={breakpoints.isMobile}
|
||||
onClose={toggleFileExplorer}
|
||||
resizable={!breakpoints.isMobile}
|
||||
onResize={(width) => setPanelWidth('fileExplorer', width)}
|
||||
minWidth={200}
|
||||
maxWidth={500}
|
||||
>
|
||||
<FileExplorerPanelEnhanced
|
||||
projectPath={projectPath}
|
||||
isVisible={true}
|
||||
onFileSelect={(path) => {
|
||||
floatingPromptRef.current?.addImage(path);
|
||||
}}
|
||||
onFileOpen={(path) => {
|
||||
setEditingFile(path);
|
||||
}}
|
||||
onToggle={toggleFileExplorer}
|
||||
/>
|
||||
</ResponsivePanel>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 relative flex flex-col overflow-hidden">
|
||||
{showPreview ? (
|
||||
// Split pane layout when preview is active
|
||||
<div className="h-full">
|
||||
<SplitPane
|
||||
left={
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 flex flex-col mx-auto w-full px-4">
|
||||
{projectPathInput}
|
||||
{messagesList}
|
||||
</div>
|
||||
{/* Floating Input for preview mode */}
|
||||
<div className="mx-auto w-full relative px-4">
|
||||
<FloatingPromptInput
|
||||
ref={floatingPromptRef}
|
||||
onSend={handleSendPrompt}
|
||||
onCancel={handleCancelExecution}
|
||||
isLoading={isLoading}
|
||||
disabled={!projectPath}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<WebviewPreview
|
||||
initialUrl={previewUrl}
|
||||
onClose={handleClosePreview}
|
||||
isMaximized={isPreviewMaximized}
|
||||
onToggleMaximize={handleTogglePreviewMaximize}
|
||||
onUrlChange={handlePreviewUrlChange}
|
||||
/>
|
||||
}
|
||||
initialSplit={layout.splitPosition}
|
||||
onSplitChange={(position) => {
|
||||
setLayoutSplitPosition(position);
|
||||
}}
|
||||
minLeftWidth={400}
|
||||
minRightWidth={400}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
) : editingFile ? (
|
||||
// File Editor layout with enhanced features
|
||||
<div className="h-full flex flex-col relative">
|
||||
<FileEditorEnhanced
|
||||
filePath={editingFile}
|
||||
onClose={() => setEditingFile(null)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Original layout when no preview or editor
|
||||
<div className="h-full flex flex-col relative">
|
||||
{/* Main content area with messages */}
|
||||
<div className="flex-1 flex flex-col mx-auto w-full px-4">
|
||||
{projectPathInput}
|
||||
{messagesList}
|
||||
|
||||
{isLoading && messages.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rotating-symbol text-primary" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{session ? "Loading session history..." : "Initializing Claude Code..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating elements container - same width as main content */}
|
||||
<div className="mx-auto w-full relative px-4">
|
||||
<ErrorBoundary>
|
||||
{/* Queued Prompts Display */}
|
||||
<AnimatePresence>
|
||||
{queuedPrompts.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="absolute bottom-24 left-0 right-0 z-30"
|
||||
>
|
||||
<div className="mx-4">
|
||||
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Queued Prompts ({queuedPrompts.length})
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setQueuedPromptsCollapsed(prev => !prev)}>
|
||||
{queuedPromptsCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
|
||||
<motion.div
|
||||
key={queuedPrompt.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex items-start gap-2 bg-muted/50 rounded-md p-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">#{index + 1}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2 break-words">{queuedPrompt.prompt}</p>
|
||||
mainContentId="main-content"
|
||||
panels={[
|
||||
// 文件浏览器面板
|
||||
{
|
||||
id: 'file-explorer',
|
||||
position: 'left',
|
||||
visible: layout.showFileExplorer,
|
||||
defaultWidth: layout.fileExplorerWidth,
|
||||
minWidth: 200,
|
||||
maxWidth: 500,
|
||||
resizable: !breakpoints.isMobile,
|
||||
content: (
|
||||
<FileExplorerPanelEnhanced
|
||||
projectPath={projectPath}
|
||||
isVisible={true}
|
||||
onFileSelect={(path) => {
|
||||
floatingPromptRef.current?.addImage(path);
|
||||
}}
|
||||
onFileOpen={(path) => {
|
||||
openFileEditor(path);
|
||||
}}
|
||||
onToggle={toggleFileExplorer}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// 主内容区域
|
||||
{
|
||||
id: 'main-content',
|
||||
position: 'center',
|
||||
visible: true,
|
||||
content: (
|
||||
<MainContentArea isEditing={layout.activeView === 'editor'}>
|
||||
{layout.activeView === 'editor' && layout.editingFile ? (
|
||||
// 文件编辑器视图
|
||||
<FileEditorEnhanced
|
||||
filePath={layout.editingFile}
|
||||
onClose={closeFileEditor}
|
||||
className="h-full"
|
||||
/>
|
||||
) : layout.activeView === 'preview' && layout.previewUrl ? (
|
||||
// 预览视图
|
||||
<SplitPane
|
||||
left={
|
||||
<ChatView
|
||||
projectPathInput={projectPathInput}
|
||||
messagesList={messagesList}
|
||||
floatingInput={
|
||||
<div className="w-full max-w-5xl mx-auto px-4">
|
||||
<FloatingPromptInput
|
||||
ref={floatingPromptRef}
|
||||
onSend={handleSendPrompt}
|
||||
onCancel={handleCancelExecution}
|
||||
isLoading={isLoading}
|
||||
disabled={!projectPath}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<WebviewPreview
|
||||
initialUrl={layout.previewUrl}
|
||||
onClose={handleClosePreview}
|
||||
isMaximized={isPreviewMaximized}
|
||||
onToggleMaximize={handleTogglePreviewMaximize}
|
||||
onUrlChange={handlePreviewUrlChange}
|
||||
/>
|
||||
}
|
||||
initialSplit={layout.splitPosition}
|
||||
onSplitChange={(position) => {
|
||||
setLayoutSplitPosition(position);
|
||||
}}
|
||||
minLeftWidth={400}
|
||||
minRightWidth={400}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
// 默认聊天视图
|
||||
<ChatView
|
||||
projectPathInput={projectPathInput}
|
||||
messagesList={messagesList}
|
||||
floatingInput={
|
||||
<div className="w-full max-w-5xl mx-auto px-4">
|
||||
<FloatingPromptInput
|
||||
ref={floatingPromptRef}
|
||||
onSend={handleSendPrompt}
|
||||
onCancel={handleCancelExecution}
|
||||
isLoading={isLoading}
|
||||
disabled={!projectPath}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
}
|
||||
floatingElements={
|
||||
<>
|
||||
{/* 排队提示显示 */}
|
||||
<AnimatePresence>
|
||||
{queuedPrompts.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="absolute bottom-20 left-0 right-0 z-30 pointer-events-auto px-4"
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Queued Prompts ({queuedPrompts.length})
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setQueuedPromptsCollapsed(prev => !prev)}>
|
||||
{queuedPromptsCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
|
||||
<motion.div
|
||||
key={queuedPrompt.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex items-start gap-2 bg-muted/50 rounded-md p-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">#{index + 1}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2 break-words">{queuedPrompt.prompt}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{displayableMessages.length > 5 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="absolute bottom-32 right-6 z-50"
|
||||
>
|
||||
<div className="flex items-center bg-background/95 backdrop-blur-md border rounded-full shadow-lg overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (displayableMessages.length > 0) {
|
||||
parentRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (parentRef.current) {
|
||||
parentRef.current.scrollTop = 1;
|
||||
requestAnimationFrame(() => {
|
||||
if (parentRef.current) {
|
||||
parentRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 hover:bg-accent rounded-none"
|
||||
title="Scroll to top"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (displayableMessages.length > 0) {
|
||||
const scrollElement = parentRef.current;
|
||||
if (scrollElement) {
|
||||
scrollElement.scrollTo({
|
||||
top: scrollElement.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 hover:bg-accent rounded-none"
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Floating Prompt Input - Now properly aligned with main content */}
|
||||
<div className="relative">
|
||||
<FloatingPromptInput
|
||||
ref={floatingPromptRef}
|
||||
onSend={handleSendPrompt}
|
||||
onCancel={handleCancelExecution}
|
||||
isLoading={isLoading}
|
||||
disabled={!projectPath}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Counter */}
|
||||
{totalTokens > 0 && (
|
||||
<div className="absolute bottom-0 right-0 z-30 pointer-events-none">
|
||||
<div className="w-full">
|
||||
<div className="flex justify-end px-4 pb-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="bg-background/95 backdrop-blur-md border rounded-full px-3 py-1 shadow-lg pointer-events-auto"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Hash className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono">{totalTokens.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">tokens</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Git Panel */}
|
||||
{layout.showGitPanel && (
|
||||
<ResponsivePanel
|
||||
isVisible={layout.showGitPanel}
|
||||
position="right"
|
||||
width={layout.gitPanelWidth}
|
||||
isMobile={breakpoints.isMobile}
|
||||
onClose={toggleGitPanel}
|
||||
resizable={!breakpoints.isMobile}
|
||||
onResize={(width) => setPanelWidth('gitPanel', width)}
|
||||
minWidth={200}
|
||||
maxWidth={500}
|
||||
>
|
||||
<GitPanelEnhanced
|
||||
projectPath={projectPath}
|
||||
isVisible={true}
|
||||
onToggle={toggleGitPanel}
|
||||
/>
|
||||
</ResponsivePanel>
|
||||
)}
|
||||
|
||||
{/* Timeline Panel - Only on desktop */}
|
||||
{layout.showTimeline && effectiveSession && !breakpoints.isMobile && (
|
||||
<ResponsivePanel
|
||||
isVisible={layout.showTimeline}
|
||||
position="right"
|
||||
width={layout.timelineWidth}
|
||||
isMobile={false}
|
||||
onClose={toggleTimeline}
|
||||
resizable={true}
|
||||
onResize={(width) => setPanelWidth('timeline', width)}
|
||||
minWidth={320}
|
||||
maxWidth={600}
|
||||
className="border-l"
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold">{t('app.sessionTimeline')}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTimeline}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
</MainContentArea>
|
||||
)
|
||||
},
|
||||
// Git 面板
|
||||
{
|
||||
id: 'git-panel',
|
||||
position: 'right',
|
||||
visible: layout.showGitPanel,
|
||||
defaultWidth: layout.gitPanelWidth,
|
||||
minWidth: 200,
|
||||
maxWidth: 500,
|
||||
resizable: !breakpoints.isMobile,
|
||||
content: (
|
||||
<GitPanelEnhanced
|
||||
projectPath={projectPath}
|
||||
isVisible={true}
|
||||
onToggle={toggleGitPanel}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// 时间线面板(仅桌面端)
|
||||
...(layout.showTimeline && effectiveSession && !breakpoints.isMobile ? [{
|
||||
id: 'timeline',
|
||||
position: 'right' as const,
|
||||
visible: true,
|
||||
defaultWidth: layout.timelineWidth,
|
||||
minWidth: 320,
|
||||
maxWidth: 600,
|
||||
resizable: true,
|
||||
content: (
|
||||
<SidePanel
|
||||
title={t('app.sessionTimeline')}
|
||||
onClose={toggleTimeline}
|
||||
position="right"
|
||||
>
|
||||
<TimelineNavigator
|
||||
sessionId={effectiveSession.id}
|
||||
projectId={effectiveSession.project_id}
|
||||
@@ -1783,11 +1782,25 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
onCheckpointCreated={handleCheckpointCreated}
|
||||
refreshVersion={timelineVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsivePanel>
|
||||
)}
|
||||
</GridLayoutContainer>
|
||||
</SidePanel>
|
||||
)
|
||||
}] : [])
|
||||
]}
|
||||
onPanelResize={(panelId, width) => {
|
||||
if (panelId === 'file-explorer') {
|
||||
setPanelWidth('fileExplorer', width);
|
||||
} else if (panelId === 'git-panel') {
|
||||
setPanelWidth('gitPanel', width);
|
||||
} else if (panelId === 'timeline') {
|
||||
setPanelWidth('timeline', width);
|
||||
}
|
||||
}}
|
||||
savedWidths={{
|
||||
'file-explorer': layout.fileExplorerWidth,
|
||||
'git-panel': layout.gitPanelWidth,
|
||||
'timeline': layout.timelineWidth,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fork Dialog */}
|
||||
|
@@ -667,39 +667,17 @@ export const FileExplorerPanelEnhanced: React.FC<FileExplorerPanelEnhancedProps>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果不可见,返回null
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ x: -300, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -300, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ width: `${width}px` }}
|
||||
className={cn(
|
||||
"fixed left-0 top-[172px] bottom-0 bg-background border-r shadow-lg z-40",
|
||||
"flex flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
className="absolute right-0 top-0 bottom-0 w-1 hover:w-2 bg-transparent hover:bg-primary/20 cursor-col-resize transition-all"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<GripVertical className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-medium text-sm">{t("app.fileExplorer")}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col h-full border-r border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-medium text-sm">{t("app.fileExplorer")}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 展开/收起按钮 */}
|
||||
<TooltipProvider>
|
||||
@@ -819,9 +797,7 @@ export const FileExplorerPanelEnhanced: React.FC<FileExplorerPanelEnhancedProps>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -872,7 +872,7 @@ const FloatingPromptInputInner = (
|
||||
{/* Fixed Position Input Bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 z-40 bg-background/98 backdrop-blur-sm border-t border-border/40",
|
||||
"w-full bg-background/98 backdrop-blur-sm border-t border-border/40",
|
||||
dragActive && "ring-2 ring-primary ring-offset-2",
|
||||
className
|
||||
)}
|
||||
@@ -881,7 +881,7 @@ const FloatingPromptInputInner = (
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
{/* Image previews */}
|
||||
{embeddedImages.length > 0 && (
|
||||
<ImagePreview
|
||||
@@ -891,7 +891,7 @@ const FloatingPromptInputInner = (
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model Picker */}
|
||||
<Popover
|
||||
@@ -900,7 +900,7 @@ const FloatingPromptInputInner = (
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={disabled}
|
||||
className="gap-2 min-w-[160px] h-10 justify-start"
|
||||
className="gap-2 min-w-[140px] h-8 justify-start"
|
||||
>
|
||||
{selectedModelData.icon}
|
||||
<span className="flex-1 text-left">{selectedModelData.name}</span>
|
||||
@@ -949,7 +949,7 @@ const FloatingPromptInputInner = (
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={disabled}
|
||||
className="gap-2 h-10"
|
||||
className="gap-2 h-8"
|
||||
>
|
||||
<Brain className="h-4 w-4" />
|
||||
<ThinkingModeIndicator
|
||||
@@ -1010,7 +1010,7 @@ const FloatingPromptInputInner = (
|
||||
placeholder={dragActive ? t('messages.dropImagesHere') : t('messages.askClaudeAnything')}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"min-h-[40px] max-h-[120px] resize-none pr-10 py-2",
|
||||
"min-h-[32px] max-h-[100px] resize-none pr-10 py-1",
|
||||
dragActive && "border-primary"
|
||||
)}
|
||||
rows={1}
|
||||
@@ -1021,7 +1021,7 @@ const FloatingPromptInputInner = (
|
||||
size="icon"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
disabled={disabled}
|
||||
className="absolute right-1 bottom-1 h-8 w-8"
|
||||
className="absolute right-1 bottom-0 h-6 w-6"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1057,7 +1057,7 @@ const FloatingPromptInputInner = (
|
||||
disabled={isLoading ? false : (!prompt.trim() || disabled)}
|
||||
variant={isLoading ? "destructive" : "default"}
|
||||
size="default"
|
||||
className="min-w-[60px] h-10"
|
||||
className="min-w-[56px] h-8"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
@@ -1069,10 +1069,6 @@ const FloatingPromptInputInner = (
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{t('input.pressEnterToSend')}{projectPath?.trim() && t('input.withFileAndCommandSupport')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -639,30 +639,14 @@ export const GitPanelEnhanced: React.FC<GitPanelEnhancedProps> = ({
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
<div
|
||||
ref={panelRef}
|
||||
initial={{ x: 300, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 300, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ width: `${width}px` }}
|
||||
className={cn(
|
||||
"fixed right-0 top-[172px] bottom-0 bg-background border-l shadow-lg z-40",
|
||||
"h-full bg-background border-l border-border",
|
||||
"flex flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
className="absolute left-0 top-0 bottom-0 w-1 hover:w-2 bg-transparent hover:bg-primary/20 cursor-col-resize transition-all"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
>
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2">
|
||||
<GripVertical className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -823,7 +807,7 @@ export const GitPanelEnhanced: React.FC<GitPanelEnhancedProps> = ({
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
50
src/components/layout/ChatView.tsx
Normal file
50
src/components/layout/ChatView.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatViewProps {
|
||||
projectPathInput?: React.ReactNode;
|
||||
messagesList: React.ReactNode;
|
||||
floatingInput?: React.ReactNode;
|
||||
floatingElements?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({
|
||||
projectPathInput,
|
||||
messagesList,
|
||||
floatingInput,
|
||||
floatingElements,
|
||||
className
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('h-full w-full flex flex-col relative', className)}>
|
||||
{/* 项目路径输入(如果提供) */}
|
||||
{projectPathInput && (
|
||||
<div className="shrink-0">
|
||||
{projectPathInput}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息列表区域 - 占据大部分空间 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{messagesList}
|
||||
</div>
|
||||
|
||||
{/* 浮动输入框 - 最小化高度 */}
|
||||
{floatingInput && (
|
||||
<div className="shrink-0 relative">
|
||||
{floatingInput}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其他浮动元素(如队列提示、Token计数器等) */}
|
||||
{floatingElements && (
|
||||
<div className="absolute inset-0 pointer-events-none z-30">
|
||||
<div className="relative h-full w-full">
|
||||
{floatingElements}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
144
src/components/layout/FlexLayoutContainer.tsx
Normal file
144
src/components/layout/FlexLayoutContainer.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { ReactNode, useState, useCallback, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface LayoutPanel {
|
||||
id: string;
|
||||
content: ReactNode;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
resizable?: boolean;
|
||||
visible?: boolean;
|
||||
position?: 'left' | 'center' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FlexLayoutContainerProps {
|
||||
panels: LayoutPanel[];
|
||||
className?: string;
|
||||
mainContentId: string;
|
||||
onPanelResize?: (panelId: string, width: number) => void;
|
||||
savedWidths?: Record<string, number>;
|
||||
}
|
||||
|
||||
export const FlexLayoutContainer: React.FC<FlexLayoutContainerProps> = ({
|
||||
panels,
|
||||
className,
|
||||
mainContentId,
|
||||
onPanelResize,
|
||||
savedWidths = {}
|
||||
}) => {
|
||||
const [panelWidths, setPanelWidths] = useState<Record<string, number>>({});
|
||||
const [isDragging, setIsDragging] = useState<string | null>(null);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartWidth, setDragStartWidth] = useState(0);
|
||||
|
||||
// 初始化面板宽度
|
||||
useEffect(() => {
|
||||
const initialWidths: Record<string, number> = {};
|
||||
panels.forEach(panel => {
|
||||
if (panel.visible !== false && panel.id !== mainContentId) {
|
||||
initialWidths[panel.id] = savedWidths[panel.id] || panel.defaultWidth || 280;
|
||||
}
|
||||
});
|
||||
setPanelWidths(initialWidths);
|
||||
}, [panels, mainContentId, savedWidths]);
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleDragStart = useCallback((e: React.MouseEvent, panelId: string) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(panelId);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartWidth(panelWidths[panelId] || 280);
|
||||
}, [panelWidths]);
|
||||
|
||||
// 处理拖拽移动
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const panel = panels.find(p => p.id === isDragging);
|
||||
if (!panel) return;
|
||||
|
||||
const delta = panel.position === 'left'
|
||||
? e.clientX - dragStartX
|
||||
: dragStartX - e.clientX;
|
||||
|
||||
const newWidth = Math.max(
|
||||
panel.minWidth || 200,
|
||||
Math.min(panel.maxWidth || 600, dragStartWidth + delta)
|
||||
);
|
||||
|
||||
setPanelWidths(prev => ({
|
||||
...prev,
|
||||
[isDragging]: newWidth
|
||||
}));
|
||||
|
||||
if (onPanelResize) {
|
||||
onPanelResize(isDragging, newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartWidth, panels, onPanelResize]);
|
||||
|
||||
// 渲染面板
|
||||
const renderPanel = (panel: LayoutPanel) => {
|
||||
if (panel.visible === false) return null;
|
||||
|
||||
const isMain = panel.id === mainContentId;
|
||||
const width = isMain ? 'flex-1' : `${panelWidths[panel.id] || panel.defaultWidth || 280}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={panel.id}
|
||||
className={cn(
|
||||
'relative h-full',
|
||||
isMain ? 'flex-1 min-w-0' : 'overflow-hidden',
|
||||
panel.className
|
||||
)}
|
||||
style={!isMain ? { width, flexShrink: 0 } : undefined}
|
||||
>
|
||||
{panel.content}
|
||||
|
||||
{/* 调整手柄 */}
|
||||
{!isMain && panel.resizable !== false && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors z-50',
|
||||
panel.position === 'left' ? 'right-0' : 'left-0',
|
||||
isDragging === panel.id && 'bg-primary/40'
|
||||
)}
|
||||
onMouseDown={(e) => handleDragStart(e, panel.id)}
|
||||
>
|
||||
<div className="absolute inset-y-0 w-4 -left-1.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 按位置排序面板
|
||||
const sortedPanels = [...panels].sort((a, b) => {
|
||||
const positionOrder = { left: 0, center: 1, right: 2 };
|
||||
const aPos = a.position || (a.id === mainContentId ? 'center' : 'left');
|
||||
const bPos = b.position || (b.id === mainContentId ? 'center' : 'left');
|
||||
return positionOrder[aPos] - positionOrder[bPos];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full w-full', className)}>
|
||||
{sortedPanels.map(renderPanel)}
|
||||
</div>
|
||||
);
|
||||
};
|
25
src/components/layout/MainContentArea.tsx
Normal file
25
src/components/layout/MainContentArea.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MainContentAreaProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export const MainContentArea: React.FC<MainContentAreaProps> = ({
|
||||
children,
|
||||
className,
|
||||
isEditing = false
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'h-full w-full flex flex-col',
|
||||
'bg-background',
|
||||
isEditing && 'relative',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
49
src/components/layout/SidePanel.tsx
Normal file
49
src/components/layout/SidePanel.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface SidePanelProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
position?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const SidePanel: React.FC<SidePanelProps> = ({
|
||||
children,
|
||||
title,
|
||||
onClose,
|
||||
className,
|
||||
position = 'left'
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col h-full',
|
||||
'bg-background',
|
||||
position === 'left' ? 'border-r' : 'border-l',
|
||||
'border-border',
|
||||
className
|
||||
)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-3 border-b border-border">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -9,6 +9,9 @@ interface LayoutState {
|
||||
showTimeline: boolean;
|
||||
splitPosition: number;
|
||||
isCompactMode: boolean;
|
||||
activeView: 'chat' | 'editor' | 'preview'; // 新增:当前活动视图
|
||||
editingFile: string | null; // 新增:正在编辑的文件
|
||||
previewUrl: string | null; // 新增:预览URL
|
||||
}
|
||||
|
||||
interface LayoutBreakpoints {
|
||||
@@ -29,6 +32,9 @@ const DEFAULT_LAYOUT: LayoutState = {
|
||||
showTimeline: false,
|
||||
splitPosition: 50,
|
||||
isCompactMode: false,
|
||||
activeView: 'chat', // 默认显示聊天视图
|
||||
editingFile: null,
|
||||
previewUrl: null,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'claudia_layout_preferences';
|
||||
@@ -250,6 +256,49 @@ export function useLayoutManager(projectPath?: string) {
|
||||
return classes.join(' ');
|
||||
}, [breakpoints, layout.isCompactMode]);
|
||||
|
||||
// 打开文件编辑器
|
||||
const openFileEditor = useCallback((filePath: string) => {
|
||||
saveLayout({
|
||||
activeView: 'editor',
|
||||
editingFile: filePath,
|
||||
previewUrl: null, // 关闭预览
|
||||
});
|
||||
}, [saveLayout]);
|
||||
|
||||
// 关闭文件编辑器
|
||||
const closeFileEditor = useCallback(() => {
|
||||
saveLayout({
|
||||
activeView: 'chat',
|
||||
editingFile: null,
|
||||
});
|
||||
}, [saveLayout]);
|
||||
|
||||
// 打开预览
|
||||
const openPreview = useCallback((url: string) => {
|
||||
saveLayout({
|
||||
activeView: 'preview',
|
||||
previewUrl: url,
|
||||
editingFile: null, // 关闭编辑器
|
||||
});
|
||||
}, [saveLayout]);
|
||||
|
||||
// 关闭预览
|
||||
const closePreview = useCallback(() => {
|
||||
saveLayout({
|
||||
activeView: 'chat',
|
||||
previewUrl: null,
|
||||
});
|
||||
}, [saveLayout]);
|
||||
|
||||
// 切换到聊天视图
|
||||
const switchToChatView = useCallback(() => {
|
||||
saveLayout({
|
||||
activeView: 'chat',
|
||||
editingFile: null,
|
||||
previewUrl: null,
|
||||
});
|
||||
}, [saveLayout]);
|
||||
|
||||
return {
|
||||
layout,
|
||||
breakpoints,
|
||||
@@ -264,5 +313,11 @@ export function useLayoutManager(projectPath?: string) {
|
||||
getGridTemplateColumns,
|
||||
getResponsiveClasses,
|
||||
saveLayout,
|
||||
// 新增的方法
|
||||
openFileEditor,
|
||||
closeFileEditor,
|
||||
openPreview,
|
||||
closePreview,
|
||||
switchToChatView,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user