UNPKG

@a24z/dynamic-file-tree

Version:

React component for selective directory filtering and file tree visualization

1,571 lines (1,568 loc) 56.4 kB
// src/hooks/useContainerHeight.ts import { useRef, useEffect, useState } from "react"; var useContainerHeight = (initialHeight = 600) => { const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(initialHeight); useEffect(() => { const updateHeight = () => { if (containerRef.current) { const height = containerRef.current.clientHeight; if (height > 0) { setContainerHeight(height); } } }; updateHeight(); window.addEventListener("resize", updateHeight); return () => window.removeEventListener("resize", updateHeight); }, []); return [containerRef, containerHeight]; }; // src/components/DirectoryFilterInput.tsx import React, { useState as useState2, useCallback, useRef as useRef2, useEffect as useEffect2 } from "react"; var DirectoryFilterInput = ({ fileTree, theme, filters = [], onFiltersChange, placeholder = "Type to filter by directory path" }) => { const [inputValue, setInputValue] = useState2(""); const [activeFilters, setActiveFilters] = useState2(filters); const [showDropdown, setShowDropdown] = useState2(false); const [selectedIndex, setSelectedIndex] = useState2(0); const [excludeMode, setExcludeMode] = useState2(false); const inputRef = useRef2(null); const getAllDirectories = useCallback(() => { if (!fileTree?.allDirectories) return []; const directories = new Set; fileTree.allDirectories.forEach((dir) => { directories.add(dir.relativePath); }); return Array.from(directories).sort(); }, [fileTree]); const getMatchingDirectories = useCallback(() => { if (!inputValue) return []; const allDirs = getAllDirectories(); const lowerInput = inputValue.toLowerCase(); return allDirs.filter((dir) => { const lowerDir = dir.toLowerCase(); return lowerDir.includes(lowerInput) && lowerDir !== lowerInput; }).slice(0, 10); }, [inputValue, getAllDirectories]); const matchingDirectories = getMatchingDirectories(); useEffect2(() => { setShowDropdown(inputValue.length > 0 && matchingDirectories.length > 0); setSelectedIndex(0); }, [inputValue, matchingDirectories.length]); const addFilter = useCallback((path, mode) => { const newFilter = { id: `filter-${Date.now()}-${Math.random()}`, path, mode }; const updatedFilters = [...activeFilters, newFilter]; setActiveFilters(updatedFilters); onFiltersChange?.(updatedFilters); setInputValue(""); setExcludeMode(false); }, [activeFilters, onFiltersChange]); const removeFilter = useCallback((filterId) => { const updatedFilters = activeFilters.filter((f) => f.id !== filterId); setActiveFilters(updatedFilters); onFiltersChange?.(updatedFilters); }, [activeFilters, onFiltersChange]); const toggleFilterMode = useCallback((filterId) => { const updatedFilters = activeFilters.map((f) => f.id === filterId ? { ...f, mode: f.mode === "include" ? "exclude" : "include" } : f); setActiveFilters(updatedFilters); onFiltersChange?.(updatedFilters); }, [activeFilters, onFiltersChange]); const getCommonPrefix = useCallback(() => { if (matchingDirectories.length === 0) return null; if (matchingDirectories.length === 1) { return matchingDirectories[0]; } let commonPrefix = matchingDirectories[0]; for (let i = 1;i < matchingDirectories.length; i++) { const current = matchingDirectories[i]; let j = 0; while (j < commonPrefix.length && j < current.length && commonPrefix[j] === current[j]) { j++; } commonPrefix = commonPrefix.substring(0, j); } const lastSlash = commonPrefix.lastIndexOf("/"); if (lastSlash > inputValue.length - 1) { return commonPrefix.substring(0, lastSlash + 1); } return commonPrefix.length > inputValue.length ? commonPrefix : null; }, [matchingDirectories, inputValue]); const handleKeyDown = useCallback((e) => { switch (e.key) { case "Tab": if (showDropdown && matchingDirectories.length > 0) { e.preventDefault(); const commonPrefix = getCommonPrefix(); if (commonPrefix) { setInputValue(commonPrefix); } } break; case "ArrowDown": if (showDropdown && matchingDirectories.length > 0) { e.preventDefault(); setSelectedIndex((prev) => prev < matchingDirectories.length - 1 ? prev + 1 : prev); } break; case "ArrowUp": if (showDropdown && matchingDirectories.length > 0) { e.preventDefault(); setSelectedIndex((prev) => prev > 0 ? prev - 1 : prev); } break; case "Enter": e.preventDefault(); if (showDropdown && matchingDirectories[selectedIndex]) { addFilter(matchingDirectories[selectedIndex], excludeMode ? "exclude" : "include"); } else if (inputValue.trim()) { addFilter(inputValue.trim(), excludeMode ? "exclude" : "include"); } setShowDropdown(false); break; case "Escape": if (showDropdown) { e.preventDefault(); setShowDropdown(false); } break; } }, [showDropdown, matchingDirectories, selectedIndex, inputValue, excludeMode, addFilter, getCommonPrefix]); return /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("input", { ref: inputRef, type: "text", value: inputValue, onChange: (e) => setInputValue(e.target.value), onKeyDown: handleKeyDown, onFocus: () => setShowDropdown(inputValue.length > 0 && matchingDirectories.length > 0), placeholder, style: { flex: 1, padding: "8px 12px", fontSize: "14px", borderRadius: "4px", border: `1px solid ${showDropdown ? theme.colors.primary : theme.colors.border}`, backgroundColor: theme.colors.backgroundSecondary || theme.colors.background, color: theme.colors.text, outline: "none", transition: "border-color 0.2s" } }), inputValue && /* @__PURE__ */ React.createElement("button", { onClick: () => setExcludeMode(!excludeMode), style: { padding: "8px 12px", fontSize: "12px", fontWeight: 500, borderRadius: "4px", border: `1px solid ${excludeMode ? theme.colors.primary : theme.colors.border}`, backgroundColor: excludeMode ? `${theme.colors.primary}20` : theme.colors.backgroundSecondary || theme.colors.background, color: excludeMode ? theme.colors.text : theme.colors.textSecondary, cursor: "pointer", transition: "all 0.2s" }, title: excludeMode ? "Excluding files in this directory" : "Including only files in this directory" }, excludeMode ? "Exclude" : "Include")), showDropdown && matchingDirectories.length > 0 && /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", zIndex: 10, width: "100%", marginTop: "4px", borderRadius: "4px", border: `1px solid ${theme.colors.primary}`, backgroundColor: theme.colors.background, boxShadow: `0 4px 6px -1px ${theme.colors.border}40`, maxHeight: "256px", overflowY: "auto" } }, matchingDirectories.map((dir, index) => /* @__PURE__ */ React.createElement("div", { key: dir, style: { padding: "8px 12px", cursor: "pointer", fontSize: "14px", backgroundColor: index === selectedIndex ? theme.colors.primary : "transparent", color: index === selectedIndex ? "#ffffff" : theme.colors.text || theme.colors.textSecondary, transition: "background-color 0.15s, color 0.15s", fontWeight: index === selectedIndex ? 500 : 400 }, onMouseEnter: () => setSelectedIndex(index), onClick: () => { addFilter(dir, excludeMode ? "exclude" : "include"); setShowDropdown(false); } }, dir)))), activeFilters.length > 0 && /* @__PURE__ */ React.createElement("div", { style: { marginTop: "8px", display: "flex", flexWrap: "wrap", gap: "8px" } }, activeFilters.map((filter) => /* @__PURE__ */ React.createElement("div", { key: filter.id, style: { display: "flex", alignItems: "center", gap: "4px", padding: "4px 8px", borderRadius: "4px", fontSize: "12px", backgroundColor: filter.mode === "include" ? `${theme.colors.primary}20` : `${theme.colors.error}20`, border: `1px solid ${filter.mode === "include" ? theme.colors.primary : theme.colors.error}`, color: theme.colors.text } }, /* @__PURE__ */ React.createElement("span", { onClick: () => toggleFilterMode(filter.id), style: { cursor: "pointer", userSelect: "none" }, title: "Click to toggle include/exclude" }, filter.mode === "include" ? "✓" : "✗", " ", filter.path), /* @__PURE__ */ React.createElement("button", { onClick: () => removeFilter(filter.id), style: { marginLeft: "4px", background: "none", border: "none", color: theme.colors.textSecondary, cursor: "pointer", padding: 0, fontSize: "14px" }, title: "Remove filter" }, "×"))))); }; // src/components/TreeNode.tsx import { ChevronRight, ChevronDown } from "lucide-react"; import React2, { useState as useState3 } from "react"; function TreeNode({ node, style, dragHandle, theme, rightContent, extraContent, isSelectedDirectory = false, nameColor, onContextMenu }) { const [isHovered, setIsHovered] = useState3(false); const isFolder = node.isInternal; const caret = isFolder ? /* @__PURE__ */ React2.createElement("span", { style: { marginRight: "4px", display: "flex", alignItems: "center" } }, node.isOpen ? /* @__PURE__ */ React2.createElement(ChevronDown, { size: 16, color: theme.colors.text }) : /* @__PURE__ */ React2.createElement(ChevronRight, { size: 16, color: theme.colors.text })) : null; const backgroundColor = node.isSelected ? `${theme.colors.primary}20` : isSelectedDirectory ? `${theme.colors.primary}15` : isHovered ? `${theme.colors.text}10` : "transparent"; const border = node.isSelected ? `1px solid ${theme.colors.primary}` : "1px solid transparent"; const color = nameColor ? nameColor : node.isSelected || isSelectedDirectory ? theme.colors.primary : theme.colors.text; return /* @__PURE__ */ React2.createElement("div", { style: { ...style, backgroundColor, border, color, cursor: "pointer", paddingLeft: `${node.level * 16}px`, paddingRight: "8px", paddingTop: "3px", paddingBottom: "3px", display: "flex", alignItems: "center", justifyContent: "space-between", boxSizing: "border-box", lineHeight: "20px" }, ref: dragHandle, onClick: () => node.isInternal ? node.toggle() : node.select(), onContextMenu: (e) => { if (onContextMenu) { e.preventDefault(); onContextMenu(e, node); } }, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false) }, /* @__PURE__ */ React2.createElement("div", { style: { display: "flex", alignItems: "center", minWidth: 0, flex: 1 } }, caret, /* @__PURE__ */ React2.createElement("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", overflowWrap: "normal", wordBreak: "normal" } }, node.data.name), extraContent), rightContent && /* @__PURE__ */ React2.createElement("div", { style: { flexShrink: 0 } }, rightContent)); } // src/components/DynamicFileTree.tsx import React3, { useMemo } from "react"; import { Tree } from "react-arborist"; var sortNodes = (a, b) => { const aIsDir = !!a.children; const bIsDir = !!b.children; if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); }; var transformFileTree = (fileTree) => { const transformNode = (node, parentId) => { const id = parentId ? `${parentId}/${node.name}` : node.name; const arborNode = { id, name: node.name }; if ("children" in node && node.children) { arborNode.children = node.children.map((child) => transformNode(child, id)).sort(sortNodes); } return arborNode; }; return fileTree.root.children.map((node) => transformNode(node, "")).sort(sortNodes); }; var filterFileTreeNodes = (nodes, selectedDirs, pathPrefix = "") => { if (selectedDirs.size === 0) return nodes; const result = []; for (const node of nodes) { const currentPath = pathPrefix ? `${pathPrefix}/${node.name}` : node.name; const isSelected = selectedDirs.has(currentPath); const isAncestor = Array.from(selectedDirs).some((sel) => sel.startsWith(`${currentPath}/`)); const isDescendant = Array.from(selectedDirs).some((sel) => currentPath.startsWith(`${sel}/`)); if ("children" in node && node.children) { if (isSelected || isDescendant) { result.push(node); } else if (isAncestor) { const filteredChildren = filterFileTreeNodes(node.children, selectedDirs, currentPath); if (filteredChildren.length > 0) { result.push({ ...node, children: filteredChildren }); } } } else { if (isDescendant) { result.push(node); } } } return result; }; var DynamicFileTree = ({ fileTree, theme, selectedDirectories = [], selectedFile, onDirectorySelect, onFileSelect, initialOpenState, defaultOpen = false, padding, onContextMenu }) => { const NodeRenderer = (props) => { const nodePath = props.node.data.id; const isSelectedOrChild = selectedDirectories.some((selectedDir) => { if (nodePath === selectedDir) return true; return nodePath.startsWith(selectedDir + "/"); }); return /* @__PURE__ */ React3.createElement(TreeNode, { ...props, theme, isSelectedDirectory: isSelectedOrChild, onContextMenu: (e, node) => { if (onContextMenu) { onContextMenu(e, node.data.id, !!node.data.children); } } }); }; const treeData = useMemo(() => { if (!selectedDirectories || selectedDirectories.length === 0) { return transformFileTree(fileTree); } const selectedDirsSet = new Set(selectedDirectories); const filteredNodes = filterFileTreeNodes(fileTree.root.children, selectedDirsSet); const filteredFileTree = { ...fileTree, root: { ...fileTree.root, children: filteredNodes } }; return transformFileTree(filteredFileTree); }, [fileTree, selectedDirectories]); const handleSelect = (selectedNodes) => { const selectedFiles = selectedNodes.filter((node) => !node.data.children).map((node) => node.data.id); const selectedDirs = selectedNodes.filter((node) => node.data.children).map((node) => node.data.id); if (onFileSelect && selectedFiles.length > 0) { onFileSelect(selectedFiles[0]); } if (onDirectorySelect) { onDirectorySelect(selectedDirs); } }; const [containerRef, containerHeight] = useContainerHeight(); return /* @__PURE__ */ React3.createElement("div", { ref: containerRef, style: { backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fonts.body, height: "100%", padding } }, /* @__PURE__ */ React3.createElement(Tree, { initialData: treeData, onSelect: handleSelect, openByDefault: defaultOpen, ...initialOpenState !== undefined && { initialOpenState }, ...selectedFile !== undefined && { selection: selectedFile }, width: "100%", height: containerHeight, rowHeight: 28 }, NodeRenderer)); }; // src/components/OrderedFileList.tsx import React4, { useMemo as useMemo2 } from "react"; var OrderedFileList = ({ fileTree, theme, onFileSelect, padding = "8px", selectedFile, sortBy = "lastModified", sortOrder = "desc", onContextMenu }) => { const [containerRef, containerHeight] = useContainerHeight(); const sortedFiles = useMemo2(() => { const files = [...fileTree.allFiles]; files.sort((a, b) => { let comparison = 0; switch (sortBy) { case "lastModified": { const timeA = new Date(a.lastModified).getTime(); const timeB = new Date(b.lastModified).getTime(); comparison = timeB - timeA; break; } case "size": { comparison = b.size - a.size; break; } case "name": { comparison = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); break; } } return sortOrder === "asc" ? -comparison : comparison; }); return files; }, [fileTree.allFiles, sortBy, sortOrder]); const formatDate = (date) => { const now = new Date; const fileDate = new Date(date); const diffMs = now.getTime() - fileDate.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return fileDate.toLocaleDateString(undefined, { month: "short", day: "numeric", year: fileDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined }); }; const formatSize = (bytes) => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; const handleFileClick = (file) => { if (onFileSelect) { onFileSelect(file.relativePath); } }; const handleContextMenu = (event, file) => { if (onContextMenu) { event.preventDefault(); onContextMenu(event, file.relativePath); } }; return /* @__PURE__ */ React4.createElement("div", { ref: containerRef, style: { backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fonts.body, height: "100%", overflow: "auto", padding } }, /* @__PURE__ */ React4.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "2px" } }, sortedFiles.map((file) => { const isSelected = selectedFile === file.relativePath; return /* @__PURE__ */ React4.createElement("div", { key: file.relativePath, onClick: () => handleFileClick(file), onContextMenu: (e) => handleContextMenu(e, file), style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 12px", borderRadius: "4px", cursor: "pointer", backgroundColor: isSelected ? theme.colors.primary + "20" : "transparent", border: isSelected ? `1px solid ${theme.colors.primary}` : "1px solid transparent", transition: "all 0.15s ease" }, onMouseEnter: (e) => { if (!isSelected) { e.currentTarget.style.backgroundColor = theme.colors.primary + "10"; } }, onMouseLeave: (e) => { if (!isSelected) { e.currentTarget.style.backgroundColor = "transparent"; } } }, /* @__PURE__ */ React4.createElement("div", { style: { display: "flex", flexDirection: "column", flex: 1, minWidth: 0 } }, /* @__PURE__ */ React4.createElement("div", { style: { fontSize: "14px", fontWeight: isSelected ? 500 : 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, title: file.relativePath }, file.name), /* @__PURE__ */ React4.createElement("div", { style: { fontSize: "12px", color: theme.colors.secondary || theme.colors.text + "99", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, title: file.relativePath }, file.relativePath)), /* @__PURE__ */ React4.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", fontSize: "12px", color: theme.colors.secondary || theme.colors.text + "99", marginLeft: "16px", flexShrink: 0 } }, /* @__PURE__ */ React4.createElement("div", null, formatDate(file.lastModified)), /* @__PURE__ */ React4.createElement("div", null, formatSize(file.size)))); })), sortedFiles.length === 0 && /* @__PURE__ */ React4.createElement("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", height: containerHeight, color: theme.colors.secondary || theme.colors.text + "99", fontSize: "14px" } }, "No files found")); }; // src/components/GitOrderedFileList.tsx import { Plus, Minus, Edit, AlertCircle, GitBranch, FileQuestion } from "lucide-react"; import React5, { useMemo as useMemo3 } from "react"; var getGitStatusDisplay = (status, theme) => { switch (status) { case "M": case "MM": return { icon: /* @__PURE__ */ React5.createElement(Edit, { size: 14 }), color: theme.colors.primary || "#007bff", label: "Modified", priority: 2 }; case "A": return { icon: /* @__PURE__ */ React5.createElement(Plus, { size: 14 }), color: "#28a745", label: "Added", priority: 1 }; case "D": return { icon: /* @__PURE__ */ React5.createElement(Minus, { size: 14 }), color: "#dc3545", label: "Deleted", priority: 3 }; case "R": return { icon: /* @__PURE__ */ React5.createElement(GitBranch, { size: 14 }), color: "#6f42c1", label: "Renamed", priority: 4 }; case "C": return { icon: /* @__PURE__ */ React5.createElement(GitBranch, { size: 14 }), color: "#20c997", label: "Copied", priority: 5 }; case "U": return { icon: /* @__PURE__ */ React5.createElement(AlertCircle, { size: 14 }), color: "#fd7e14", label: "Unmerged", priority: 0 }; case "??": return { icon: /* @__PURE__ */ React5.createElement(FileQuestion, { size: 14 }), color: "#6c757d", label: "Untracked", priority: 6 }; case "AM": return { icon: /* @__PURE__ */ React5.createElement(Plus, { size: 14 }), color: "#17a2b8", label: "Added & Modified", priority: 1 }; default: return null; } }; var GitOrderedFileList = ({ fileTree, theme, gitStatusData, onFileSelect, padding = "8px", selectedFile, sortBy = "lastModified", sortOrder = "desc", showOnlyChanged = false, onContextMenu }) => { const [containerRef, containerHeight] = useContainerHeight(); const gitStatusMap = useMemo3(() => { const map = new Map; gitStatusData.forEach((item) => { map.set(item.filePath, item.status); }); return map; }, [gitStatusData]); const filesWithGitStatus = useMemo3(() => { const enhanced = fileTree.allFiles.map((file) => ({ ...file, gitStatus: gitStatusMap.get(file.relativePath) })); if (showOnlyChanged) { return enhanced.filter((file) => file.gitStatus && file.gitStatus !== null); } return enhanced; }, [fileTree.allFiles, gitStatusMap, showOnlyChanged]); const sortedFiles = useMemo3(() => { const files = [...filesWithGitStatus]; files.sort((a, b) => { let comparison = 0; switch (sortBy) { case "lastModified": { const timeA = new Date(a.lastModified).getTime(); const timeB = new Date(b.lastModified).getTime(); comparison = timeB - timeA; break; } case "size": { comparison = b.size - a.size; break; } case "name": { comparison = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); break; } case "gitStatus": { const aDisplay = a.gitStatus ? getGitStatusDisplay(a.gitStatus, theme) : null; const bDisplay = b.gitStatus ? getGitStatusDisplay(b.gitStatus, theme) : null; const aPriority = aDisplay?.priority ?? 999; const bPriority = bDisplay?.priority ?? 999; comparison = aPriority - bPriority; break; } } return sortOrder === "asc" ? -comparison : comparison; }); return files; }, [filesWithGitStatus, sortBy, sortOrder, theme]); const formatDate = (date) => { const now = new Date; const fileDate = new Date(date); const diffMs = now.getTime() - fileDate.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return fileDate.toLocaleDateString(undefined, { month: "short", day: "numeric", year: fileDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined }); }; const formatSize = (bytes) => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; const handleFileClick = (file) => { if (onFileSelect) { onFileSelect(file.relativePath); } }; const handleContextMenu = (event, file) => { if (onContextMenu) { event.preventDefault(); onContextMenu(event, file.relativePath); } }; return /* @__PURE__ */ React5.createElement("div", { ref: containerRef, style: { backgroundColor: theme.colors.background, color: theme.colors.text, fontFamily: theme.fonts.body, height: "100%", overflow: "auto", padding } }, /* @__PURE__ */ React5.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "2px" } }, sortedFiles.map((file) => { const isSelected = selectedFile === file.relativePath; const gitDisplay = file.gitStatus ? getGitStatusDisplay(file.gitStatus, theme) : null; return /* @__PURE__ */ React5.createElement("div", { key: file.relativePath, onClick: () => handleFileClick(file), onContextMenu: (e) => handleContextMenu(e, file), style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 12px", borderRadius: "4px", cursor: "pointer", backgroundColor: isSelected ? theme.colors.primary + "20" : "transparent", border: isSelected ? `1px solid ${theme.colors.primary}` : "1px solid transparent", transition: "all 0.15s ease" }, onMouseEnter: (e) => { if (!isSelected) { e.currentTarget.style.backgroundColor = theme.colors.primary + "10"; } }, onMouseLeave: (e) => { if (!isSelected) { e.currentTarget.style.backgroundColor = "transparent"; } } }, /* @__PURE__ */ React5.createElement("div", { style: { display: "flex", alignItems: "center", flex: 1, minWidth: 0, gap: "8px" } }, gitDisplay && /* @__PURE__ */ React5.createElement("div", { style: { display: "flex", alignItems: "center", color: gitDisplay.color, flexShrink: 0 }, title: gitDisplay.label }, gitDisplay.icon), /* @__PURE__ */ React5.createElement("div", { style: { display: "flex", flexDirection: "column", flex: 1, minWidth: 0 } }, /* @__PURE__ */ React5.createElement("div", { style: { fontSize: "14px", fontWeight: isSelected ? 500 : 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: gitDisplay?.color || theme.colors.text }, title: file.relativePath }, file.name), /* @__PURE__ */ React5.createElement("div", { style: { fontSize: "12px", color: theme.colors.secondary || theme.colors.text + "99", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, title: file.relativePath }, file.relativePath))), /* @__PURE__ */ React5.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", fontSize: "12px", color: theme.colors.secondary || theme.colors.text + "99", marginLeft: "16px", flexShrink: 0 } }, /* @__PURE__ */ React5.createElement("div", null, formatDate(file.lastModified)), /* @__PURE__ */ React5.createElement("div", null, formatSize(file.size)))); })), sortedFiles.length === 0 && /* @__PURE__ */ React5.createElement("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", height: containerHeight, color: theme.colors.secondary || theme.colors.text + "99", fontSize: "14px" } }, showOnlyChanged ? "No changed files" : "No files found")); }; // src/components/FileTreeContainer.tsx import React6, { useState as useState4, useMemo as useMemo4 } from "react"; var FileTreeContainer = ({ fileTree, theme, selectedFile, onFileSelect, onContextMenu }) => { const [filters, setFilters] = useState4([]); const selectedDirectories = useMemo4(() => { return filters.filter((f) => f.mode === "include").map((f) => f.path); }, [filters]); return /* @__PURE__ */ React6.createElement("div", { style: { display: "flex", flexDirection: "column", height: "100%" } }, /* @__PURE__ */ React6.createElement(DirectoryFilterInput, { fileTree, theme, filters, onFiltersChange: setFilters }), /* @__PURE__ */ React6.createElement("div", { style: { flex: 1, marginTop: "1rem", overflow: "hidden" } }, /* @__PURE__ */ React6.createElement(DynamicFileTree, { fileTree, theme, selectedDirectories, selectedFile, onFileSelect, onContextMenu }))); }; // src/components/GitStatusFileTree.tsx import { Plus as Plus2, Minus as Minus2, Edit as Edit2, AlertCircle as AlertCircle2, GitBranch as GitBranch2, FileQuestion as FileQuestion2 } from "lucide-react"; import React7, { useMemo as useMemo5 } from "react"; import { Tree as Tree2 } from "react-arborist"; var getGitStatusDisplay2 = (status, theme) => { switch (status) { case "M": case "MM": return { icon: /* @__PURE__ */ React7.createElement(Edit2, { size: 14 }), color: theme.colors.primary || "#007bff", label: "Modified" }; case "A": return { icon: /* @__PURE__ */ React7.createElement(Plus2, { size: 14 }), color: "#28a745", label: "Added" }; case "D": return { icon: /* @__PURE__ */ React7.createElement(Minus2, { size: 14 }), color: "#dc3545", label: "Deleted" }; case "R": return { icon: /* @__PURE__ */ React7.createElement(GitBranch2, { size: 14 }), color: "#6f42c1", label: "Renamed" }; case "C": return { icon: /* @__PURE__ */ React7.createElement(GitBranch2, { size: 14 }), color: "#20c997", label: "Copied" }; case "U": return { icon: /* @__PURE__ */ React7.createElement(AlertCircle2, { size: 14 }), color: "#fd7e14", label: "Unmerged" }; case "??": return { icon: /* @__PURE__ */ React7.createElement(FileQuestion2, { size: 14 }), color: "#6c757d", label: "Untracked" }; case "AM": return { icon: /* @__PURE__ */ React7.createElement(Plus2, { size: 14 }), color: "#17a2b8", label: "Added & Modified" }; default: return null; } }; var sortGitNodes = (a, b) => { const aIsDir = !!a.children; const bIsDir = !!b.children; if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); }; var transformGitFileTree = (fileTree, gitStatusMap) => { const transformNode = (node, parentId) => { const id = parentId ? `${parentId}/${node.name}` : node.name; const gitStatus = gitStatusMap.get(id); const arborNode = { id, name: node.name, gitStatus }; if ("children" in node && node.children) { arborNode.children = node.children.map((child) => transformNode(child, id)).sort(sortGitNodes); if (arborNode.children) { const hasChangedChildren = arborNode.children.some((child) => child.gitStatus || child.hasChangedChildren); arborNode.hasChangedChildren = hasChangedChildren; } } return arborNode; }; return fileTree.root.children.map((node) => transformNode(node, "")).sort(sortGitNodes); }; var filterGitStatusNodes = (nodes, showUnchangedFiles) => { if (showUnchangedFiles) return nodes; const result = []; for (const node of nodes) { if (node.children) { if (node.gitStatus || node.hasChangedChildren) { const filteredChildren = filterGitStatusNodes(node.children, showUnchangedFiles); result.push({ ...node, children: filteredChildren }); } } else { if (node.gitStatus) { result.push(node); } } } return result; }; var GitStatusFileTree = ({ fileTree, theme, gitStatusData, selectedDirectories = [], selectedFile, onDirectorySelect, onFileSelect, showUnchangedFiles = true, transparentBackground = false, padding, onContextMenu, openByDefault }) => { const gitStatusMap = useMemo5(() => { const map = new Map; gitStatusData.forEach((item) => { map.set(item.filePath, item.status); }); return map; }, [gitStatusData]); const NodeRenderer = (props) => { const { node } = props; const gitDisplay = node.data.gitStatus ? getGitStatusDisplay2(node.data.gitStatus, theme) : null; let nameColor; if (gitDisplay) { nameColor = gitDisplay.color; } else if (node.data.hasChangedChildren) { const baseColor = theme.colors.primary || "#007bff"; nameColor = baseColor + "80"; } const rightContent = gitDisplay ? /* @__PURE__ */ React7.createElement("div", { style: { display: "flex", alignItems: "center", color: gitDisplay.color, marginRight: "8px" }, title: gitDisplay.label }, gitDisplay.icon, /* @__PURE__ */ React7.createElement("span", { style: { marginLeft: "4px", fontSize: "12px", fontWeight: "bold" } }, node.data.gitStatus)) : null; return /* @__PURE__ */ React7.createElement(TreeNode, { ...props, theme, rightContent, nameColor, onContextMenu: (e, node2) => { if (onContextMenu) { onContextMenu(e, node2.data.id, !!node2.data.children); } } }); }; const treeData = useMemo5(() => { let transformedData = transformGitFileTree(fileTree, gitStatusMap); if (!showUnchangedFiles) { transformedData = filterGitStatusNodes(transformedData, showUnchangedFiles); } if (selectedDirectories && selectedDirectories.length > 0) {} return transformedData; }, [fileTree, gitStatusMap, showUnchangedFiles, selectedDirectories]); const handleSelect = (selectedNodes) => { const selectedFiles = selectedNodes.filter((node) => !node.data.children).map((node) => node.data.id); const selectedDirs = selectedNodes.filter((node) => node.data.children).map((node) => node.data.id); if (onFileSelect && selectedFiles.length > 0) { onFileSelect(selectedFiles[0]); } if (onDirectorySelect) { onDirectorySelect(selectedDirs); } }; const [containerRef, containerHeight] = useContainerHeight(); return /* @__PURE__ */ React7.createElement("div", { ref: containerRef, style: { backgroundColor: transparentBackground ? "transparent" : theme.colors.background, color: theme.colors.text, fontFamily: theme.fonts.body, height: "100%", padding } }, /* @__PURE__ */ React7.createElement(Tree2, { initialData: treeData, onSelect: handleSelect, ...selectedFile !== undefined && { selection: selectedFile }, ...openByDefault !== undefined && { openByDefault }, width: "100%", height: containerHeight, rowHeight: 28 }, NodeRenderer)); }; // src/components/GitStatusFileTreeContainer.tsx import { RefreshCw, Eye, EyeOff, AlertCircle as AlertCircle3 } from "lucide-react"; import React8, { useState as useState5, useMemo as useMemo6 } from "react"; var GitStatusFileTreeContainer = ({ fileTree, theme, gitStatusData, selectedFile, onFileSelect, onRefresh, isLoading = false, error = null, showControls = true, onContextMenu }) => { const [filters, setFilters] = useState5([]); const [showUnchangedFiles, setShowUnchangedFiles] = useState5(true); const selectedDirectories = useMemo6(() => { return filters.filter((f) => f.mode === "include").map((f) => f.path); }, [filters]); const changedFilesCount = gitStatusData.length; const hasChanges = changedFilesCount > 0; const handleRefresh = () => { onRefresh?.(); }; const toggleShowUnchangedFiles = () => { setShowUnchangedFiles(!showUnchangedFiles); }; return /* @__PURE__ */ React8.createElement("div", { style: { display: "flex", flexDirection: "column", height: "100%" } }, showControls && /* @__PURE__ */ React8.createElement("div", { style: { padding: "12px", borderBottom: `1px solid ${theme.colors.border || "#e0e0e0"}`, backgroundColor: theme.colors.backgroundSecondary || theme.colors.background } }, /* @__PURE__ */ React8.createElement("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" } }, /* @__PURE__ */ React8.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px" } }, /* @__PURE__ */ React8.createElement("h3", { style: { margin: 0, fontSize: "14px", fontWeight: "bold", color: theme.colors.text } }, "Git Status"), hasChanges && /* @__PURE__ */ React8.createElement("span", { style: { backgroundColor: theme.colors.primary, color: "#ffffff", padding: "2px 8px", borderRadius: "12px", fontSize: "12px", fontWeight: "bold" } }, changedFilesCount), isLoading && /* @__PURE__ */ React8.createElement(RefreshCw, { size: 16, color: theme.colors.text, className: "git-status-spinner" })), /* @__PURE__ */ React8.createElement("div", { style: { display: "flex", gap: "8px" } }, /* @__PURE__ */ React8.createElement("button", { onClick: toggleShowUnchangedFiles, style: { background: "none", border: `1px solid ${theme.colors.border || "#ccc"}`, borderRadius: "4px", padding: "4px 8px", cursor: "pointer", display: "flex", alignItems: "center", gap: "4px", fontSize: "12px", color: theme.colors.text }, title: showUnchangedFiles ? "Hide unchanged files" : "Show all files" }, showUnchangedFiles ? /* @__PURE__ */ React8.createElement(EyeOff, { size: 14 }) : /* @__PURE__ */ React8.createElement(Eye, { size: 14 }), showUnchangedFiles ? "Hide unchanged" : "Show all"), /* @__PURE__ */ React8.createElement("button", { onClick: handleRefresh, disabled: isLoading, style: { background: "none", border: `1px solid ${theme.colors.border || "#ccc"}`, borderRadius: "4px", padding: "4px 8px", cursor: isLoading ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: "4px", fontSize: "12px", color: theme.colors.text, opacity: isLoading ? 0.6 : 1 }, title: "Refresh git status" }, /* @__PURE__ */ React8.createElement(RefreshCw, { size: 14 }), "Refresh"))), error && /* @__PURE__ */ React8.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "8px", backgroundColor: "#fff3cd", border: "1px solid #ffeaa7", borderRadius: "4px", fontSize: "12px", color: "#856404" } }, /* @__PURE__ */ React8.createElement(AlertCircle3, { size: 14 }), error), !error && hasChanges && /* @__PURE__ */ React8.createElement("div", { style: { fontSize: "12px", color: theme.colors.textSecondary || "#666", marginTop: "4px" } }, changedFilesCount, " file", changedFilesCount !== 1 ? "s" : "", " with changes")), /* @__PURE__ */ React8.createElement(DirectoryFilterInput, { fileTree, theme, filters, onFiltersChange: setFilters }), /* @__PURE__ */ React8.createElement("div", { style: { flex: 1, marginTop: "1rem", overflow: "hidden" } }, /* @__PURE__ */ React8.createElement(GitStatusFileTree, { fileTree, theme, gitStatusData, selectedDirectories, selectedFile, onFileSelect, showUnchangedFiles, onContextMenu }))); }; // src/components/MultiFileTree/MultiFileTree.tsx import React10, { useState as useState6, useMemo as useMemo8 } from "react"; // src/utils/multiTree/pathUtils.ts function extractNameFromPath(path) { if (!path) return ""; const parts = path.split("/"); return parts[parts.length - 1] || ""; } function parseUnifiedPath(path) { const pathSegments = path.split("/"); const sourceName = pathSegments[0]; const originalPath = pathSegments.slice(1).join("/"); return { sourceName, originalPath }; } function updateTreePaths(node, prefix) { const originalPath = node.path || ""; const updatedPath = `${prefix}/${originalPath}`; const updatedRelativePath = `${prefix}/${node.relativePath || originalPath}`; if ("children" in node) { const dirNode = node; const nodeName = dirNode.name || extractNameFromPath(originalPath); const updatedDir = { ...dirNode, path: updatedPath, name: nodeName, relativePath: updatedRelativePath, depth: (dirNode.depth || 0) + 1, children: (dirNode.children || []).map((child) => updateTreePaths(child, prefix)) }; return updatedDir; } else { const fileNode = node; const nodeName = fileNode.name || extractNameFromPath(originalPath); return { ...fileNode, path: updatedPath, name: nodeName, relativePath: updatedRelativePath }; } } // src/utils/multiTree/combineRepositoryTrees.ts function combineRepositoryTrees(sources, options = {}) { const rootDirectoryName = options.rootDirectoryName || "Repositories"; if (sources.length === 0) { return { sha: "empty", root: { path: "", name: rootDirectoryName, children: [], fileCount: 0, totalSize: 0, depth: 0, relativePath: "" }, allFiles: [], allDirectories: [], stats: { totalFiles: 0, totalDirectories: 0, totalSize: 0, maxDepth: 0 }, metadata: { id: "combined-tree", timestamp: new Date, sourceType: "combined", sourceInfo: {} } }; } const rootChildren = []; const allFiles = []; const allDirectories = []; let totalFileCount = 0; let totalDirectoryCount = 0; let totalSizeBytes = 0; let maxDepth = 0; const rootDir = { path: "", name: rootDirectoryName, children: [], fileCount: 0, totalSize: 0, depth: 0, relativePath: "" }; sources.forEach((source) => { const sourceTree = source.tree; if (!sourceTree || !sourceTree.root) { return; } const sourceName = source.name || "unknown-source"; const sourceDir = { path: sourceName, name: sourceName, children: sourceTree.root.children ? sourceTree.root.children.map((child) => { return updateTreePaths(child, sourceName); }) : [], fileCount: sourceTree.root.fileCount || 0, totalSize: sourceTree.root.totalSize || 0, depth: 1, relativePath: sourceName }; rootChildren.push(sourceDir); allDirectories.push(sourceDir); if (sourceTree.allFiles) { sourceTree.allFiles.forEach((file) => { const updatedFile = { ...file, path: `${sourceName}/${file.path || ""}`, name: file.name || extractNameFromPath(file.path) || "unknown" }; allFiles.push(updatedFile); }); } if (sourceTree.allDirectories) { sourceTree.allDirectories.forEach((dir) => { const updatedDir = { ...dir, path: `${sourceName}/${dir.path || ""}`, name: dir.name || extractNameFromPath(dir.path) || "unknown" }; allDirectories.push(updatedDir); }); } if (sourceTree.stats) { totalFileCount += sourceTree.stats.totalFiles || 0; totalDirectoryCount += sourceTree.stats.totalDirectories || 0; totalSizeBytes += sourceTree.stats.totalSize || 0; maxDepth = Math.max(maxDepth, (sourceTree.stats.maxDepth || 0) + 1); } }); rootDir.children = rootChildren; rootDir.fileCount = totalFileCount; rootDir.totalSize = totalSizeBytes; allDirectories.unshift(rootDir); return { sha: "combined", root: rootDir, allFiles, allDirectories, stats: { totalFiles: totalFileCount, totalDirectories: totalDirectoryCount + sources.length + 1, totalSize: totalSizeBytes, maxDepth: maxDepth + 1 }, metadata: { id: "combined-tree", timestamp: new Date, sourceType: "combined", sourceInfo: { sourceCount: sources.length } } }; } // src/utils/multiTree/filterFileTree.ts function filterFileTreeByPaths(tree, selectedPaths) { if (selectedPaths.length === 0) { return tree; } const shouldIncludePath = (path) => { return selectedPaths.some((selectedPath) => path.startsWith(selectedPath) || selectedPath.startsWith(path)); }; const filterDirectory = (dir) => { if (!shouldIncludePath(dir.path)) { return null; } const filteredChildren = dir.children.map((child) => { if ("children" in child) { return filterDirectory(child); } else { return shouldIncludePath(child.path) ? child : null; } }).filter((child) => child !== null); if (filteredChildren.length === 0 && !selectedPaths.includes(dir.path)) { return null; } return { ...dir, children: filteredChildren, fileCount: filteredChildren.filter((c) => !("children" in c)).length }; }; const filteredRoot = filterDirectory(tree.root); if (!filteredRoot) { return tree; } const allFiles = tree.allFiles.filter((f) => shouldIncludePath(f.path)); const allDirectories = tree.allDirectories.filter((d) => shouldIncludePath(d.path)); return { ...tree, root: filteredRoot, allFiles, allDirectories, stats: { ...tree.stats, totalFiles: allFiles.length, totalDirectories: allDirectories.length } }; } // src/components/MultiFileTree/MultiFileTreeCore.tsx import React9, { useMemo as useMemo7 } from "react"; var MultiFileTreeCore = ({ sources, theme, selectedDirectories = [], viewMode = "all", onFileSelect, initialOpenState, defaultOpen = false, padding }) => { const unifiedTree = useMemo7(() => { return combineRepositoryTrees(sources); }, [sources]); const displayTree = useMemo7(() => { if (viewMode === "selected" && selectedDirectories.length > 0) { return filterFileTreeByPaths(unifiedTree, selectedDirectories); } return unifiedTree; }, [unifiedTree, viewMode, selectedDirectories]); const handleFileSelect = (filePath) => { if (!onFileSelect) return; const { sourceName, originalPath } = parseUnifiedPath(filePath); const source = sources.find((s) => s.name === sourceName); if (source) { onFileSelect(source, originalPath); } }; return /* @__PURE__ */ React9.createElement(DynamicFileTree, { key: `unified-${viewMode}-${selectedDirectories.join(",")}`, fileTree: displayTree, theme, selectedDirectories, onFileSelect: handleFileSelect, initialOpenState, defaultOpen, padding }); }; // src/components/MultiFileTree/MultiFileTree.tsx var MultiFileTree = ({ sources, theme, showHeader = true, showFilters = true, showViewModeToggle = true, showSelectedFileIndicator = true, initialViewMode = "all", rootDirectoryName = "Repositories", title = "Repository Explorer", onFileSelect, initialOpenState, defaultOpen = false, padding }) => { const [selectedFile, setSelectedFile] = useStat