184 lines
6.1 KiB
JavaScript
184 lines
6.1 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Box, Text, useInput } from 'ink';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const h = React.createElement;
|
|
|
|
// Helper to sort: folders first
|
|
const sortFiles = (files, dirPath) => {
|
|
return files.sort((a, b) => {
|
|
const pathA = path.join(dirPath, a);
|
|
const pathB = path.join(dirPath, b);
|
|
try {
|
|
const statA = fs.statSync(pathA);
|
|
const statB = fs.statSync(pathB);
|
|
if (statA.isDirectory() && !statB.isDirectory()) return -1;
|
|
if (!statA.isDirectory() && statB.isDirectory()) return 1;
|
|
return a.localeCompare(b);
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
});
|
|
};
|
|
|
|
const FileTree = ({
|
|
rootPath,
|
|
onSelect,
|
|
onOpen,
|
|
selectedFiles = new Set(),
|
|
isActive = false,
|
|
height = 20,
|
|
width = 30
|
|
}) => {
|
|
const [expanded, setExpanded] = useState(new Set([rootPath])); // Expanded folders
|
|
const [cursor, setCursor] = useState(rootPath); // Currently highlighted path
|
|
const [flatList, setFlatList] = useState([]); // Computed flat list for rendering (calc'd from expanded)
|
|
|
|
// Ignore list
|
|
const IGNORE_DIRS = new Set(['.git', 'node_modules', '.opencode', 'dist', 'build', 'coverage']);
|
|
|
|
// Rebuild flat list when expanded changes
|
|
// Returns array of { path, name, isDir, depth, isExpanded, hasChildren }
|
|
const buildFlatList = useCallback(() => {
|
|
const list = [];
|
|
|
|
const traverse = (currentPath, depth) => {
|
|
if (depth > 10) return; // Safety
|
|
|
|
const name = path.basename(currentPath) || (currentPath === rootPath ? '/' : currentPath);
|
|
let isDir = false;
|
|
try {
|
|
isDir = fs.statSync(currentPath).isDirectory();
|
|
} catch (e) { return; }
|
|
|
|
const isExpanded = expanded.has(currentPath);
|
|
|
|
list.push({
|
|
path: currentPath,
|
|
name: name,
|
|
isDir: isDir,
|
|
depth: depth,
|
|
isExpanded: isExpanded
|
|
});
|
|
|
|
if (isDir && isExpanded) {
|
|
try {
|
|
const children = fs.readdirSync(currentPath).filter(f => !IGNORE_DIRS.has(f) && !f.startsWith('.'));
|
|
const sorted = sortFiles(children, currentPath);
|
|
for (const child of sorted) {
|
|
traverse(path.join(currentPath, child), depth + 1);
|
|
}
|
|
} catch (e) {
|
|
// Permission error or file delete race condition
|
|
}
|
|
}
|
|
};
|
|
|
|
traverse(rootPath, 0);
|
|
return list;
|
|
}, [expanded, rootPath]);
|
|
|
|
useEffect(() => {
|
|
setFlatList(buildFlatList());
|
|
}, [buildFlatList]);
|
|
|
|
useInput((input, key) => {
|
|
if (!isActive) return;
|
|
|
|
const currentIndex = flatList.findIndex(item => item.path === cursor);
|
|
|
|
if (key.downArrow) {
|
|
const nextIndex = Math.min(flatList.length - 1, currentIndex + 1);
|
|
setCursor(flatList[nextIndex].path);
|
|
}
|
|
|
|
if (key.upArrow) {
|
|
const prevIndex = Math.max(0, currentIndex - 1);
|
|
setCursor(flatList[prevIndex].path);
|
|
}
|
|
|
|
if (key.rightArrow || key.return) {
|
|
const item = flatList[currentIndex];
|
|
if (item && item.isDir) {
|
|
if (!expanded.has(item.path)) {
|
|
setExpanded(prev => new Set([...prev, item.path]));
|
|
}
|
|
} else if (key.return) {
|
|
const selectedItem = flatList[currentIndex];
|
|
if (selectedItem && !selectedItem.isDir && typeof onOpen === 'function') {
|
|
onOpen(selectedItem.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (key.leftArrow) {
|
|
const item = flatList[currentIndex];
|
|
if (item && item.isDir && expanded.has(item.path)) {
|
|
const newExpanded = new Set(expanded);
|
|
newExpanded.delete(item.path);
|
|
setExpanded(newExpanded);
|
|
} else {
|
|
// Determine parent path to jump up
|
|
const parentPath = path.dirname(item.path);
|
|
if (parentPath && parentPath.length >= rootPath.length) {
|
|
setCursor(parentPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (input === ' ') {
|
|
const item = flatList[currentIndex];
|
|
if (item && !item.isDir) {
|
|
// Toggle selection
|
|
if (onSelect) {
|
|
onSelect(item.path);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Calculate viewport based on cursor
|
|
const cursorIndex = flatList.findIndex(item => item.path === cursor);
|
|
// Ensure height is valid number
|
|
const safeHeight = Math.max(5, height || 20);
|
|
const renderStart = Math.max(0, Math.min(cursorIndex - Math.floor(safeHeight / 2), flatList.length - safeHeight));
|
|
const renderEnd = Math.min(flatList.length, renderStart + safeHeight);
|
|
|
|
const visibleItems = flatList.slice(renderStart, renderEnd);
|
|
|
|
return h(Box, { flexDirection: 'column', width: width, height: safeHeight },
|
|
visibleItems.map((item) => {
|
|
const isSelected = selectedFiles.has(item.path);
|
|
const isCursor = item.path === cursor;
|
|
|
|
// Indentation
|
|
const indent = ' '.repeat(Math.max(0, item.depth));
|
|
|
|
// Icon
|
|
let icon = item.isDir
|
|
? (item.isExpanded ? '▼ ' : '▶ ')
|
|
: (isSelected ? '[x] ' : '[ ] ');
|
|
|
|
// Color logic
|
|
let color = 'white';
|
|
if (item.isDir) color = 'cyan';
|
|
if (isSelected) color = 'green';
|
|
|
|
// Cursor style
|
|
const bg = isCursor ? 'blue' : undefined;
|
|
const textColor = isCursor ? 'white' : color;
|
|
|
|
return h(Box, { key: item.path, width: '100%' },
|
|
h(Text, {
|
|
backgroundColor: bg,
|
|
color: textColor,
|
|
wrap: 'truncate'
|
|
}, `${indent}${icon}${item.name}`)
|
|
);
|
|
})
|
|
);
|
|
};
|
|
|
|
export default FileTree;
|