diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs index 546ed4a..bd4ab7b 100644 --- a/src-tauri/src/commands/filesystem.rs +++ b/src-tauri/src/commands/filesystem.rs @@ -261,4 +261,31 @@ pub async fn watch_directory( }).map_err(|e| e.to_string())?; Ok(()) +} + +/// 获取文件树(简化版,供文件浏览器使用) +#[tauri::command] +pub async fn get_file_tree(project_path: String) -> Result, String> { + let path = Path::new(&project_path); + if !path.exists() { + return Err(format!("Path does not exist: {}", path.display())); + } + + let ignore_patterns = vec![ + String::from("node_modules"), + String::from(".git"), + String::from("target"), + String::from("dist"), + String::from("build"), + String::from(".idea"), + String::from(".vscode"), + String::from("__pycache__"), + String::from(".DS_Store"), + ]; + + let root_node = read_directory_recursive(path, 0, 3, &ignore_patterns) + .map_err(|e| e.to_string())?; + + // Return children of root node if it has any + Ok(root_node.children.unwrap_or_default()) } \ No newline at end of file diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index fc9994c..4ead458 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -423,4 +423,11 @@ pub async fn get_git_diff( } Ok(String::from_utf8_lossy(&diff_output.stdout).to_string()) +} + +/// 获取 Git 提交列表(简化版) +#[tauri::command] +pub async fn get_git_commits(project_path: String, limit: usize) -> Result, String> { + // 使用已有的 get_git_history 函数,直接传递 limit 参数 + get_git_history(project_path, Some(limit), None).await } \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47be208..18da692 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -62,10 +62,10 @@ use commands::packycode_nodes::{ }; use commands::filesystem::{ read_directory_tree, search_files_by_name, get_file_info, watch_directory, - read_file, write_file, + read_file, write_file, get_file_tree, }; use commands::git::{ - get_git_status, get_git_history, get_git_branches, get_git_diff, + get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits, }; use process::ProcessRegistryState; use std::sync::Mutex; @@ -311,12 +311,14 @@ fn main() { watch_directory, read_file, write_file, + get_file_tree, // Git get_git_status, get_git_history, get_git_branches, get_git_diff, + get_git_commits, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tests/TESTS_COMPLETE.md b/src-tauri/tests/TESTS_COMPLETE.md index 4276a66..706b487 100644 --- a/src-tauri/tests/TESTS_COMPLETE.md +++ b/src-tauri/tests/TESTS_COMPLETE.md @@ -19,7 +19,7 @@ test result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out ``` -### Implementation Details: +### Implementation Details:q #### Real Claude Execution: - `execute_claude_task()` - Executes Claude with specified task and captures output diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 783dc59..65a730a 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -33,8 +33,8 @@ 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 { FileExplorerPanelEnhanced } from "./FileExplorerPanelEnhanced"; +import { GitPanelEnhanced } from "./GitPanelEnhanced"; import { FileEditorEnhanced } from "./FileEditorEnhanced"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -116,8 +116,6 @@ export const ClaudeCodeSession: React.FC = ({ // 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(null); @@ -1458,7 +1456,7 @@ export const ClaudeCodeSession: React.FC = ({ showTimeline && "sm:mr-96" )}> {/* File Explorer Panel */} - { @@ -1470,7 +1468,6 @@ export const ClaudeCodeSession: React.FC = ({ setEditingFile(path); }} onToggle={() => setShowFileExplorer(!showFileExplorer)} - width={fileExplorerWidth} /> {/* Main Content with Input */} @@ -1481,28 +1478,43 @@ export const ClaudeCodeSession: React.FC = ({ )}> {showPreview ? ( // Split pane layout when preview is active - - {projectPathInput} - {messagesList} - - } - right={ - - } - initialSplit={splitPosition} - onSplitChange={setSplitPosition} - minLeftWidth={400} - minRightWidth={400} - className="h-full" - /> +
+ +
+ {projectPathInput} + {messagesList} +
+ {/* Floating Input for preview mode */} +
+ +
+
+ } + right={ + + } + initialSplit={splitPosition} + onSplitChange={setSplitPosition} + minLeftWidth={400} + minRightWidth={400} + className="h-full" + /> + ) : editingFile ? ( // File Editor layout with enhanced features
@@ -1513,8 +1525,9 @@ export const ClaudeCodeSession: React.FC = ({ />
) : ( - // Original layout when no preview + // Original layout when no preview or editor
+ {/* Main content area with messages */}
{projectPathInput} {messagesList} @@ -1531,22 +1544,20 @@ export const ClaudeCodeSession: React.FC = ({ )}
- {/* Floating Prompt Input - Bound to Main Content */} - - {/* Queued Prompts Display */} - - {queuedPrompts.length > 0 && ( - -
-
+ {/* Floating elements container - same width as main content */} +
+ + {/* Queued Prompts Display */} + + {queuedPrompts.length > 0 && ( + +
+
Queued Prompts ({queuedPrompts.length}) @@ -1587,17 +1598,17 @@ export const ClaudeCodeSession: React.FC = ({
)} - + - {/* Navigation Arrows */} - {displayableMessages.length > 5 && ( - + {/* Navigation Arrows */} + {displayableMessages.length > 5 && ( +
)}
{/* Git Panel */} - setShowGitPanel(!showGitPanel)} - width={gitPanelWidth} />
diff --git a/src/components/FileExplorerPanelEnhanced.tsx b/src/components/FileExplorerPanelEnhanced.tsx new file mode 100644 index 0000000..5d5e157 --- /dev/null +++ b/src/components/FileExplorerPanelEnhanced.tsx @@ -0,0 +1,810 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { + Folder, + FolderOpen, + File, + FileText, + FileCode, + FileJson, + FileImage, + Search, + ChevronRight, + ChevronDown, + X, + RefreshCw, + Loader2, + AlertCircle, + GripVertical, + FolderTree, + FileStack, + Maximize2, + Minimize2, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface FileNode { + name: string; + path: string; + file_type: "file" | "directory"; + children?: FileNode[]; + size?: number; + modified?: number; + expanded?: boolean; + depth?: number; +} + +interface FileExplorerPanelEnhancedProps { + projectPath: string; + isVisible: boolean; + onFileSelect?: (path: string) => void; + onFileOpen?: (path: string) => void; + onToggle: () => void; + className?: string; +} + +// 获取文件图标 +const getFileIcon = (filename: string) => { + const ext = filename.split(".").pop()?.toLowerCase(); + + const iconMap: Record = { + // 代码文件 + ts: , + tsx: , + js: , + jsx: , + py: , + rs: , + go: , + java: , + cpp: , + c: , + + // 配置文件 + json: , + yaml: , + yml: , + toml: , + xml: , + + // 文档文件 + md: , + txt: , + pdf: , + + // 图片文件 + png: , + jpg: , + jpeg: , + gif: , + svg: , + ico: , + }; + + return iconMap[ext || ""] || ; +}; + +// 组织文件到文件夹结构 +const organizeFilesByFolder = (files: FileNode[]): Map => { + const folderMap = new Map(); + + const processNode = (node: FileNode, parentPath: string = "") => { + const currentPath = parentPath || "根目录"; + + if (node.file_type === "file") { + if (!folderMap.has(currentPath)) { + folderMap.set(currentPath, []); + } + folderMap.get(currentPath)!.push(node); + } else { + const folderPath = parentPath ? `${parentPath}/${node.name}` : node.name; + if (!folderMap.has(folderPath)) { + folderMap.set(folderPath, []); + } + + if (node.children) { + node.children.forEach(child => processNode(child, folderPath)); + } + } + }; + + files.forEach(node => processNode(node)); + return folderMap; +}; + +export const FileExplorerPanelEnhanced: React.FC = ({ + projectPath, + isVisible, + onFileSelect, + onFileOpen, + onToggle, + className, +}) => { + const { t } = useTranslation(); + const [fileTree, setFileTree] = useState([]); + const [filteredTree, setFilteredTree] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedPath, setSelectedPath] = useState(null); + const [flattenedNodes, setFlattenedNodes] = useState([]); + const [lastClickTime, setLastClickTime] = useState(0); + const [lastClickPath, setLastClickPath] = useState(null); + const [width, setWidth] = useState(320); + const [isResizing, setIsResizing] = useState(false); + const [viewMode, setViewMode] = useState<"tree" | "folder">("tree"); + + const panelRef = useRef(null); + const resizeHandleRef = useRef(null); + const unlistenRef = useRef(null); + + // 处理拖拽调整宽度 + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing) return; + + const newWidth = e.clientX; + if (newWidth >= 200 && newWidth <= 600) { + setWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + if (isResizing) { + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing]); + + // 切换节点展开状态 + const toggleExpand = useCallback((path: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + // 展开所有节点(从指定节点开始) + const expandAll = useCallback((startNode?: FileNode) => { + const nodesToExpand = new Set(); + + const collectNodes = (nodes: FileNode[]) => { + nodes.forEach(node => { + if (node.file_type === 'directory') { + nodesToExpand.add(node.path); + if (node.children) { + collectNodes(node.children); + } + } + }); + }; + + if (startNode) { + if (startNode.file_type === 'directory') { + nodesToExpand.add(startNode.path); + if (startNode.children) { + collectNodes(startNode.children); + } + } + } else { + collectNodes(filteredTree); + } + + setExpandedNodes(nodesToExpand); + }, [filteredTree]); + + // 收起所有节点(从指定节点开始) + const collapseAll = useCallback((startNode?: FileNode) => { + if (startNode) { + const nodesToRemove = new Set(); + + const collectNodes = (node: FileNode) => { + if (node.file_type === 'directory') { + nodesToRemove.add(node.path); + if (node.children) { + node.children.forEach(collectNodes); + } + } + }; + + collectNodes(startNode); + + setExpandedNodes(prev => { + const next = new Set(prev); + nodesToRemove.forEach(path => next.delete(path)); + return next; + }); + } else { + setExpandedNodes(new Set()); + } + }, []); + + // 获取当前选中的节点 + const getSelectedNode = useCallback((): FileNode | undefined => { + if (!selectedPath) return undefined; + + const findNode = (nodes: FileNode[]): FileNode | undefined => { + for (const node of nodes) { + if (node.path === selectedPath) { + return node; + } + if (node.children) { + const found = findNode(node.children); + if (found) return found; + } + } + return undefined; + }; + + return findNode(filteredTree); + }, [selectedPath, filteredTree]); + + // 处理展开按钮点击 + const handleExpandAllClick = useCallback(() => { + const selectedNode = getSelectedNode(); + expandAll(selectedNode); + }, [getSelectedNode, expandAll]); + + // 处理收起按钮点击 + const handleCollapseAllClick = useCallback(() => { + const selectedNode = getSelectedNode(); + collapseAll(selectedNode); + }, [getSelectedNode, collapseAll]); + + // 扁平化文件树 + const flattenTree = useCallback((nodes: FileNode[], depth = 0): FileNode[] => { + const result: FileNode[] = []; + + nodes.forEach(node => { + const nodeWithDepth = { ...node, depth }; + result.push(nodeWithDepth); + + if (node.file_type === 'directory' && expandedNodes.has(node.path) && node.children) { + result.push(...flattenTree(node.children, depth + 1)); + } + }); + + return result; + }, [expandedNodes]); + + // 加载文件树 + const loadFileTree = useCallback(async () => { + if (!projectPath) return; + + try { + setLoading(true); + setError(null); + + const tree = await invoke("get_file_tree", { + projectPath: projectPath, // 使用驼峰命名 + }); + + setFileTree(tree); + setFilteredTree(tree); + + // 默认展开第一层目录 + const firstLevelDirs = tree + .filter(node => node.file_type === 'directory') + .map(node => node.path); + setExpandedNodes(new Set(firstLevelDirs)); + } catch (err) { + console.error("Failed to load file tree:", err); + setError(err instanceof Error ? err.message : "Failed to load file tree"); + } finally { + setLoading(false); + } + }, [projectPath]); + + // 处理文件打开 + const handleOpenFile = useCallback((path: string) => { + if (onFileOpen) { + onFileOpen(path); + } + }, [onFileOpen]); + + // 处理键盘导航 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible) return; + + const currentIndex = flattenedNodes.findIndex(node => node.path === selectedPath); + if (currentIndex === -1 && flattenedNodes.length > 0) { + setSelectedPath(flattenedNodes[0].path); + return; + } + + const currentNode = flattenedNodes[currentIndex]; + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + if (currentIndex > 0) { + const prevNode = flattenedNodes[currentIndex - 1]; + setSelectedPath(prevNode.path); + } + break; + + case 'ArrowDown': + e.preventDefault(); + if (currentIndex < flattenedNodes.length - 1) { + const nextNode = flattenedNodes[currentIndex + 1]; + setSelectedPath(nextNode.path); + } + break; + + case 'ArrowLeft': + e.preventDefault(); + if (currentNode) { + if (currentNode.file_type === 'directory' && expandedNodes.has(currentNode.path)) { + toggleExpand(currentNode.path); + } + } + break; + + case 'ArrowRight': + e.preventDefault(); + if (currentNode) { + if (currentNode.file_type === 'directory') { + if (!expandedNodes.has(currentNode.path)) { + toggleExpand(currentNode.path); + } + } else { + handleOpenFile(currentNode.path); + } + } + break; + + case 'Enter': + e.preventDefault(); + if (currentNode) { + if (currentNode.file_type === 'directory') { + toggleExpand(currentNode.path); + } else { + handleOpenFile(currentNode.path); + } + } + break; + } + }; + + if (isVisible) { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + } + }, [isVisible, selectedPath, flattenedNodes, expandedNodes, toggleExpand, onFileSelect, handleOpenFile]); + + // 更新扁平化节点列表 + useEffect(() => { + setFlattenedNodes(flattenTree(filteredTree)); + }, [filteredTree, flattenTree]); + + // 过滤文件树 + useEffect(() => { + if (!searchTerm) { + setFilteredTree(fileTree); + return; + } + + const filterNodes = (nodes: FileNode[]): FileNode[] => { + return nodes.reduce((acc: FileNode[], node) => { + const matches = node.name.toLowerCase().includes(searchTerm.toLowerCase()); + + if (node.file_type === 'directory' && node.children) { + const filteredChildren = filterNodes(node.children); + if (filteredChildren.length > 0 || matches) { + acc.push({ + ...node, + children: filteredChildren, + }); + } + } else if (matches) { + acc.push(node); + } + + return acc; + }, []); + }; + + setFilteredTree(filterNodes(fileTree)); + }, [searchTerm, fileTree]); + + // 监听文件系统变化 + useEffect(() => { + if (!projectPath || !isVisible) return; + + const setupListener = async () => { + try { + unlistenRef.current = await listen("file-changed", (event) => { + console.log("File changed:", event.payload); + loadFileTree(); + }); + } catch (err) { + console.error("Failed to setup file listener:", err); + } + }; + + setupListener(); + loadFileTree(); + + return () => { + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + }; + }, [projectPath, isVisible, loadFileTree]); + + // 处理文件点击 + const handleFileClick = useCallback((node: FileNode) => { + setSelectedPath(node.path); + + if (node.file_type === 'directory') { + toggleExpand(node.path); + } else { + const currentTime = Date.now(); + + if (lastClickPath === node.path && currentTime - lastClickTime < 500) { + if (onFileSelect) { + onFileSelect(node.path); + } + } else { + handleOpenFile(node.path); + } + + setLastClickTime(currentTime); + setLastClickPath(node.path); + } + }, [onFileSelect, toggleExpand, lastClickTime, lastClickPath, handleOpenFile]); + + // 渲染文件节点 + const renderFileNode = (node: FileNode, depth = 0) => { + const isExpanded = expandedNodes.has(node.path); + const isSelected = selectedPath === node.path; + const isDirectory = node.file_type === 'directory'; + + // 计算显示的路径(处理长路径) + const displayName = node.name.length > 30 + ? `${node.name.substring(0, 27)}...` + : node.name; + + return ( +
+ + +
handleFileClick(node)} + > + {isDirectory && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} + + {isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + getFileIcon(node.name) + )} + + + + + + {displayName} + + + {node.name.length > 30 && ( + +

{node.path}

+
+ )} +
+
+
+
+ + + handleOpenFile(node.path)}> + {t("app.open")} + + {!isDirectory && onFileSelect && ( + onFileSelect(node.path)}> + {t("app.addToMentions")} + + )} + navigator.clipboard.writeText(node.path)}> + {t("app.copyPath")} + + +
+ + {isDirectory && isExpanded && node.children && ( +
+ {node.children.map((child) => renderFileNode(child, depth + 1))} +
+ )} +
+ ); + }; + + // 渲染文件夹分组视图 + const renderFolderView = () => { + const folderMap = organizeFilesByFolder(filteredTree); + const folders = Array.from(folderMap.keys()).sort(); + + return ( +
+ {folders.map(folderPath => { + const files = folderMap.get(folderPath) || []; + const isExpanded = expandedNodes.has(folderPath); + + if (files.length === 0) return null; + + return ( +
+
toggleExpand(folderPath)} + > + {isExpanded ? ( + + ) : ( + + )} + + + {folderPath} + + + {files.length} 个文件 + +
+ + {isExpanded && ( +
+ {files.map(file => ( +
handleFileClick(file)} + > + {getFileIcon(file.name)} + + + + + {file.name.length > 35 + ? `${file.name.substring(0, 32)}...` + : file.name} + + + {file.name.length > 35 && ( + +

{file.path}

+
+ )} +
+
+
+ ))} +
+ )} +
+ ); + })} +
+ ); + }; + + return ( + + {isVisible && ( + + {/* 拖拽手柄 */} +
setIsResizing(true)} + > +
+ +
+
+ + {/* Header */} +
+
+ +

{t("app.fileExplorer")}

+
+
+ {/* 展开/收起按钮 */} + + + + + + +

{selectedPath ? '展开当前文件夹' : '展开所有文件夹'}

+
+
+
+ + + + + + + +

{selectedPath ? '收起当前文件夹' : '收起所有文件夹'}

+
+
+
+ + {/* 视图切换按钮 */} + + + + + + +

{viewMode === 'tree' ? '切换到文件夹视图' : '切换到树形视图'}

+
+
+
+ + + +
+
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + placeholder={t("app.searchFiles")} + className="pl-8 h-8" + /> +
+
+ + {/* Content */} + {error ? ( +
+ +

{error}

+
+ ) : loading ? ( +
+ +
+ ) : ( + +
+ {viewMode === 'tree' ? ( + filteredTree.map((node) => renderFileNode(node)) + ) : ( + renderFolderView() + )} +
+
+ )} +
+ )} +
+ ); +}; + +export default FileExplorerPanelEnhanced; \ No newline at end of file diff --git a/src/components/GitPanelEnhanced.tsx b/src/components/GitPanelEnhanced.tsx new file mode 100644 index 0000000..7044383 --- /dev/null +++ b/src/components/GitPanelEnhanced.tsx @@ -0,0 +1,802 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { invoke } from "@tauri-apps/api/core"; +import { + GitBranch, + GitCommit, + GitPullRequest, + GitMerge, + X, + RefreshCw, + Loader2, + AlertCircle, + FileText, + FilePlus, + FileX, + FileDiff, + GripVertical, + Check, + Folder, + FolderOpen, + ChevronRight, + ChevronDown, + Maximize2, + Minimize2 +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface GitFileStatus { + path: string; + status: string; + staged: boolean; +} + +interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileTreeNode[]; + status?: string; + staged?: boolean; + expanded?: boolean; +} + +interface GitStatus { + branch: string; + ahead: number; + behind: number; + staged: GitFileStatus[]; + modified: GitFileStatus[]; + untracked: GitFileStatus[]; + conflicted: GitFileStatus[]; + is_clean: boolean; + remote_url: string | null; +} + +interface GitCommit { + hash: string; + short_hash: string; + author: string; + email: string; + date: string; + message: string; + files_changed: number; + insertions: number; + deletions: number; +} + +interface GitPanelEnhancedProps { + projectPath: string; + isVisible: boolean; + onToggle: () => void; + onFileSelect?: (path: string) => void; + className?: string; +} + +// 获取文件状态图标 +const getFileStatusIcon = (status: 'modified' | 'staged' | 'untracked' | 'conflicted') => { + switch (status) { + case 'modified': + return ; + case 'staged': + return ; + case 'untracked': + return ; + case 'conflicted': + return ; + default: + return ; + } +}; + +// 获取文件状态标签 +const getFileStatusBadge = (status: string) => { + switch (status) { + case 'modified': + return M; + case 'added': + return A; + case 'deleted': + return D; + case 'renamed': + return R; + case 'untracked': + return U; + case 'conflicted': + return C; + default: + return null; + } +}; + +export const GitPanelEnhanced: React.FC = ({ + projectPath, + isVisible, + onToggle, + onFileSelect, + className, +}) => { + const [gitStatus, setGitStatus] = useState(null); + const [commits, setCommits] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("changes"); + const [width, setWidth] = useState(320); + const [isResizing, setIsResizing] = useState(false); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [selectedPath, setSelectedPath] = useState(null); + + const panelRef = useRef(null); + const resizeHandleRef = useRef(null); + const refreshIntervalRef = useRef(null); + + // 处理拖拽调整宽度 + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing) return; + + const windowWidth = window.innerWidth; + const newWidth = windowWidth - e.clientX; + + if (newWidth >= 200 && newWidth <= 600) { + setWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + if (isResizing) { + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing]); + + // 加载 Git 状态 + const loadGitStatus = useCallback(async () => { + if (!projectPath) return; + + try { + setLoading(true); + setError(null); + + const status = await invoke("get_git_status", { + path: projectPath, // 修改参数名为 path + }); + + setGitStatus(status); + } catch (err) { + console.error("Failed to load git status:", err); + setError(err instanceof Error ? err.message : "Failed to load git status"); + } finally { + setLoading(false); + } + }, [projectPath]); + + // 加载提交历史 + const loadCommits = useCallback(async () => { + if (!projectPath) return; + + try { + const commitList = await invoke("get_git_commits", { + projectPath: projectPath, // 使用驼峰命名 + limit: 20, + }); + + setCommits(commitList); + } catch (err) { + console.error("Failed to load commits:", err); + } + }, [projectPath]); + + // 自动刷新 + useEffect(() => { + if (!isVisible) { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + return; + } + + loadGitStatus(); + loadCommits(); + + // 每5秒刷新一次 + refreshIntervalRef.current = setInterval(() => { + loadGitStatus(); + if (activeTab === 'history') { + loadCommits(); + } + }, 5000); + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + }; + }, [isVisible, projectPath, activeTab, loadGitStatus, loadCommits]); + + // 处理文件点击 + const handleFileClick = (filePath: string) => { + if (onFileSelect) { + const fullPath = `${projectPath}/${filePath}`; + onFileSelect(fullPath); + } + }; + + // 切换节点展开状态 + const toggleExpand = useCallback((path: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + // 构建文件树结构(不依赖于 expandedNodes) + const buildFileTree = (files: GitFileStatus[]): FileTreeNode[] => { + const root: FileTreeNode = { + name: 'root', + path: '', + type: 'directory', + children: [] + }; + + files.forEach(file => { + const parts = file.path.split('/'); + let currentNode = root; + + // 遍历路径的每个部分,构建树结构 + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLastPart = i === parts.length - 1; + const currentPath = parts.slice(0, i + 1).join('/'); + + if (isLastPart) { + // 添加文件节点 + if (!currentNode.children) { + currentNode.children = []; + } + currentNode.children.push({ + name: part, + path: file.path, + type: 'file', + status: file.status, + staged: file.staged + }); + } else { + // 查找或创建目录节点 + if (!currentNode.children) { + currentNode.children = []; + } + + let dirNode = currentNode.children.find( + child => child.type === 'directory' && child.name === part + ); + + if (!dirNode) { + dirNode = { + name: part, + path: currentPath, + type: 'directory', + children: [] + }; + currentNode.children.push(dirNode); + } + + currentNode = dirNode; + } + } + }); + + // 排序:目录在前,文件在后,按名称字母顺序 + const sortNodes = (nodes: FileTreeNode[]) => { + nodes.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name); + }); + + nodes.forEach(node => { + if (node.children) { + sortNodes(node.children); + } + }); + }; + + if (root.children) { + sortNodes(root.children); + } + + return root.children || []; + }; + + // 展开所有节点(从指定节点开始) + const expandAll = useCallback((startPath?: string) => { + const nodesToExpand = new Set(); + + const collectNodes = (nodes: FileTreeNode[], parentPath: string = '') => { + nodes.forEach(node => { + if (node.type === 'directory') { + const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; + nodesToExpand.add(node.path); + if (node.children) { + collectNodes(node.children, fullPath); + } + } + }); + }; + + if (startPath) { + // 找到指定节点并展开其子节点 + const findAndExpand = (nodes: FileTreeNode[]): boolean => { + for (const node of nodes) { + if (node.path === startPath && node.type === 'directory') { + nodesToExpand.add(node.path); + if (node.children) { + collectNodes(node.children, node.path); + } + return true; + } + if (node.children && findAndExpand(node.children)) { + return true; + } + } + return false; + }; + + // 先构建完整的树 + const allTrees = []; + if (gitStatus) { + if (gitStatus.staged.length > 0) allTrees.push(...buildFileTree(gitStatus.staged)); + if (gitStatus.modified.length > 0) allTrees.push(...buildFileTree(gitStatus.modified)); + if (gitStatus.untracked.length > 0) allTrees.push(...buildFileTree(gitStatus.untracked)); + if (gitStatus.conflicted.length > 0) allTrees.push(...buildFileTree(gitStatus.conflicted)); + } + + findAndExpand(allTrees); + } else { + // 展开所有节点 + if (gitStatus) { + const allTrees = []; + if (gitStatus.staged.length > 0) allTrees.push(...buildFileTree(gitStatus.staged)); + if (gitStatus.modified.length > 0) allTrees.push(...buildFileTree(gitStatus.modified)); + if (gitStatus.untracked.length > 0) allTrees.push(...buildFileTree(gitStatus.untracked)); + if (gitStatus.conflicted.length > 0) allTrees.push(...buildFileTree(gitStatus.conflicted)); + collectNodes(allTrees); + } + } + + setExpandedNodes(nodesToExpand); + }, [gitStatus]); + + // 收起所有节点(从指定节点开始) + const collapseAll = useCallback((startPath?: string) => { + if (startPath) { + const nodesToRemove = new Set(); + + const collectNodes = (nodes: FileTreeNode[]): boolean => { + for (const node of nodes) { + if (node.path === startPath && node.type === 'directory') { + const collectChildren = (n: FileTreeNode) => { + if (n.type === 'directory') { + nodesToRemove.add(n.path); + if (n.children) { + n.children.forEach(collectChildren); + } + } + }; + collectChildren(node); + return true; + } + if (node.children && collectNodes(node.children)) { + return true; + } + } + return false; + }; + + // 构建完整的树 + const allTrees = []; + if (gitStatus) { + if (gitStatus.staged.length > 0) allTrees.push(...buildFileTree(gitStatus.staged)); + if (gitStatus.modified.length > 0) allTrees.push(...buildFileTree(gitStatus.modified)); + if (gitStatus.untracked.length > 0) allTrees.push(...buildFileTree(gitStatus.untracked)); + if (gitStatus.conflicted.length > 0) allTrees.push(...buildFileTree(gitStatus.conflicted)); + } + + collectNodes(allTrees); + + setExpandedNodes(prev => { + const next = new Set(prev); + nodesToRemove.forEach(path => next.delete(path)); + return next; + }); + } else { + setExpandedNodes(new Set()); + } + }, [gitStatus]); + + + // 渲染文件树节点 + const renderFileTreeNode = (node: FileTreeNode, depth = 0, statusType: 'modified' | 'staged' | 'untracked' | 'conflicted') => { + const isExpanded = node.type === 'directory' && expandedNodes.has(node.path); + const isDirectory = node.type === 'directory'; + const hasChildren = node.children && node.children.length > 0; + const isSelected = selectedPath === node.path; + + return ( +
+
{ + setSelectedPath(node.path); + if (isDirectory) { + toggleExpand(node.path); + } else { + handleFileClick(node.path); + } + }} + > + {isDirectory && hasChildren && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} + + {isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + <> + {node.status && getFileStatusBadge(node.status)} + + + )} + + + + + + {node.name} + + + {(node.name.length > 30 || (!isDirectory && node.path.length > 40)) && ( + +

{node.path}

+
+ )} +
+
+
+ + {isDirectory && isExpanded && node.children && ( +
+ {node.children.map((child) => renderFileTreeNode(child, depth + 1, statusType))} +
+ )} +
+ ); + }; + + // 渲染文件列表(树形结构) + const renderFileList = (files: GitFileStatus[], statusType: 'modified' | 'staged' | 'untracked' | 'conflicted') => { + if (files.length === 0) return null; + + const fileTree = buildFileTree(files); + + return ( +
+
+ {getFileStatusIcon(statusType)} + + {statusType === 'modified' && '已修改'} + {statusType === 'staged' && '已暂存'} + {statusType === 'untracked' && '未跟踪'} + {statusType === 'conflicted' && '冲突'} + + + {files.length} + +
+ +
+ {fileTree.map((node) => renderFileTreeNode(node, 0, statusType))} +
+
+ ); + }; + + // 渲染提交历史 + const renderCommitHistory = () => { + if (commits.length === 0) { + return ( +
+ +

暂无提交记录

+
+ ); + } + + return ( +
+ {commits.map((commit) => ( +
+
+ +
+

+ {commit.message} +

+
+ + {commit.author} + + + {commit.date} + + + {commit.short_hash || commit.hash.substring(0, 7)} + + {commit.files_changed > 0 && ( + + {commit.files_changed} files + + )} +
+
+
+
+ ))} +
+ ); + }; + + // 计算变更统计 + const changeStats = gitStatus ? { + total: gitStatus.staged.length + gitStatus.modified.length + gitStatus.untracked.length + gitStatus.conflicted.length, + staged: gitStatus.staged.length, + modified: gitStatus.modified.length, + untracked: gitStatus.untracked.length, + conflicted: gitStatus.conflicted.length, + } : null; + + return ( + + {isVisible && ( + + {/* 拖拽手柄 */} +
setIsResizing(true)} + > +
+ +
+
+ + {/* Header */} +
+
+ +

Git

+ {gitStatus && ( + + {gitStatus.branch} + + )} +
+
+ {/* 展开/收起按钮 */} + + + + + + +

{selectedPath ? '展开当前文件夹' : '展开所有文件夹'}

+
+
+
+ + + + + + + +

{selectedPath ? '收起当前文件夹' : '收起所有文件夹'}

+
+
+
+ + {changeStats && changeStats.total > 0 && ( + + {changeStats.total} 变更 + + )} + + +
+
+ + {/* Branch Info */} + {gitStatus && (gitStatus.ahead > 0 || gitStatus.behind > 0) && ( +
+
+ {gitStatus.ahead > 0 && ( +
+ + {gitStatus.ahead} ahead +
+ )} + {gitStatus.behind > 0 && ( +
+ + {gitStatus.behind} behind +
+ )} +
+
+ )} + + {/* Tabs */} + + + + + 变更 + {changeStats && changeStats.total > 0 && ( + + {changeStats.total} + + )} + + + + 历史 + + + + {/* Content */} + {error ? ( +
+ +

{error}

+
+ ) : loading && !gitStatus ? ( +
+ +
+ ) : ( + <> + + +
+ {gitStatus && ( + <> + {renderFileList(gitStatus.staged, 'staged')} + {renderFileList(gitStatus.modified, 'modified')} + {renderFileList(gitStatus.untracked, 'untracked')} + {renderFileList(gitStatus.conflicted, 'conflicted')} + + {changeStats?.total === 0 && ( +
+ +

工作区干净

+
+ )} + + )} +
+
+
+ + + +
+ {renderCommitHistory()} +
+
+
+ + )} +
+
+ )} +
+ ); +}; + +export default GitPanelEnhanced; \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index a70dd83..b4dab2e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -10,6 +10,42 @@ display: none; } +/* Custom thin scrollbar */ +.scrollbar-thin { + scrollbar-width: thin; +} + +.scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background-color: rgba(155, 155, 155, 0.3); + border-radius: 3px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: rgba(155, 155, 155, 0.5); +} + +/* Scrollbar colors */ +.scrollbar-thumb-muted::-webkit-scrollbar-thumb { + background-color: rgba(100, 100, 100, 0.3); +} + +.hover\:scrollbar-thumb-muted-foreground:hover::-webkit-scrollbar-thumb { + background-color: rgba(100, 100, 100, 0.6); +} + +.scrollbar-track-transparent::-webkit-scrollbar-track { + background: transparent; +} + /* Custom animations */ @keyframes spin { from {