CodeReview/frontend/src/components/audit/FileSelectionDialog.tsx

630 lines
25 KiB
TypeScript
Raw Normal View History

/**
* File Selection Dialog
* Cyberpunk Terminal Aesthetic
*/
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Search,
FileText,
CheckSquare,
Square,
FolderOpen,
Folder,
ChevronRight,
ChevronDown,
FileCode,
FileJson,
File,
Filter,
RotateCcw,
RefreshCw,
Terminal,
} from "lucide-react";
import { api } from "@/shared/config/database";
import { toast } from "sonner";
interface FileSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
branch?: string;
excludePatterns?: string[];
onConfirm: (selectedFiles: string[]) => void;
}
interface FileNode {
path: string;
size: number;
}
interface FolderNode {
name: string;
path: string;
files: FileNode[];
subfolders: Map<string, FolderNode>;
expanded: boolean;
}
// 文件类型图标映射
const getFileIcon = (path: string) => {
const ext = path.split(".").pop()?.toLowerCase() || "";
const codeExts = [
"js", "ts", "tsx", "jsx", "py", "java", "go", "rs", "cpp", "c", "h",
"cs", "php", "rb", "swift", "kt", "sh",
];
const configExts = ["json", "yml", "yaml", "toml", "xml", "ini"];
if (codeExts.includes(ext)) {
return <FileCode className="w-4 h-4 text-sky-400" />;
}
if (configExts.includes(ext)) {
return <FileJson className="w-4 h-4 text-amber-400" />;
}
return <File className="w-4 h-4 text-muted-foreground" />;
};
// 获取文件扩展名
const getExtension = (path: string): string => {
const ext = path.split(".").pop()?.toLowerCase() || "";
return ext;
};
// 构建文件夹树结构
const buildFolderTree = (files: FileNode[]): FolderNode => {
const root: FolderNode = {
name: "",
path: "",
files: [],
subfolders: new Map(),
expanded: true,
};
files.forEach((file) => {
const parts = file.path.split("/");
let current = root;
// 遍历路径的每个部分(除了文件名)
for (let i = 0; i < parts.length - 1; i++) {
const folderName = parts[i];
const folderPath = parts.slice(0, i + 1).join("/");
if (!current.subfolders.has(folderName)) {
current.subfolders.set(folderName, {
name: folderName,
path: folderPath,
files: [],
subfolders: new Map(),
expanded: true,
});
}
current = current.subfolders.get(folderName)!;
}
// 添加文件到当前文件夹
current.files.push(file);
});
return root;
};
export default function FileSelectionDialog({
open,
onOpenChange,
projectId,
branch,
excludePatterns,
onConfirm,
}: FileSelectionDialogProps) {
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<"tree" | "flat">("tree");
const [filterType, setFilterType] = useState<string>("");
useEffect(() => {
if (open && projectId) {
loadFiles();
} else {
setFiles([]);
setSelectedFiles(new Set());
setSearchTerm("");
setExpandedFolders(new Set());
setFilterType("");
}
}, [open, projectId, branch, excludePatterns]);
const loadFiles = async () => {
try {
setLoading(true);
const data = await api.getProjectFiles(projectId, branch, excludePatterns);
setFiles(data);
setSelectedFiles(new Set(data.map((f) => f.path)));
// 默认展开所有文件夹
const folders = new Set<string>();
data.forEach((f) => {
const parts = f.path.split("/");
for (let i = 1; i < parts.length; i++) {
folders.add(parts.slice(0, i).join("/"));
}
});
setExpandedFolders(folders);
} catch (error) {
console.error("Failed to load files:", error);
toast.error("加载文件列表失败");
} finally {
setLoading(false);
}
};
// 获取所有文件类型
const fileTypes = useMemo(() => {
const types = new Map<string, number>();
files.forEach((f) => {
const ext = getExtension(f.path);
if (ext) {
types.set(ext, (types.get(ext) || 0) + 1);
}
});
return Array.from(types.entries()).sort((a, b) => b[1] - a[1]);
}, [files]);
// 过滤后的文件
const filteredFiles = useMemo(() => {
let result = files;
// 按搜索词过滤
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter((f) => f.path.toLowerCase().includes(term));
}
// 按文件类型过滤
if (filterType) {
result = result.filter((f) => getExtension(f.path) === filterType);
}
return result;
}, [files, searchTerm, filterType]);
// 构建文件夹树
const folderTree = useMemo(() => buildFolderTree(filteredFiles), [filteredFiles]);
const handleToggleFile = useCallback((path: string) => {
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
if (newSelected.has(path)) {
newSelected.delete(path);
} else {
newSelected.add(path);
}
return newSelected;
});
}, []);
const handleToggleFolder = useCallback(
(folderPath: string) => {
// 获取该文件夹下的所有文件
const folderFiles = filteredFiles.filter(
(f) => f.path.startsWith(folderPath + "/") || f.path === folderPath
);
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
const allSelected = folderFiles.every((f) => newSelected.has(f.path));
if (allSelected) {
// 取消选择该文件夹下的所有文件
folderFiles.forEach((f) => newSelected.delete(f.path));
} else {
// 选择该文件夹下的所有文件
folderFiles.forEach((f) => newSelected.add(f.path));
}
return newSelected;
});
},
[filteredFiles]
);
const handleExpandFolder = useCallback((folderPath: string) => {
setExpandedFolders((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(folderPath)) {
newExpanded.delete(folderPath);
} else {
newExpanded.add(folderPath);
}
return newExpanded;
});
}, []);
const handleSelectAll = () => {
setSelectedFiles(new Set(filteredFiles.map((f) => f.path)));
};
const handleDeselectAll = () => {
const filteredPaths = new Set(filteredFiles.map((f) => f.path));
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
filteredPaths.forEach((p) => newSelected.delete(p));
return newSelected;
});
};
const handleInvertSelection = () => {
const filteredPaths = new Set(filteredFiles.map((f) => f.path));
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
filteredPaths.forEach((p) => {
if (newSelected.has(p)) {
newSelected.delete(p);
} else {
newSelected.add(p);
}
});
return newSelected;
});
};
const handleConfirm = () => {
if (selectedFiles.size === 0) {
toast.error("请至少选择一个文件");
return;
}
onConfirm(Array.from(selectedFiles));
onOpenChange(false);
};
const formatSize = (bytes: number) => {
if (bytes === 0) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
// 检查文件夹的选中状态
const getFolderSelectionState = (
folderPath: string
): "all" | "some" | "none" => {
const folderFiles = filteredFiles.filter((f) =>
f.path.startsWith(folderPath + "/")
);
if (folderFiles.length === 0) return "none";
const selectedCount = folderFiles.filter((f) =>
selectedFiles.has(f.path)
).length;
if (selectedCount === 0) return "none";
if (selectedCount === folderFiles.length) return "all";
return "some";
};
// 渲染文件夹树
const renderFolderTree = (node: FolderNode, depth: number = 0) => {
const items: React.ReactNode[] = [];
// 渲染子文件夹
Array.from(node.subfolders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((folder) => {
const isExpanded = expandedFolders.has(folder.path);
const selectionState = getFolderSelectionState(folder.path);
items.push(
<div key={`folder-${folder.path}`}>
<div
className="flex items-center space-x-2 p-2 hover:bg-muted border border-transparent hover:border-border cursor-pointer transition-colors rounded"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
<button
onClick={(e) => {
e.stopPropagation();
handleExpandFolder(folder.path);
}}
className="p-0.5 hover:bg-muted rounded"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectionState === "all"}
ref={(el) => {
if (el) {
(el as HTMLButtonElement).dataset.state =
selectionState === "some" ? "indeterminate" : selectionState === "all" ? "checked" : "unchecked";
}
}}
onCheckedChange={() => handleToggleFolder(folder.path)}
className="border-border data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-background0"
/>
</div>
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-amber-400" />
) : (
<Folder className="w-4 h-4 text-amber-400" />
)}
<span
className="text-sm font-mono font-medium flex-1 text-foreground"
onClick={() => handleExpandFolder(folder.path)}
>
{folder.name}
</span>
<Badge className="cyber-badge-muted font-mono text-xs">
{
filteredFiles.filter((f) =>
f.path.startsWith(folder.path + "/")
).length
}
</Badge>
</div>
{isExpanded && renderFolderTree(folder, depth + 1)}
</div>
);
});
// 渲染文件
node.files
.sort((a, b) => a.path.localeCompare(b.path))
.forEach((file) => {
const fileName = file.path.split("/").pop() || file.path;
items.push(
<div
key={`file-${file.path}`}
className="flex items-center space-x-3 p-2 hover:bg-muted border border-transparent hover:border-border cursor-pointer transition-colors rounded"
style={{ paddingLeft: `${depth * 16 + 32}px` }}
onClick={() => handleToggleFile(file.path)}
>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedFiles.has(file.path)}
onCheckedChange={() => handleToggleFile(file.path)}
className="border-border data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
{getFileIcon(file.path)}
<span
className="text-sm font-mono flex-1 min-w-0 truncate text-foreground"
title={file.path}
>
{fileName}
</span>
{file.size > 0 && (
<Badge className="cyber-badge-muted font-mono text-xs flex-shrink-0">
{formatSize(file.size)}
</Badge>
)}
</div>
);
});
return items;
};
// 渲染扁平列表
const renderFlatList = () => {
return filteredFiles.map((file) => (
<div
key={file.path}
className="flex items-center space-x-3 p-2 hover:bg-muted border border-transparent hover:border-border cursor-pointer transition-colors rounded"
onClick={() => handleToggleFile(file.path)}
>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedFiles.has(file.path)}
onCheckedChange={() => handleToggleFile(file.path)}
className="border-border data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
{getFileIcon(file.path)}
<div className="flex-1 min-w-0">
<p className="text-sm font-mono truncate text-foreground" title={file.path}>
{file.path}
</p>
</div>
{file.size > 0 && (
<Badge className="cyber-badge-muted font-mono text-xs flex-shrink-0">
{formatSize(file.size)}
</Badge>
)}
</div>
));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[1000px] !w-[95vw] max-h-[85vh] flex flex-col cyber-card p-0 cyber-dialog !fixed">
<DialogHeader className="cyber-card-header flex-shrink-0">
<div className="flex items-center gap-3">
<FolderOpen className="w-5 h-5 text-primary" />
<DialogTitle className="text-lg font-bold uppercase tracking-wider text-foreground">
</DialogTitle>
</div>
{excludePatterns && excludePatterns.length > 0 && (
<Badge className="cyber-badge-muted font-mono text-xs ml-auto">
{excludePatterns.length}
</Badge>
)}
</DialogHeader>
<div className="p-5 flex-1 flex flex-col min-h-0 space-y-3 overflow-y-auto">
{/* 工具栏 */}
<div className="flex items-center gap-2 flex-wrap">
{/* 搜索框 */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="搜索文件..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="!pl-10 h-9 cyber-input"
/>
</div>
{/* 文件类型筛选 */}
{fileTypes.length > 0 && (
<div className="flex items-center gap-1">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="h-9 px-2 cyber-input font-mono text-sm cyber-bg-elevated"
>
<option value=""></option>
{fileTypes.slice(0, 10).map(([ext, count]) => (
<option key={ext} value={ext}>
.{ext} ({count})
</option>
))}
</select>
</div>
)}
{/* 视图切换 */}
<div className="flex border border-border rounded overflow-hidden">
<button
onClick={() => setViewMode("tree")}
className={`px-3 py-1.5 text-xs font-mono uppercase ${viewMode === "tree" ? "bg-primary text-foreground" : "bg-muted text-muted-foreground hover:bg-muted"}`}
>
</button>
<button
onClick={() => setViewMode("flat")}
className={`px-3 py-1.5 text-xs font-mono uppercase border-l border-border ${viewMode === "flat" ? "bg-primary text-foreground" : "bg-muted text-muted-foreground hover:bg-muted"}`}
>
</button>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
className="h-8 px-3 cyber-btn-outline font-mono text-xs"
>
<CheckSquare className="w-3 h-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeselectAll}
className="h-8 px-3 cyber-btn-outline font-mono text-xs"
>
<Square className="w-3 h-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleInvertSelection}
className="h-8 px-3 cyber-btn-outline font-mono text-xs"
>
<RefreshCw className="w-3 h-3 mr-1" />
</Button>
{(searchTerm || filterType) && (
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchTerm("");
setFilterType("");
}}
className="h-8 px-3 cyber-btn-outline font-mono text-xs text-muted-foreground"
>
<RotateCcw className="w-3 h-3 mr-1" />
</Button>
)}
</div>
<div className="text-sm font-mono text-muted-foreground">
{searchTerm || filterType ? (
<span>
: {filteredFiles.length}/{files.length}
<span className="text-primary font-bold">{selectedFiles.size}</span>
</span>
) : (
<span>
{files.length} <span className="text-primary font-bold">{selectedFiles.size}</span>
</span>
)}
</div>
</div>
{/* 文件列表 */}
<div className="border border-border cyber-bg-elevated relative h-[450px] overflow-hidden rounded">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="loading-spinner" />
</div>
) : filteredFiles.length > 0 ? (
<div className="h-full overflow-auto custom-scrollbar">
<div className="p-2">
{viewMode === "tree"
? renderFolderTree(folderTree)
: renderFlatList()}
</div>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground">
<FileText className="w-12 h-12 mb-2 opacity-20" />
<p className="font-mono text-sm">
{searchTerm || filterType
? "没有匹配的文件"
: "没有找到文件"}
</p>
</div>
)}
</div>
</div>
<DialogFooter className="p-5 border-t border-border bg-muted flex-shrink-0 flex justify-between">
<div className="text-xs font-mono text-muted-foreground flex items-center gap-2">
<Terminal className="w-3 h-3" />
/
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="px-4 h-10 cyber-btn-outline font-mono"
>
</Button>
<Button
onClick={handleConfirm}
disabled={selectedFiles.size === 0}
className="px-5 h-10 cyber-btn-primary font-mono font-bold uppercase"
>
<FileText className="w-4 h-4 mr-2" />
({selectedFiles.size})
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}