Initial Release: OpenQode Public Alpha v1.3
This commit is contained in:
177
bin/ui/components/FileTree.mjs
Normal file
177
bin/ui/components/FileTree.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
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,
|
||||
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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user