优化页面布局

This commit is contained in:
2025-08-10 21:44:48 +08:00
parent 4fa9f93f46
commit b1cd9f9098
13 changed files with 1242 additions and 87 deletions

View File

@@ -12,7 +12,9 @@ import {
ChevronUp,
X,
Hash,
Command
Command,
PanelLeftOpen,
PanelRightOpen
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -38,7 +40,8 @@ import { GitPanelEnhanced } from "./GitPanelEnhanced";
import { FileEditorEnhanced } from "./FileEditorEnhanced";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks";
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks";
import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout";
interface ClaudeCodeSessionProps {
/**
@@ -82,6 +85,19 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
onStreamingChange,
}) => {
const { t } = useTranslation();
const layoutManager = useLayoutManager(initialProjectPath || session?.project_path);
const {
layout,
breakpoints,
toggleFileExplorer,
toggleGitPanel,
toggleTimeline,
setPanelWidth,
setSplitPosition: setLayoutSplitPosition,
getGridTemplateColumns,
getResponsiveClasses
} = layoutManager;
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -92,7 +108,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [totalTokens, setTotalTokens] = useState(0);
const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null);
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [showTimeline, setShowTimeline] = useState(false);
const [timelineVersion, setTimelineVersion] = useState(0);
const [showSettings, setShowSettings] = useState(false);
const [showForkDialog, setShowForkDialog] = useState(false);
@@ -107,16 +122,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [showPreview, setShowPreview] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
const [splitPosition, setSplitPosition] = useState(50);
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
// Add collapsed state for queued prompts
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
// New state for file explorer and git panel
const [showFileExplorer, setShowFileExplorer] = useState(false);
const [showGitPanel, setShowGitPanel] = useState(false);
// File editor state
const [editingFile, setEditingFile] = useState<string | null>(null);
@@ -1080,7 +1090,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
setIsPreviewMaximized(!isPreviewMaximized);
// Reset split position when toggling maximize
if (isPreviewMaximized) {
setSplitPosition(50);
setLayoutSplitPosition(50);
}
};
@@ -1260,7 +1270,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}
return (
<div className={cn("flex flex-col h-full bg-background relative", className)}>
<div className={cn("flex flex-col h-full bg-background relative", getResponsiveClasses(), className)}>
<div className="w-full h-full flex flex-col">
{/* Header */}
<motion.div
@@ -1298,10 +1308,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<Button
variant="ghost"
size="icon"
onClick={() => setShowFileExplorer(!showFileExplorer)}
className={cn("h-8 w-8", showFileExplorer && "text-primary")}
onClick={toggleFileExplorer}
className={cn("h-8 w-8", layout.showFileExplorer && "text-primary")}
>
<FolderOpen className="h-4 w-4" />
<PanelLeftOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -1319,10 +1329,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<Button
variant="ghost"
size="icon"
onClick={() => setShowGitPanel(!showGitPanel)}
className={cn("h-8 w-8", showGitPanel && "text-primary")}
onClick={toggleGitPanel}
className={cn("h-8 w-8", layout.showGitPanel && "text-primary")}
>
<GitBranch className="h-4 w-4" />
<PanelRightOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -1397,10 +1407,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<Button
variant="ghost"
size="icon"
onClick={() => setShowTimeline(!showTimeline)}
onClick={toggleTimeline}
className="h-8 w-8"
>
<GitBranch className={cn("h-4 w-4", showTimeline && "text-primary")} />
<GitBranch className={cn("h-4 w-4", layout.showTimeline && "text-primary")} />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -1450,36 +1460,45 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
</motion.div>
{/* Main Content Area with panels */}
<div className={cn(
"flex-1 overflow-hidden transition-all duration-300 flex",
showTimeline && "sm:mr-96"
)}>
{/* Main Content Area with Grid Layout */}
<GridLayoutContainer
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 */}
<FileExplorerPanelEnhanced
projectPath={projectPath}
isVisible={showFileExplorer}
onFileSelect={(path) => {
// Add file path to prompt input (double click)
floatingPromptRef.current?.addImage(path);
}}
onFileOpen={(path) => {
// Open file in editor (single click)
setEditingFile(path);
}}
onToggle={() => setShowFileExplorer(!showFileExplorer)}
/>
{/* Main Content with Input */}
<div className={cn(
"flex-1 transition-all duration-300 relative flex flex-col"
{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>
)}
style={{
marginLeft: showFileExplorer ? '15vw' : 'auto',
marginRight: showGitPanel ? '15vw' : 'auto',
width: (!showFileExplorer && !showGitPanel) ? '90%' : 'calc(100% - ' + ((showFileExplorer ? 15 : 0) + (showGitPanel ? 15 : 0)) + 'vw)',
maxWidth: (!showFileExplorer && !showGitPanel) ? '100%' : 'none'
}}>
{/* Main Content */}
<div className="flex-1 relative flex flex-col overflow-hidden">
{showPreview ? (
// Split pane layout when preview is active
<div className="h-full">
@@ -1512,8 +1531,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
onUrlChange={handlePreviewUrlChange}
/>
}
initialSplit={splitPosition}
onSplitChange={setSplitPosition}
initialSplit={layout.splitPosition}
onSplitChange={(position) => {
setLayoutSplitPosition(position);
}}
minLeftWidth={400}
minRightWidth={400}
className="h-full"
@@ -1705,38 +1726,52 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
{/* Git Panel */}
<GitPanelEnhanced
projectPath={projectPath}
isVisible={showGitPanel}
onToggle={() => setShowGitPanel(!showGitPanel)}
/>
</div>
{/* Timeline */}
<AnimatePresence>
{showTimeline && effectiveSession && (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed right-0 top-0 h-full w-full sm:w-96 bg-background border-l border-border shadow-xl z-30 overflow-hidden"
{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">
{/* Timeline Header */}
<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={() => setShowTimeline(false)}
onClick={toggleTimeline}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Timeline Content */}
<div className="flex-1 overflow-y-auto p-4">
<TimelineNavigator
sessionId={effectiveSession.id}
@@ -1750,9 +1785,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
/>
</div>
</div>
</motion.div>
</ResponsivePanel>
)}
</AnimatePresence>
</GridLayoutContainer>
</div>
{/* Fork Dialog */}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
X,
Save,
@@ -156,11 +157,15 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
const [minimap, setMinimap] = useState(true);
const [wordWrap, setWordWrap] = useState<'on' | 'off'>('on');
const [autoSave, setAutoSave] = useState(false);
const [lastCheckTime, setLastCheckTime] = useState<number>(Date.now());
const [fileChanged, setFileChanged] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const fileCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
const unlistenRef = useRef<UnlistenFn | null>(null);
const fileName = filePath.split("/").pop() || filePath;
const language = getLanguageFromPath(filePath);
@@ -180,13 +185,15 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
setContent(fileContent);
setOriginalContent(fileContent);
setHasChanges(false);
setFileChanged(false);
setLastCheckTime(Date.now());
} catch (err) {
console.error("Failed to load file:", err);
setError(err instanceof Error ? err.message : "Failed to load file");
} finally {
setLoading(false);
}
}, [filePath]);
}, [filePath, hasChanges]);
// 保存文件
const saveFile = useCallback(async () => {
@@ -204,6 +211,8 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
setOriginalContent(content);
setHasChanges(false);
setSaved(true);
setLastCheckTime(Date.now());
setFileChanged(false);
// 显示保存成功提示
setTimeout(() => setSaved(false), 2000);
@@ -368,12 +377,128 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [hasChanges, saveFile, isFullscreen]);
// 使用真正的文件系统监听
useEffect(() => {
const setupFileWatcher = async () => {
if (!filePath) return;
try {
// 监听文件所在目录
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
await invoke('watch_directory', {
path: dirPath,
recursive: false
});
// 监听文件变化事件
unlistenRef.current = await listen('file-system-change', (event: any) => {
const { path, change_type } = event.payload;
// 检查是否是当前文件的变化
if (path === filePath && (change_type === 'modified' || change_type === 'created')) {
// 检查时间间隔,避免自己保存触发的事件
const timeSinceLastSave = Date.now() - lastCheckTime;
if (timeSinceLastSave > 1000) { // 超过1秒可能是外部修改
console.log('File changed externally:', path, change_type);
setFileChanged(true);
// 如果没有未保存的更改,自动重新加载
if (!hasChanges) {
loadFile();
} else {
// 显示提示
setError("文件已被外部程序修改,点击重新加载按钮查看最新内容");
}
}
}
});
} catch (err) {
console.error('Failed to setup file watcher:', err);
// 如果文件监听失败,回退到轮询模式
fallbackToPolling();
}
};
// 回退到轮询模式
const fallbackToPolling = () => {
const checkFileChanges = async () => {
if (!filePath || !editorRef.current) return;
try {
const fileInfo = await invoke<any>('get_file_info', { path: filePath });
if (fileInfo && fileInfo.modified) {
const fileModifiedTime = new Date(fileInfo.modified).getTime();
if (fileModifiedTime > lastCheckTime && !hasChanges) {
const newContent = await invoke<string>('read_file', { path: filePath });
if (newContent !== originalContent) {
setFileChanged(true);
if (!hasChanges) {
setContent(newContent);
setOriginalContent(newContent);
setFileChanged(false);
setLastCheckTime(Date.now());
}
}
}
}
} catch (err) {
console.debug('File check error:', err);
}
};
// 每3秒检查一次文件变化
fileCheckIntervalRef.current = setInterval(checkFileChanges, 3000);
};
setupFileWatcher();
// 清理函数
return () => {
// 停止监听
if (filePath) {
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
invoke('unwatch_directory', { path: dirPath }).catch(console.error);
}
// 清理事件监听
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
// 清理轮询定时器
if (fileCheckIntervalRef.current) {
clearInterval(fileCheckIntervalRef.current);
}
};
}, [filePath, hasChanges, lastCheckTime, originalContent, loadFile]);
// 移除旧的轮询实现
// 重新加载文件
const reloadFile = useCallback(async () => {
if (!filePath) return;
if (hasChanges) {
const shouldReload = window.confirm(
"您有未保存的更改。重新加载将丢失这些更改。是否继续?"
);
if (!shouldReload) return;
}
await loadFile();
}, [filePath, hasChanges, loadFile]);
// 加载文件
useEffect(() => {
if (filePath) {
loadFile();
}
}, [filePath, loadFile]);
}, [filePath]); // 移除 loadFile 依赖,避免循环
// 计算诊断统计
const diagnosticStats = {
@@ -587,6 +712,28 @@ export const FileEditorEnhanced: React.FC<FileEditorEnhancedProps> = ({
</DropdownMenuContent>
</DropdownMenu>
{/* 文件外部修改提示 */}
{fileChanged && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={reloadFile}
className="flex items-center gap-1 border-yellow-500/50 text-yellow-500 hover:bg-yellow-500/10"
>
<AlertTriangle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* 保存按钮 */}
{hasChanges && (
<Button

View File

@@ -0,0 +1,218 @@
import React, { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
interface GridLayoutContainerProps {
children: ReactNode;
className?: string;
gridTemplateColumns: string;
isMobile: boolean;
isTablet: boolean;
showFileExplorer: boolean;
showGitPanel: boolean;
showTimeline: boolean;
}
/**
* Grid-based layout container for responsive panel management
*/
export const GridLayoutContainer: React.FC<GridLayoutContainerProps> = ({
children,
className,
gridTemplateColumns,
isMobile,
isTablet,
showFileExplorer,
showGitPanel,
showTimeline,
}) => {
// Mobile layout: Stack panels as overlays
if (isMobile) {
return (
<div className={cn('relative h-full w-full overflow-hidden', className)}>
{children}
{/* Mobile overlay panels */}
<AnimatePresence>
{(showFileExplorer || showGitPanel || showTimeline) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50 z-40"
onClick={() => {
// This will be handled by parent component
}}
/>
)}
</AnimatePresence>
</div>
);
}
// Tablet layout: Adaptive grid with optional sidebar
if (isTablet) {
return (
<div
className={cn(
'h-full w-full grid transition-all duration-300',
className
)}
style={{
gridTemplateColumns: showTimeline ? '1fr 320px' : '1fr',
gap: 0,
}}
>
<div className="grid h-full" style={{ gridTemplateColumns: gridTemplateColumns }}>
{React.Children.toArray(children).slice(0, -1)}
</div>
{showTimeline && React.Children.toArray(children).slice(-1)}
</div>
);
}
// Desktop/Widescreen layout: Full grid
return (
<div
className={cn(
'h-full w-full grid transition-all duration-300',
className
)}
style={{
gridTemplateColumns: gridTemplateColumns,
gap: 0,
}}
>
{children}
</div>
);
};
interface ResponsivePanelProps {
children: ReactNode;
isVisible: boolean;
position: 'left' | 'right' | 'overlay';
width?: number;
isMobile: boolean;
onClose?: () => void;
className?: string;
resizable?: boolean;
onResize?: (width: number) => void;
minWidth?: number;
maxWidth?: number;
}
/**
* Responsive panel component with mobile overlay support
*/
export const ResponsivePanel: React.FC<ResponsivePanelProps> = ({
children,
isVisible,
position,
width = 320,
isMobile,
onClose,
className,
resizable = false,
onResize,
minWidth = 200,
maxWidth = 600,
}) => {
const [isResizing, setIsResizing] = React.useState(false);
const [currentWidth, setCurrentWidth] = React.useState(width);
const panelRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
setCurrentWidth(width);
}, [width]);
const handleMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = currentWidth;
const handleMouseMove = (e: MouseEvent) => {
const diff = position === 'left' ? e.clientX - startX : startX - e.clientX;
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + diff));
setCurrentWidth(newWidth);
onResize?.(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
if (!isVisible) return null;
// Mobile: Full screen overlay
if (isMobile) {
return (
<motion.div
initial={{ x: position === 'left' ? '-100%' : '100%' }}
animate={{ x: 0 }}
exit={{ x: position === 'left' ? '-100%' : '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className={cn(
'absolute inset-y-0 z-50 bg-background shadow-2xl',
position === 'left' ? 'left-0' : 'right-0',
'w-[85vw] max-w-sm',
className
)}
ref={panelRef}
>
{onClose && (
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-lg hover:bg-accent z-10"
aria-label="Close panel"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<div className="h-full overflow-y-auto">
{children}
</div>
</motion.div>
);
}
// Desktop: Integrated panel with optional resize
return (
<div
ref={panelRef}
className={cn(
'relative h-full overflow-hidden border-border',
position === 'left' && 'border-r',
position === 'right' && 'border-l',
className
)}
style={{ width: currentWidth }}
>
{resizable && (
<div
className={cn(
'absolute top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 transition-colors z-10',
position === 'left' ? 'right-0' : 'left-0',
isResizing && 'bg-primary/30'
)}
onMouseDown={handleMouseDown}
/>
)}
<div className="h-full overflow-y-auto">
{children}
</div>
</div>
);
};

View File

@@ -25,3 +25,4 @@ export {
} from './usePerformanceMonitor';
export { TAB_SCREEN_NAMES } from './useAnalytics';
export { useTranslation, getLanguageDisplayName } from './useTranslation';
export { useLayoutManager } from './useLayoutManager';

View File

@@ -0,0 +1,268 @@
import { useState, useEffect, useCallback } from 'react';
interface LayoutState {
fileExplorerWidth: number;
gitPanelWidth: number;
timelineWidth: number;
showFileExplorer: boolean;
showGitPanel: boolean;
showTimeline: boolean;
splitPosition: number;
isCompactMode: boolean;
}
interface LayoutBreakpoints {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isWidescreen: boolean;
screenWidth: number;
screenHeight: number;
}
const DEFAULT_LAYOUT: LayoutState = {
fileExplorerWidth: 280,
gitPanelWidth: 320,
timelineWidth: 384,
showFileExplorer: false,
showGitPanel: false,
showTimeline: false,
splitPosition: 50,
isCompactMode: false,
};
const STORAGE_KEY = 'claudia_layout_preferences';
/**
* Custom hook for managing responsive layout with persistent state
*/
export function useLayoutManager(projectPath?: string) {
const [layout, setLayout] = useState<LayoutState>(DEFAULT_LAYOUT);
const [breakpoints, setBreakpoints] = useState<LayoutBreakpoints>({
isMobile: false,
isTablet: false,
isDesktop: true,
isWidescreen: false,
screenWidth: window.innerWidth,
screenHeight: window.innerHeight,
});
// Load saved layout preferences
useEffect(() => {
const loadLayout = async () => {
try {
// Try to load project-specific layout first
const key = projectPath ? `${STORAGE_KEY}_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}` : STORAGE_KEY;
const saved = localStorage.getItem(key);
if (saved) {
const savedLayout = JSON.parse(saved) as Partial<LayoutState>;
setLayout(prev => ({ ...prev, ...savedLayout }));
}
} catch (error) {
console.error('Failed to load layout preferences:', error);
}
};
loadLayout();
}, [projectPath]);
// Save layout changes
const saveLayout = useCallback((newLayout: Partial<LayoutState>) => {
const updated = { ...layout, ...newLayout };
setLayout(updated);
// Save to localStorage
try {
const key = projectPath ? `${STORAGE_KEY}_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}` : STORAGE_KEY;
localStorage.setItem(key, JSON.stringify(updated));
} catch (error) {
console.error('Failed to save layout preferences:', error);
}
}, [layout, projectPath]);
// Update breakpoints on resize
useEffect(() => {
const updateBreakpoints = () => {
const width = window.innerWidth;
const height = window.innerHeight;
setBreakpoints({
isMobile: width < 640,
isTablet: width >= 640 && width < 1024,
isDesktop: width >= 1024 && width < 1536,
isWidescreen: width >= 1536,
screenWidth: width,
screenHeight: height,
});
// Auto-adjust layout for mobile
if (width < 640) {
saveLayout({
isCompactMode: true,
showFileExplorer: false,
showGitPanel: false,
showTimeline: false,
});
}
};
updateBreakpoints();
window.addEventListener('resize', updateBreakpoints);
return () => window.removeEventListener('resize', updateBreakpoints);
}, [saveLayout]);
// Panel toggle functions
const toggleFileExplorer = useCallback(() => {
const newState = !layout.showFileExplorer;
// On mobile, close other panels when opening one
if (breakpoints.isMobile && newState) {
saveLayout({
showFileExplorer: true,
showGitPanel: false,
showTimeline: false,
});
} else {
saveLayout({ showFileExplorer: newState });
}
}, [layout.showFileExplorer, breakpoints.isMobile, saveLayout]);
const toggleGitPanel = useCallback(() => {
const newState = !layout.showGitPanel;
// On mobile, close other panels when opening one
if (breakpoints.isMobile && newState) {
saveLayout({
showFileExplorer: false,
showGitPanel: true,
showTimeline: false,
});
} else {
saveLayout({ showGitPanel: newState });
}
}, [layout.showGitPanel, breakpoints.isMobile, saveLayout]);
const toggleTimeline = useCallback(() => {
const newState = !layout.showTimeline;
// On mobile, close other panels when opening one
if (breakpoints.isMobile && newState) {
saveLayout({
showFileExplorer: false,
showGitPanel: false,
showTimeline: true,
});
} else {
saveLayout({ showTimeline: newState });
}
}, [layout.showTimeline, breakpoints.isMobile, saveLayout]);
// Update panel width
const setPanelWidth = useCallback((panel: 'fileExplorer' | 'gitPanel' | 'timeline', width: number) => {
const key = `${panel}Width` as keyof LayoutState;
saveLayout({ [key]: width });
}, [saveLayout]);
// Set split position
const setSplitPosition = useCallback((position: number) => {
saveLayout({ splitPosition: position });
}, [saveLayout]);
// Toggle compact mode
const toggleCompactMode = useCallback(() => {
saveLayout({ isCompactMode: !layout.isCompactMode });
}, [layout.isCompactMode, saveLayout]);
// Reset layout to defaults
const resetLayout = useCallback(() => {
setLayout(DEFAULT_LAYOUT);
try {
const key = projectPath ? `${STORAGE_KEY}_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}` : STORAGE_KEY;
localStorage.removeItem(key);
} catch (error) {
console.error('Failed to reset layout:', error);
}
}, [projectPath]);
// Calculate available content width
const getContentWidth = useCallback(() => {
let width = breakpoints.screenWidth;
if (layout.showFileExplorer && !breakpoints.isMobile) {
width -= layout.fileExplorerWidth;
}
if (layout.showGitPanel && !breakpoints.isMobile) {
width -= layout.gitPanelWidth;
}
if (layout.showTimeline && !breakpoints.isMobile) {
width -= layout.timelineWidth;
}
return width;
}, [breakpoints, layout]);
// Get grid template columns for CSS Grid layout
const getGridTemplateColumns = useCallback(() => {
const parts: string[] = [];
// Mobile: stack everything
if (breakpoints.isMobile) {
return '1fr';
}
// Desktop: dynamic grid
if (layout.showFileExplorer) {
parts.push(`${layout.fileExplorerWidth}px`);
}
parts.push('1fr'); // Main content
if (layout.showGitPanel) {
parts.push(`${layout.gitPanelWidth}px`);
}
if (layout.showTimeline) {
parts.push(`${layout.timelineWidth}px`);
}
return parts.join(' ');
}, [breakpoints.isMobile, layout]);
// Get responsive class names
const getResponsiveClasses = useCallback(() => {
const classes: string[] = [];
if (breakpoints.isMobile) {
classes.push('mobile-layout');
} else if (breakpoints.isTablet) {
classes.push('tablet-layout');
} else if (breakpoints.isDesktop) {
classes.push('desktop-layout');
} else if (breakpoints.isWidescreen) {
classes.push('widescreen-layout');
}
if (layout.isCompactMode) {
classes.push('compact-mode');
}
return classes.join(' ');
}, [breakpoints, layout.isCompactMode]);
return {
layout,
breakpoints,
toggleFileExplorer,
toggleGitPanel,
toggleTimeline,
setPanelWidth,
setSplitPosition,
toggleCompactMode,
resetLayout,
getContentWidth,
getGridTemplateColumns,
getResponsiveClasses,
saveLayout,
};
}

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "./styles/grid-layout.css";
/* Custom scrollbar hiding */
.scrollbar-hide {

181
src/styles/grid-layout.css Normal file
View File

@@ -0,0 +1,181 @@
/* Grid Layout Styles for ClaudeCodeSession */
/* Base layout classes */
.mobile-layout {
@apply relative;
}
.tablet-layout {
@apply relative;
}
.desktop-layout {
@apply relative;
}
.widescreen-layout {
@apply relative;
}
/* Compact mode adjustments */
.compact-mode {
@apply text-sm;
}
.compact-mode .floating-prompt-input {
@apply h-10;
}
.compact-mode .message-container {
@apply py-2;
}
/* Panel transitions */
.panel-transition {
transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1),
transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Mobile panel overlays */
@media (max-width: 639px) {
.mobile-panel-overlay {
@apply fixed inset-0 bg-black/50 z-40;
}
.mobile-panel {
@apply fixed inset-y-0 z-50 bg-background shadow-2xl;
max-width: 85vw;
}
.mobile-panel-left {
@apply left-0;
}
.mobile-panel-right {
@apply right-0;
}
}
/* Tablet adjustments */
@media (min-width: 640px) and (max-width: 1023px) {
.tablet-sidebar {
width: min(320px, 40vw);
}
}
/* Desktop grid layout */
@media (min-width: 1024px) {
.desktop-grid {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
}
.desktop-content-grid {
display: grid;
gap: 0;
height: 100%;
transition: grid-template-columns 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
}
/* Widescreen optimizations */
@media (min-width: 1536px) {
.widescreen-content {
max-width: 1400px;
margin: 0 auto;
}
}
/* Resize handle styles */
.resize-handle {
@apply absolute top-0 bottom-0 w-1 cursor-col-resize;
@apply hover:bg-primary/20 transition-colors;
}
.resize-handle:active {
@apply bg-primary/30;
}
.resize-handle-left {
@apply right-0;
}
.resize-handle-right {
@apply left-0;
}
/* Panel content scrolling */
.panel-content {
@apply h-full overflow-y-auto;
scrollbar-width: thin;
}
.panel-content::-webkit-scrollbar {
width: 6px;
}
.panel-content::-webkit-scrollbar-track {
@apply bg-transparent;
}
.panel-content::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-full;
}
.panel-content::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/30;
}
/* Animation classes */
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideOutLeft {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
.animate-slide-in-left {
animation: slideInLeft 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-slide-in-right {
animation: slideInRight 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-slide-out-left {
animation: slideOutLeft 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.animate-slide-out-right {
animation: slideOutRight 300ms cubic-bezier(0.4, 0, 0.2, 1);
}