@a24z/dynamic-file-tree
Version:
React component for selective directory filtering and file tree visualization
1,571 lines (1,568 loc) • 56.4 kB
JavaScript
// 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