2025-12-10 19:20:31 +08:00
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
} from "@/components/ui/dialog";
|
2025-12-06 20:47:28 +08:00
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-12-10 19:20:31 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Search,
|
|
|
|
|
|
FileText,
|
|
|
|
|
|
CheckSquare,
|
|
|
|
|
|
Square,
|
|
|
|
|
|
FolderOpen,
|
|
|
|
|
|
Folder,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
ChevronDown,
|
|
|
|
|
|
FileCode,
|
|
|
|
|
|
FileJson,
|
|
|
|
|
|
File,
|
|
|
|
|
|
Filter,
|
|
|
|
|
|
RotateCcw,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
} from "lucide-react";
|
2025-12-06 20:47:28 +08:00
|
|
|
|
import { api } from "@/shared/config/database";
|
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
|
|
|
|
|
|
interface FileSelectionDialogProps {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
projectId: string;
|
|
|
|
|
|
branch?: string;
|
2025-12-10 18:46:33 +08:00
|
|
|
|
excludePatterns?: string[];
|
2025-12-06 20:47:28 +08:00
|
|
|
|
onConfirm: (selectedFiles: string[]) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface FileNode {
|
|
|
|
|
|
path: string;
|
|
|
|
|
|
size: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
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-blue-600" />;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (configExts.includes(ext)) {
|
|
|
|
|
|
return <FileJson className="w-4 h-4 text-yellow-600" />;
|
|
|
|
|
|
}
|
|
|
|
|
|
return <File className="w-4 h-4 text-gray-500" />;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 获取文件扩展名
|
|
|
|
|
|
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) {
|
2025-12-06 20:47:28 +08:00
|
|
|
|
const [files, setFiles] = useState<FileNode[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
2025-12-10 19:20:31 +08:00
|
|
|
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [viewMode, setViewMode] = useState<"tree" | "flat">("tree");
|
|
|
|
|
|
const [filterType, setFilterType] = useState<string>("");
|
2025-12-06 20:47:28 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (open && projectId) {
|
|
|
|
|
|
loadFiles();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setFiles([]);
|
|
|
|
|
|
setSelectedFiles(new Set());
|
|
|
|
|
|
setSearchTerm("");
|
2025-12-10 19:20:31 +08:00
|
|
|
|
setExpandedFolders(new Set());
|
|
|
|
|
|
setFilterType("");
|
2025-12-06 20:47:28 +08:00
|
|
|
|
}
|
2025-12-10 18:46:33 +08:00
|
|
|
|
}, [open, projectId, branch, excludePatterns]);
|
2025-12-06 20:47:28 +08:00
|
|
|
|
|
|
|
|
|
|
const loadFiles = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
2025-12-10 18:46:33 +08:00
|
|
|
|
const data = await api.getProjectFiles(projectId, branch, excludePatterns);
|
2025-12-06 20:47:28 +08:00
|
|
|
|
setFiles(data);
|
2025-12-10 19:20:31 +08:00
|
|
|
|
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);
|
2025-12-06 20:47:28 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Failed to load files:", error);
|
|
|
|
|
|
toast.error("加载文件列表失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
// 获取所有文件类型
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤后的文件
|
2025-12-06 20:47:28 +08:00
|
|
|
|
const filteredFiles = useMemo(() => {
|
2025-12-10 19:20:31 +08:00
|
|
|
|
let result = files;
|
|
|
|
|
|
|
|
|
|
|
|
// 按搜索词过滤
|
|
|
|
|
|
if (searchTerm) {
|
|
|
|
|
|
const term = searchTerm.toLowerCase();
|
|
|
|
|
|
result = result.filter((f) => f.path.toLowerCase().includes(term));
|
2025-12-06 20:47:28 +08:00
|
|
|
|
}
|
2025-12-10 19:20:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 按文件类型过滤
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
2025-12-06 20:47:28 +08:00
|
|
|
|
|
|
|
|
|
|
const handleSelectAll = () => {
|
2025-12-10 19:20:31 +08:00
|
|
|
|
setSelectedFiles(new Set(filteredFiles.map((f) => f.path)));
|
2025-12-06 20:47:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeselectAll = () => {
|
2025-12-10 19:20:31 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2025-12-06 20:47:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
// 检查文件夹的选中状态
|
|
|
|
|
|
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-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
|
|
|
|
|
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleExpandFolder(folder.path);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="p-0.5 hover:bg-gray-200 rounded"
|
|
|
|
|
|
>
|
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=indeterminate]:bg-gray-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
|
<FolderOpen className="w-4 h-4 text-amber-500" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Folder className="w-4 h-4 text-amber-500" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="text-sm font-mono font-medium flex-1"
|
|
|
|
|
|
onClick={() => handleExpandFolder(folder.path)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{folder.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="text-xs font-mono rounded-none border-gray-300 text-gray-500"
|
|
|
|
|
|
>
|
|
|
|
|
|
{
|
|
|
|
|
|
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-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
|
|
|
|
|
|
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="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{getFileIcon(file.path)}
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="text-sm font-mono flex-1 min-w-0 truncate"
|
|
|
|
|
|
title={file.path}
|
|
|
|
|
|
>
|
|
|
|
|
|
{fileName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{file.size > 0 && (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="text-xs font-mono rounded-none border-gray-400 text-gray-500 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-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
|
|
|
|
|
|
onClick={() => handleToggleFile(file.path)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={selectedFiles.has(file.path)}
|
|
|
|
|
|
onCheckedChange={() => handleToggleFile(file.path)}
|
|
|
|
|
|
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{getFileIcon(file.path)}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<p className="text-sm font-mono truncate" title={file.path}>
|
|
|
|
|
|
{file.path}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{file.size > 0 && (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="text-xs font-mono rounded-none border-gray-400 text-gray-500 flex-shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatSize(file.size)}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-06 20:47:28 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
2025-12-10 19:20:31 +08:00
|
|
|
|
<DialogContent className="!max-w-[1000px] !w-[95vw] max-h-[85vh] flex flex-col bg-white border-2 border-black p-0 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-none">
|
|
|
|
|
|
<DialogHeader className="p-5 border-b-2 border-black bg-gray-50 flex-shrink-0">
|
2025-12-10 19:22:54 +08:00
|
|
|
|
<DialogTitle className="flex items-center gap-3 font-display font-bold uppercase text-lg pr-8">
|
|
|
|
|
|
<FolderOpen className="w-5 h-5 text-black flex-shrink-0" />
|
|
|
|
|
|
<span>选择要审计的文件</span>
|
|
|
|
|
|
{excludePatterns && excludePatterns.length > 0 && (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="rounded-none border-gray-400 text-gray-600 font-mono text-xs ml-auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
已排除 {excludePatterns.length} 种模式
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
2025-12-06 20:47:28 +08:00
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
<div className="p-5 flex-1 flex flex-col min-h-0 space-y-3">
|
|
|
|
|
|
{/* 工具栏 */}
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
{/* 搜索框 */}
|
|
|
|
|
|
<div className="relative flex-1 min-w-[200px]">
|
2025-12-06 20:47:28 +08:00
|
|
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="搜索文件..."
|
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
2025-12-10 19:20:31 +08:00
|
|
|
|
className="pl-10 h-9 rounded-none border-2 border-black font-mono text-sm"
|
2025-12-06 20:47:28 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-10 19:20:31 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 文件类型筛选 */}
|
|
|
|
|
|
{fileTypes.length > 0 && (
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<Filter className="w-4 h-4 text-gray-500" />
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={filterType}
|
|
|
|
|
|
onChange={(e) => setFilterType(e.target.value)}
|
|
|
|
|
|
className="h-9 px-2 rounded-none border-2 border-black font-mono text-sm bg-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">全部类型</option>
|
|
|
|
|
|
{fileTypes.slice(0, 10).map(([ext, count]) => (
|
|
|
|
|
|
<option key={ext} value={ext}>
|
|
|
|
|
|
.{ext} ({count})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 视图切换 */}
|
|
|
|
|
|
<div className="flex border-2 border-black">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setViewMode("tree")}
|
|
|
|
|
|
className={`px-2 py-1 text-xs font-mono ${viewMode === "tree" ? "bg-black text-white" : "bg-white text-black hover:bg-gray-100"}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
树形
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setViewMode("flat")}
|
|
|
|
|
|
className={`px-2 py-1 text-xs font-mono border-l-2 border-black ${viewMode === "flat" ? "bg-black text-white" : "bg-white text-black hover:bg-gray-100"}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
列表
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-12-06 20:47:28 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
{/* 操作按钮 */}
|
|
|
|
|
|
<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 rounded-none border-2 border-black 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 rounded-none border-2 border-black 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 rounded-none border-2 border-black 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 rounded-none border-2 border-gray-400 font-mono text-xs text-gray-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RotateCcw className="w-3 h-3 mr-1" />
|
|
|
|
|
|
重置筛选
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm font-mono text-gray-600">
|
|
|
|
|
|
{searchTerm || filterType ? (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
筛选: {filteredFiles.length}/{files.length} 个文件,
|
|
|
|
|
|
已选 {selectedFiles.size} 个
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span>
|
|
|
|
|
|
共 {files.length} 个文件,已选 {selectedFiles.size} 个
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-06 20:47:28 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
{/* 文件列表 - 使用固定高度确保滚动正常工作 */}
|
|
|
|
|
|
<div className="border-2 border-black bg-gray-50 relative h-[450px] overflow-hidden">
|
2025-12-06 20:47:28 +08:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
|
|
|
|
<div className="animate-spin rounded-none h-8 w-8 border-4 border-primary border-t-transparent"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : filteredFiles.length > 0 ? (
|
2025-12-10 19:20:31 +08:00
|
|
|
|
<div className="h-full overflow-auto">
|
|
|
|
|
|
<div className="p-2">
|
|
|
|
|
|
{viewMode === "tree"
|
|
|
|
|
|
? renderFolderTree(folderTree)
|
|
|
|
|
|
: renderFlatList()}
|
2025-12-06 20:47:28 +08:00
|
|
|
|
</div>
|
2025-12-10 19:20:31 +08:00
|
|
|
|
</div>
|
2025-12-06 20:47:28 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500">
|
|
|
|
|
|
<FileText className="w-12 h-12 mb-2 opacity-20" />
|
2025-12-10 19:20:31 +08:00
|
|
|
|
<p className="font-mono text-sm">
|
|
|
|
|
|
{searchTerm || filterType
|
|
|
|
|
|
? "没有匹配的文件"
|
|
|
|
|
|
: "没有找到文件"}
|
|
|
|
|
|
</p>
|
2025-12-06 20:47:28 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-10 19:20:31 +08:00
|
|
|
|
<DialogFooter className="p-5 border-t-2 border-black bg-gray-50 flex-shrink-0 flex justify-between">
|
|
|
|
|
|
<div className="text-xs font-mono text-gray-500">
|
|
|
|
|
|
提示:点击文件夹可展开/折叠,点击文件夹复选框可批量选择
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
|
className="px-4 h-9 rounded-none border-2 border-black font-mono"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleConfirm}
|
|
|
|
|
|
disabled={selectedFiles.size === 0}
|
|
|
|
|
|
className="px-4 h-9 rounded-none border-2 border-black bg-primary text-white font-mono shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] hover:shadow-[1px_1px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] transition-all disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<FileText className="w-4 h-4 mr-2" />
|
|
|
|
|
|
确认选择 ({selectedFiles.size})
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-12-06 20:47:28 +08:00
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|