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

670 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
ChevronsUpDown,
ChevronsDownUp,
} 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 handleExpandAll = useCallback(() => {
const folders = new Set<string>();
filteredFiles.forEach((f) => {
const parts = f.path.split("/");
for (let i = 1; i < parts.length; i++) {
folders.add(parts.slice(0, i).join("/"));
}
});
setExpandedFolders(folders);
}, [filteredFiles]);
const handleCollapseAll = useCallback(() => {
setExpandedFolders(new Set());
}, []);
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 py-1 border border-border rounded font-mono text-xs cyber-bg-elevated text-foreground"
>
<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>
{viewMode === "tree" && (
<>
<Button
variant="outline"
size="sm"
onClick={handleExpandAll}
className="h-8 px-3 cyber-btn-outline font-mono text-xs"
>
<ChevronDown className="w-3 h-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCollapseAll}
className="h-8 px-3 cyber-btn-outline font-mono text-xs"
>
<ChevronRight 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>
);
}