增加文件管理器

This commit is contained in:
2025-08-09 13:22:54 +08:00
parent c5b72a9879
commit 5e532ad83f
8 changed files with 363 additions and 169 deletions

View File

@@ -33,6 +33,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { SplitPane } from "@/components/ui/split-pane";
import { WebviewPreview } from "./WebviewPreview";
import { FileExplorerPanel } from "./FileExplorerPanel";
import { GitPanel } from "./GitPanel";
import { FileEditor } from "./FileEditor";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks";
@@ -110,6 +113,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// 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);
const [fileExplorerWidth] = useState(280);
const [gitPanelWidth] = useState(320);
// File editor state
const [editingFile, setEditingFile] = useState<string | null>(null);
const parentRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const hasActiveSessionRef = useRef(false);
@@ -438,7 +450,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// If already loading, queue the prompt
if (isLoading) {
const newPrompt = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
prompt,
model
};
@@ -1022,7 +1034,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
setIsLoading(true);
setError(null);
const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newSessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
await api.forkFromCheckpoint(
forkCheckpointId,
effectiveSession.id,
@@ -1280,6 +1292,48 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
<div className="flex items-center gap-2">
{/* File Explorer Toggle */}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setShowFileExplorer(!showFileExplorer)}
className={cn("h-8 w-8", showFileExplorer && "text-primary")}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>File Explorer</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Git Panel Toggle */}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setShowGitPanel(!showGitPanel)}
className={cn("h-8 w-8", showGitPanel && "text-primary")}
>
<GitBranch className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Git Panel</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{projectPath && onProjectSettings && (
<TooltipProvider>
<Tooltip>
@@ -1352,7 +1406,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Timeline Navigator</p>
<p>{t('app.timeline')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -1398,11 +1452,33 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
</motion.div>
{/* Main Content Area */}
{/* Main Content Area with panels */}
<div className={cn(
"flex-1 overflow-hidden transition-all duration-300",
"flex-1 overflow-hidden transition-all duration-300 flex",
showTimeline && "sm:mr-96"
)}>
{/* File Explorer Panel */}
<FileExplorerPanel
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)}
width={fileExplorerWidth}
/>
{/* Main Content with Input */}
<div className={cn(
"flex-1 transition-all duration-300 relative flex flex-col",
showFileExplorer && "pl-[280px]",
showGitPanel && "pr-[320px]"
)}>
{showPreview ? (
// Split pane layout when preview is active
<SplitPane
@@ -1427,182 +1503,198 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
minRightWidth={400}
className="h-full"
/>
) : editingFile ? (
// File Editor layout
<div className="h-full flex flex-col relative">
<FileEditor
filePath={editingFile}
onClose={() => setEditingFile(null)}
className="flex-1"
/>
</div>
) : (
// Original layout when no preview
<div className="h-full flex flex-col max-w-5xl mx-auto">
{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>
)}
</div>
{/* Floating Prompt Input - Always visible */}
<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="fixed bottom-24 left-1/2 -translate-x-1/2 z-30 w-full max-w-3xl 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 className="h-full flex flex-col relative">
<div className="flex-1 flex flex-col max-w-5xl mx-auto w-full">
{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>
<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>
{/* Navigation Arrows - positioned above prompt bar with spacing */}
{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="fixed 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={() => {
// Use virtualizer to scroll to the first item
if (displayableMessages.length > 0) {
// Scroll to top of the container
parentRef.current?.scrollTo({
top: 0,
behavior: 'smooth'
});
// After smooth scroll completes, trigger a small scroll to ensure rendering
setTimeout(() => {
if (parentRef.current) {
// Scroll down 1px then back to 0 to trigger virtualizer update
parentRef.current.scrollTop = 1;
requestAnimationFrame(() => {
if (parentRef.current) {
parentRef.current.scrollTop = 0;
}
});
}
}, 500); // Wait for smooth scroll to complete
}
}}
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={() => {
// Use virtualizer to scroll to the last item
if (displayableMessages.length > 0) {
// Scroll to bottom of the container
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 - Bound to Main Content */}
<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={cn(
"absolute bottom-24 left-0 right-0 z-30 transition-all duration-300",
showTimeline && "sm:right-96"
)}
>
<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>
</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>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={cn(
"absolute bottom-0 left-0 right-0 transition-all duration-300 z-50",
showTimeline && "sm:right-96"
)}>
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
{/* Token Counter - positioned under the Send button */}
{totalTokens > 0 && (
<div className="fixed bottom-0 left-0 right-0 z-30 pointer-events-none">
<div className="max-w-5xl mx-auto">
<div className="flex justify-end px-4 pb-2">
{/* Navigation Arrows */}
{displayableMessages.length > 5 && (
<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"
transition={{ delay: 0.5 }}
className="absolute bottom-32 right-6 z-50"
>
<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 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>
)}
<div className="absolute bottom-0 left-0 right-0 z-50">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
</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>
)}
</ErrorBoundary>
</div>
{/* Git Panel */}
<GitPanel
projectPath={projectPath}
isVisible={showGitPanel}
onToggle={() => setShowGitPanel(!showGitPanel)}
width={gitPanelWidth}
/>
</div>
{/* Timeline */}
<AnimatePresence>
@@ -1665,7 +1757,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
placeholder="e.g., Alternative approach"
value={forkSessionName}
onChange={(e) => setForkSessionName(e.target.value)}
onKeyPress={(e) => {
onKeyDown={(e) => {
if (e.key === "Enter" && !isLoading) {
handleConfirmFork();
}
@@ -1731,3 +1823,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
);
};
// Add default export for lazy loading
export default ClaudeCodeSession;

View File

@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
// Lazy load heavy components
const ClaudeCodeSession = lazy(() => import('@/components/ClaudeCodeSession').then(m => ({ default: m.ClaudeCodeSession })));
const ClaudeCodeSession = lazy(() => import('./ClaudeCodeSession'));
const AgentRunOutputViewer = lazy(() => import('@/components/AgentRunOutputViewer'));
const AgentExecution = lazy(() => import('@/components/AgentExecution').then(m => ({ default: m.AgentExecution })));
const CreateAgent = lazy(() => import('@/components/CreateAgent').then(m => ({ default: m.CreateAgent })));

View File

@@ -61,7 +61,38 @@
"noCheckpointsYet": "No checkpoints yet",
"sessionTimeline": "Session Timeline",
"checkpoints": "checkpoints",
"loadingTimeline": "Loading timeline..."
"loadingTimeline": "Loading timeline...",
"fileExplorer": "File Explorer",
"searchFiles": "Search files...",
"copyPath": "Copy Path",
"openFile": "Open File",
"addToChat": "Add to Chat",
"noFilesFound": "No files found",
"viewMode": "View Mode",
"editMode": "Edit Mode",
"saved": "Saved",
"unsavedChangesConfirm": "You have unsaved changes. Are you sure you want to leave?",
"gitPanel": "Git",
"gitStatus": "Status",
"gitHistory": "History",
"gitBranches": "Branches",
"workingTreeClean": "Working tree clean",
"staged": "Staged",
"modified": "Modified",
"untracked": "Untracked",
"conflicted": "Conflicted",
"noGitRepository": "No Git repository found",
"noCommitsFound": "No commits found",
"noBranchesFound": "No branches found",
"localBranches": "Local Branches",
"remoteBranches": "Remote Branches",
"current": "current",
"andMore": "... and {{count}} more",
"filesChanged": "files",
"justNow": "just now",
"minutesAgo": "{{count}} minute{{plural}} ago",
"hoursAgo": "{{count}} hour{{plural}} ago",
"daysAgo": "{{count}} day{{plural}} ago"
},
"navigation": {
"projects": "CC Projects",

View File

@@ -58,7 +58,38 @@
"noCheckpointsYet": "尚无检查点",
"sessionTimeline": "会话时间线",
"checkpoints": "个检查点",
"loadingTimeline": "加载时间线中..."
"loadingTimeline": "加载时间线中...",
"fileExplorer": "文件管理器",
"searchFiles": "搜索文件...",
"copyPath": "复制路径",
"openFile": "打开文件",
"addToChat": "添加到聊天",
"noFilesFound": "未找到文件",
"viewMode": "查看模式",
"editMode": "编辑模式",
"saved": "已保存",
"unsavedChangesConfirm": "您有未保存的更改。确定要离开吗?",
"gitPanel": "Git",
"gitStatus": "状态",
"gitHistory": "历史",
"gitBranches": "分支",
"workingTreeClean": "工作区清洁",
"staged": "已暂存",
"modified": "已修改",
"untracked": "未跟踪",
"conflicted": "冲突",
"noGitRepository": "未找到Git仓库",
"noCommitsFound": "未找到提交",
"noBranchesFound": "未找到分支",
"localBranches": "本地分支",
"remoteBranches": "远程分支",
"current": "当前",
"andMore": "... 还有 {{count}} 个",
"filesChanged": "个文件",
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
},
"navigation": {
"projects": "Claude Code 项目",