git状态以及文化管理器优化
This commit is contained in:
@@ -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<Vec<FileNode>, 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())
|
||||
}
|
@@ -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<Vec<GitCommit>, String> {
|
||||
// 使用已有的 get_git_history 函数,直接传递 limit 参数
|
||||
get_git_history(project_path, Some(limit), None).await
|
||||
}
|
@@ -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");
|
||||
|
@@ -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
|
||||
|
@@ -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<ClaudeCodeSessionProps> = ({
|
||||
// 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);
|
||||
@@ -1458,7 +1456,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
showTimeline && "sm:mr-96"
|
||||
)}>
|
||||
{/* File Explorer Panel */}
|
||||
<FileExplorerPanel
|
||||
<FileExplorerPanelEnhanced
|
||||
projectPath={projectPath}
|
||||
isVisible={showFileExplorer}
|
||||
onFileSelect={(path) => {
|
||||
@@ -1470,7 +1468,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
setEditingFile(path);
|
||||
}}
|
||||
onToggle={() => setShowFileExplorer(!showFileExplorer)}
|
||||
width={fileExplorerWidth}
|
||||
/>
|
||||
|
||||
{/* Main Content with Input */}
|
||||
@@ -1481,28 +1478,43 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
)}>
|
||||
{showPreview ? (
|
||||
// Split pane layout when preview is active
|
||||
<SplitPane
|
||||
left={
|
||||
<div className="h-full flex flex-col">
|
||||
{projectPathInput}
|
||||
{messagesList}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<WebviewPreview
|
||||
initialUrl={previewUrl}
|
||||
onClose={handleClosePreview}
|
||||
isMaximized={isPreviewMaximized}
|
||||
onToggleMaximize={handleTogglePreviewMaximize}
|
||||
onUrlChange={handlePreviewUrlChange}
|
||||
/>
|
||||
}
|
||||
initialSplit={splitPosition}
|
||||
onSplitChange={setSplitPosition}
|
||||
minLeftWidth={400}
|
||||
minRightWidth={400}
|
||||
className="h-full"
|
||||
/>
|
||||
<div className="h-full">
|
||||
<SplitPane
|
||||
left={
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 flex flex-col max-w-5xl mx-auto w-full">
|
||||
{projectPathInput}
|
||||
{messagesList}
|
||||
</div>
|
||||
{/* Floating Input for preview mode */}
|
||||
<div className="max-w-5xl mx-auto w-full relative">
|
||||
<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={splitPosition}
|
||||
onSplitChange={setSplitPosition}
|
||||
minLeftWidth={400}
|
||||
minRightWidth={400}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
) : editingFile ? (
|
||||
// File Editor layout with enhanced features
|
||||
<div className="h-full flex flex-col relative">
|
||||
@@ -1513,8 +1525,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Original layout when no preview
|
||||
// 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 max-w-5xl mx-auto w-full">
|
||||
{projectPathInput}
|
||||
{messagesList}
|
||||
@@ -1531,22 +1544,20 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
)}
|
||||
</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">
|
||||
{/* Floating elements container - same width as main content */}
|
||||
<div className="max-w-5xl mx-auto w-full relative">
|
||||
<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})
|
||||
@@ -1587,17 +1598,17 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</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"
|
||||
>
|
||||
{/* 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"
|
||||
@@ -1650,7 +1661,8 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 z-50">
|
||||
{/* Floating Prompt Input - Now properly aligned with main content */}
|
||||
<div className="relative">
|
||||
<FloatingPromptInput
|
||||
ref={floatingPromptRef}
|
||||
onSend={handleSendPrompt}
|
||||
@@ -1682,17 +1694,17 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Git Panel */}
|
||||
<GitPanel
|
||||
<GitPanelEnhanced
|
||||
projectPath={projectPath}
|
||||
isVisible={showGitPanel}
|
||||
onToggle={() => setShowGitPanel(!showGitPanel)}
|
||||
width={gitPanelWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
810
src/components/FileExplorerPanelEnhanced.tsx
Normal file
810
src/components/FileExplorerPanelEnhanced.tsx
Normal file
@@ -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<string, React.ReactNode> = {
|
||||
// 代码文件
|
||||
ts: <FileCode className="h-4 w-4 text-blue-500" />,
|
||||
tsx: <FileCode className="h-4 w-4 text-blue-500" />,
|
||||
js: <FileCode className="h-4 w-4 text-yellow-500" />,
|
||||
jsx: <FileCode className="h-4 w-4 text-yellow-500" />,
|
||||
py: <FileCode className="h-4 w-4 text-green-500" />,
|
||||
rs: <FileCode className="h-4 w-4 text-orange-500" />,
|
||||
go: <FileCode className="h-4 w-4 text-cyan-500" />,
|
||||
java: <FileCode className="h-4 w-4 text-red-500" />,
|
||||
cpp: <FileCode className="h-4 w-4 text-purple-500" />,
|
||||
c: <FileCode className="h-4 w-4 text-purple-500" />,
|
||||
|
||||
// 配置文件
|
||||
json: <FileJson className="h-4 w-4 text-yellow-600" />,
|
||||
yaml: <FileText className="h-4 w-4 text-pink-500" />,
|
||||
yml: <FileText className="h-4 w-4 text-pink-500" />,
|
||||
toml: <FileText className="h-4 w-4 text-gray-500" />,
|
||||
xml: <FileText className="h-4 w-4 text-orange-500" />,
|
||||
|
||||
// 文档文件
|
||||
md: <FileText className="h-4 w-4 text-gray-600" />,
|
||||
txt: <FileText className="h-4 w-4 text-gray-500" />,
|
||||
pdf: <FileText className="h-4 w-4 text-red-600" />,
|
||||
|
||||
// 图片文件
|
||||
png: <FileImage className="h-4 w-4 text-green-600" />,
|
||||
jpg: <FileImage className="h-4 w-4 text-green-600" />,
|
||||
jpeg: <FileImage className="h-4 w-4 text-green-600" />,
|
||||
gif: <FileImage className="h-4 w-4 text-green-600" />,
|
||||
svg: <FileImage className="h-4 w-4 text-purple-600" />,
|
||||
ico: <FileImage className="h-4 w-4 text-blue-600" />,
|
||||
};
|
||||
|
||||
return iconMap[ext || ""] || <File className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
// 组织文件到文件夹结构
|
||||
const organizeFilesByFolder = (files: FileNode[]): Map<string, FileNode[]> => {
|
||||
const folderMap = new Map<string, FileNode[]>();
|
||||
|
||||
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<FileExplorerPanelEnhancedProps> = ({
|
||||
projectPath,
|
||||
isVisible,
|
||||
onFileSelect,
|
||||
onFileOpen,
|
||||
onToggle,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fileTree, setFileTree] = useState<FileNode[]>([]);
|
||||
const [filteredTree, setFilteredTree] = useState<FileNode[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [flattenedNodes, setFlattenedNodes] = useState<FileNode[]>([]);
|
||||
const [lastClickTime, setLastClickTime] = useState(0);
|
||||
const [lastClickPath, setLastClickPath] = useState<string | null>(null);
|
||||
const [width, setWidth] = useState(320);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"tree" | "folder">("tree");
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
const unlistenRef = useRef<UnlistenFn | null>(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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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<FileNode[]>("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 (
|
||||
<div key={node.path}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 hover:bg-accent rounded-sm cursor-pointer group",
|
||||
isSelected && "bg-accent",
|
||||
"select-none"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => handleFileClick(node)}
|
||||
>
|
||||
{isDirectory && (
|
||||
<div className="w-4 h-4 flex items-center justify-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-blue-500" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(node.name)
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{displayName}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{node.name.length > 30 && (
|
||||
<TooltipContent side="right">
|
||||
<p className="max-w-xs break-all">{node.path}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleOpenFile(node.path)}>
|
||||
{t("app.open")}
|
||||
</ContextMenuItem>
|
||||
{!isDirectory && onFileSelect && (
|
||||
<ContextMenuItem onClick={() => onFileSelect(node.path)}>
|
||||
{t("app.addToMentions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => navigator.clipboard.writeText(node.path)}>
|
||||
{t("app.copyPath")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => renderFileNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染文件夹分组视图
|
||||
const renderFolderView = () => {
|
||||
const folderMap = organizeFilesByFolder(filteredTree);
|
||||
const folders = Array.from(folderMap.keys()).sort();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{folders.map(folderPath => {
|
||||
const files = folderMap.get(folderPath) || [];
|
||||
const isExpanded = expandedNodes.has(folderPath);
|
||||
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={folderPath} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted"
|
||||
onClick={() => toggleExpand(folderPath)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<FolderOpen className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium flex-1 truncate">
|
||||
{folderPath}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{files.length} 个文件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-2 space-y-1">
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent cursor-pointer",
|
||||
selectedPath === file.path && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleFileClick(file)}
|
||||
>
|
||||
{getFileIcon(file.name)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{file.name.length > 35
|
||||
? `${file.name.substring(0, 32)}...`
|
||||
: file.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{file.name.length > 35 && (
|
||||
<TooltipContent side="right">
|
||||
<p className="max-w-xs break-all">{file.path}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 items-center gap-1">
|
||||
{/* 展开/收起按钮 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleExpandAllClick}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selectedPath ? '展开当前文件夹' : '展开所有文件夹'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleCollapseAllClick}
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selectedPath ? '收起当前文件夹' : '收起所有文件夹'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* 视图切换按钮 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setViewMode(viewMode === 'tree' ? 'folder' : 'tree')}
|
||||
>
|
||||
{viewMode === 'tree' ? (
|
||||
<FileStack className="h-4 w-4" />
|
||||
) : (
|
||||
<FolderTree className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{viewMode === 'tree' ? '切换到文件夹视图' : '切换到树形视图'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={loadFileTree}
|
||||
disabled={loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t("app.searchFiles")}
|
||||
className="pl-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||||
<p className="text-sm text-center text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{viewMode === 'tree' ? (
|
||||
filteredTree.map((node) => renderFileNode(node))
|
||||
) : (
|
||||
renderFolderView()
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileExplorerPanelEnhanced;
|
802
src/components/GitPanelEnhanced.tsx
Normal file
802
src/components/GitPanelEnhanced.tsx
Normal file
@@ -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 <FileDiff className="h-4 w-4 text-yellow-500" />;
|
||||
case 'staged':
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case 'untracked':
|
||||
return <FilePlus className="h-4 w-4 text-blue-500" />;
|
||||
case 'conflicted':
|
||||
return <FileX className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <FileText className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件状态标签
|
||||
const getFileStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'modified':
|
||||
return <Badge variant="outline" className="text-yellow-600 border-yellow-600">M</Badge>;
|
||||
case 'added':
|
||||
return <Badge variant="outline" className="text-green-600 border-green-600">A</Badge>;
|
||||
case 'deleted':
|
||||
return <Badge variant="outline" className="text-red-600 border-red-600">D</Badge>;
|
||||
case 'renamed':
|
||||
return <Badge variant="outline" className="text-blue-600 border-blue-600">R</Badge>;
|
||||
case 'untracked':
|
||||
return <Badge variant="outline" className="text-gray-600 border-gray-600">U</Badge>;
|
||||
case 'conflicted':
|
||||
return <Badge variant="outline" className="text-orange-600 border-orange-600">C</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const GitPanelEnhanced: React.FC<GitPanelEnhancedProps> = ({
|
||||
projectPath,
|
||||
isVisible,
|
||||
onToggle,
|
||||
onFileSelect,
|
||||
className,
|
||||
}) => {
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [commits, setCommits] = useState<GitCommit[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("changes");
|
||||
const [width, setWidth] = useState(320);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(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<GitStatus>("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<GitCommit[]>("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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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 (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 hover:bg-accent rounded-sm cursor-pointer group",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => {
|
||||
setSelectedPath(node.path);
|
||||
if (isDirectory) {
|
||||
toggleExpand(node.path);
|
||||
} else {
|
||||
handleFileClick(node.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDirectory && hasChildren && (
|
||||
<div className="w-4 h-4 flex items-center justify-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{node.status && getFileStatusBadge(node.status)}
|
||||
<FileText className="h-3 w-3 text-muted-foreground flex-shrink-0 ml-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{node.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(node.name.length > 30 || (!isDirectory && node.path.length > 40)) && (
|
||||
<TooltipContent side="right">
|
||||
<p className="max-w-xs break-all">{node.path}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => renderFileTreeNode(child, depth + 1, statusType))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染文件列表(树形结构)
|
||||
const renderFileList = (files: GitFileStatus[], statusType: 'modified' | 'staged' | 'untracked' | 'conflicted') => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const fileTree = buildFileTree(files);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground sticky top-0 bg-background z-10">
|
||||
{getFileStatusIcon(statusType)}
|
||||
<span>
|
||||
{statusType === 'modified' && '已修改'}
|
||||
{statusType === 'staged' && '已暂存'}
|
||||
{statusType === 'untracked' && '未跟踪'}
|
||||
{statusType === 'conflicted' && '冲突'}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{files.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{fileTree.map((node) => renderFileTreeNode(node, 0, statusType))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染提交历史
|
||||
const renderCommitHistory = () => {
|
||||
if (commits.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<GitCommit className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">暂无提交记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{commits.map((commit) => (
|
||||
<div
|
||||
key={commit.hash}
|
||||
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<GitCommit className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{commit.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{commit.author}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{commit.date}
|
||||
</span>
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{commit.short_hash || commit.hash.substring(0, 7)}
|
||||
</code>
|
||||
{commit.files_changed > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{commit.files_changed} files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 计算变更统计
|
||||
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 (
|
||||
<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 right-0 top-[172px] bottom-0 bg-background border-l shadow-lg z-40",
|
||||
"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">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-medium text-sm">Git</h3>
|
||||
{gitStatus && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{gitStatus.branch}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 展开/收起按钮 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => expandAll(selectedPath || undefined)}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selectedPath ? '展开当前文件夹' : '展开所有文件夹'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => collapseAll(selectedPath || undefined)}
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selectedPath ? '收起当前文件夹' : '收起所有文件夹'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{changeStats && changeStats.total > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{changeStats.total} 变更
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={loadGitStatus}
|
||||
disabled={loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch Info */}
|
||||
{gitStatus && (gitStatus.ahead > 0 || gitStatus.behind > 0) && (
|
||||
<div className="px-3 py-2 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{gitStatus.ahead > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GitPullRequest className="h-3 w-3 text-green-500" />
|
||||
<span>{gitStatus.ahead} ahead</span>
|
||||
</div>
|
||||
)}
|
||||
{gitStatus.behind > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GitMerge className="h-3 w-3 text-blue-500" />
|
||||
<span>{gitStatus.behind} behind</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
|
||||
<TabsList className="w-full rounded-none border-b">
|
||||
<TabsTrigger value="changes" className="flex-1 gap-2">
|
||||
<FileDiff className="h-4 w-4" />
|
||||
变更
|
||||
{changeStats && changeStats.total > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{changeStats.total}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex-1 gap-2">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
历史
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||||
<p className="text-sm text-center text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : loading && !gitStatus ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="changes" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-4">
|
||||
{gitStatus && (
|
||||
<>
|
||||
{renderFileList(gitStatus.staged, 'staged')}
|
||||
{renderFileList(gitStatus.modified, 'modified')}
|
||||
{renderFileList(gitStatus.untracked, 'untracked')}
|
||||
{renderFileList(gitStatus.conflicted, 'conflicted')}
|
||||
|
||||
{changeStats?.total === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Check className="h-8 w-8 mb-2 text-green-500" />
|
||||
<p className="text-sm">工作区干净</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="flex-1 m-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{renderCommitHistory()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitPanelEnhanced;
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user