@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
318 lines • 15.6 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { readFile } from 'node:fs/promises';
import { highlight } from 'cli-highlight';
import { Box, Text, useFocus, useInput } from 'ink';
import { useEffect, useMemo, useState } from 'react';
import { StyledTitle } from '../../components/ui/styled-title.js';
import { CHARS_PER_TOKEN_ESTIMATE, FILE_EXPLORER_TOKEN_WARNING_THRESHOLD, FILE_EXPLORER_VISIBLE_ITEMS, } from '../../constants.js';
import { useTheme } from '../../hooks/useTheme.js';
import { useTitleShape } from '../../hooks/useTitleShape.js';
import { useUIStateContext } from '../../hooks/useUIState.js';
import { buildFileTree, flattenTree, flattenTreeAll, } from '../../utils/file-tree.js';
import { compressIndentation } from '../../utils/indentation-normalizer.js';
import { getVSCodeServerSync } from '../../vscode/vscode-server.js';
import { TreeItem } from './tree-item.js';
import { formatSize, formatTokens, getAllFilesInDirectory, getLanguageFromPath, } from './utils.js';
export function FileExplorer({ onClose }) {
const { colors } = useTheme();
const { currentTitleShape } = useTitleShape();
const { setPendingFileMentions } = useUIStateContext();
const [tree, setTree] = useState([]);
const [expanded, setExpanded] = useState(new Set());
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedFiles, setSelectedFiles] = useState(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [preview, setPreview] = useState(null);
const [previewError, setPreviewError] = useState(null);
const [previewPath, setPreviewPath] = useState(null);
const [viewMode, setViewMode] = useState('tree');
const [previewScroll, setPreviewScroll] = useState(0);
const [searchMode, setSearchMode] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [closed, setClosed] = useState(false);
// Capture focus
useFocus({ autoFocus: true, id: 'file-explorer' });
// Build file tree on mount
useEffect(() => {
async function loadTree() {
try {
const nodes = await buildFileTree(process.cwd(), { maxDepth: 5 });
setTree(nodes);
setLoading(false);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load files');
setLoading(false);
}
}
void loadTree();
}, []);
// Flatten tree based on expanded state (for normal browsing)
const flatList = flattenTree(tree, expanded);
// For search mode, flatten ALL nodes so we can find nested files
const allNodes = flattenTreeAll(tree);
// Filter by search query if in search mode
const filteredList = searchMode && searchQuery
? allNodes.filter(item => item.node.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.node.path.toLowerCase().includes(searchQuery.toLowerCase()))
: flatList;
// Calculate scroll window
const scrollStart = Math.max(0, Math.min(selectedIndex - Math.floor(FILE_EXPLORER_VISIBLE_ITEMS / 2), filteredList.length - FILE_EXPLORER_VISIBLE_ITEMS));
const visibleItems = filteredList.slice(scrollStart, scrollStart + FILE_EXPLORER_VISIBLE_ITEMS);
// Get selected node
const selectedNode = filteredList[selectedIndex]?.node;
// Calculate estimated tokens for selected files
const estimatedTokens = useMemo(() => {
if (selectedFiles.size === 0)
return 0;
let totalSize = 0;
for (const filePath of selectedFiles) {
// Find the node to get its size
const node = allNodes.find(n => n.node.path === filePath);
if (node?.node.size) {
totalSize += node.node.size;
}
}
// Estimate tokens: chars / 4 (standard approximation)
return Math.ceil(totalSize / CHARS_PER_TOKEN_ESTIMATE);
}, [selectedFiles, allNodes]);
// Load preview when entering preview mode
const loadPreviewForNode = async (node) => {
if (node.isDirectory) {
setPreview(null);
setPreviewError('Cannot preview directory');
return;
}
// If VS Code is connected, open the file there for better viewing
const vscodeServer = getVSCodeServerSync();
if (vscodeServer?.hasConnections()) {
vscodeServer.openFileInVSCode(node.absolutePath);
}
try {
const content = await readFile(node.absolutePath, 'utf-8');
const lang = getLanguageFromPath(node.path);
// Compress indentation for compact display in narrow terminals
const lines = content.split('\n');
const compressedLines = compressIndentation(lines);
const compressedContent = compressedLines.join('\n');
// Apply syntax highlighting
let highlighted;
try {
highlighted = highlight(compressedContent, {
language: lang,
theme: 'default',
});
}
catch {
// Fallback to plain text if highlighting fails
highlighted = compressedContent;
}
setPreview(highlighted);
setPreviewPath(node.path);
setPreviewError(null);
setPreviewScroll(0);
}
catch {
setPreview(null);
setPreviewError('Cannot preview (binary or unreadable)');
}
};
const toggleFileSelection = (path) => {
setSelectedFiles(prev => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
}
else {
next.add(path);
}
return next;
});
};
const toggleDirectorySelection = (dirNode) => {
const filesInDir = getAllFilesInDirectory(dirNode);
if (filesInDir.length === 0)
return;
setSelectedFiles(prev => {
const next = new Set(prev);
// Check if all files in directory are already selected
const allSelected = filesInDir.every(f => prev.has(f));
if (allSelected) {
// Deselect all
for (const f of filesInDir) {
next.delete(f);
}
}
else {
// Select all
for (const f of filesInDir) {
next.add(f);
}
}
return next;
});
};
const handleSelect = () => {
if (!selectedNode)
return;
if (selectedNode.isDirectory) {
// Toggle directory expansion
setExpanded(prev => {
const next = new Set(prev);
if (next.has(selectedNode.path)) {
next.delete(selectedNode.path);
}
else {
next.add(selectedNode.path);
}
return next;
});
}
else {
// For files, enter preview mode
setViewMode('preview');
void loadPreviewForNode(selectedNode);
}
};
const handleGoUp = () => {
// Find parent directory and collapse it
if (!selectedNode)
return;
const parts = selectedNode.path.split('/');
if (parts.length > 1) {
parts.pop();
const parentPath = parts.join('/');
setExpanded(prev => {
const next = new Set(prev);
next.delete(parentPath);
return next;
});
}
};
// Keyboard handler
useInput((input, key) => {
if (key.escape) {
if (searchMode) {
setSearchMode(false);
setSearchQuery('');
}
else if (viewMode === 'preview') {
setViewMode('tree');
}
else {
// Exit and pass selected files to input
setClosed(true);
if (selectedFiles.size > 0) {
setPendingFileMentions(Array.from(selectedFiles));
}
onClose();
}
return;
}
// Shift+Tab to go back from preview
if (key.tab && key.shift) {
if (viewMode === 'preview') {
setViewMode('tree');
}
return;
}
// Preview mode navigation
if (viewMode === 'preview') {
if (key.upArrow) {
setPreviewScroll(prev => Math.max(0, prev - 1));
}
else if (key.downArrow) {
const lines = preview?.split('\n').length ?? 0;
setPreviewScroll(prev => Math.min(Math.max(0, lines - FILE_EXPLORER_VISIBLE_ITEMS), prev + 1));
}
else if (input === ' ' && previewPath) {
// Toggle selection in preview mode
toggleFileSelection(previewPath);
}
return;
}
// Search mode input
if (searchMode) {
if (key.backspace || key.delete) {
setSearchQuery(prev => {
const newQuery = prev.slice(0, -1);
if (newQuery === '') {
setSearchMode(false);
}
return newQuery;
});
setSelectedIndex(0);
}
else if (input && input.length === 1 && !key.ctrl && !key.meta) {
setSearchQuery(prev => prev + input);
setSelectedIndex(0);
}
else if (key.upArrow) {
setSelectedIndex(prev => Math.max(0, prev - 1));
}
else if (key.downArrow) {
setSelectedIndex(prev => Math.min(filteredList.length - 1, prev + 1));
}
else if (key.return) {
handleSelect();
}
return;
}
// Normal tree mode
if (key.upArrow) {
setSelectedIndex(prev => Math.max(0, prev - 1));
}
else if (key.downArrow) {
setSelectedIndex(prev => Math.min(filteredList.length - 1, prev + 1));
}
else if (key.return) {
handleSelect();
}
else if (input === '/') {
setSearchMode(true);
}
else if (input === ' ') {
// Toggle selection with space
if (selectedNode) {
if (selectedNode.isDirectory) {
// Select/deselect all files in directory
toggleDirectorySelection(selectedNode);
}
else {
toggleFileSelection(selectedNode.path);
}
}
}
else if (key.backspace) {
// Go up one directory by collapsing current
handleGoUp();
}
});
if (closed) {
return null;
}
if (loading) {
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StyledTitle, { title: "/explorer", borderColor: colors.primary, shape: currentTitleShape }), _jsx(Text, { color: colors.text, children: "Loading file tree..." })] }));
}
if (error) {
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StyledTitle, { title: "/explorer", borderColor: colors.primary, shape: currentTitleShape }), _jsxs(Text, { color: colors.error, children: ["Error: ", error] })] }));
}
// Preview mode view
if (viewMode === 'preview') {
const previewLines = preview?.split('\n') ?? [];
const visiblePreviewLines = previewLines.slice(previewScroll, previewScroll + FILE_EXPLORER_VISIBLE_ITEMS);
const isSelected = previewPath ? selectedFiles.has(previewPath) : false;
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StyledTitle, { title: `/explorer - ${previewPath}`, borderColor: colors.primary, shape: currentTitleShape }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: isSelected ? colors.success : colors.secondary, children: isSelected ? '✓ Selected' : '✗ Not selected' }), selectedFiles.size > 0 && (_jsxs(Text, { color: colors.secondary, children: [' ', "| ", selectedFiles.size, " file(s) (~", formatTokens(estimatedTokens), ' ', "tokens)"] }))] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: previewError ? (_jsx(Text, { color: colors.warning, children: previewError })) : (visiblePreviewLines.map((line, i) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: colors.secondary, dimColor: true, children: String(previewScroll + i + 1).padStart(4, ' ') }), _jsx(Text, { color: colors.secondary, dimColor: true, children: ' | ' }), line] }, i)))) }), preview && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.secondary, dimColor: true, children: ["Line ", previewScroll + 1, "-", Math.min(previewScroll + FILE_EXPLORER_VISIBLE_ITEMS, previewLines.length), ' ', "of ", previewLines.length] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.secondary, dimColor: true, children: "Up/Down: scroll | Space: toggle select | Shift+Tab/Esc: back" }) })] }));
}
// Tree mode view
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StyledTitle, { title: "/explorer", borderColor: colors.primary, shape: currentTitleShape }), searchMode && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.primary, children: ["Search: ", _jsx(Text, { bold: true, children: searchQuery || '_' })] }) })), selectedFiles.size > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.success, children: [selectedFiles.size, " file(s) selected (~", formatTokens(estimatedTokens), " tokens)"] }), estimatedTokens > FILE_EXPLORER_TOKEN_WARNING_THRESHOLD && (_jsx(Text, { color: colors.warning, children: "That's a lot of context!" }))] })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visibleItems.length === 0 ? (_jsx(Text, { color: colors.secondary, children: searchQuery ? 'No matches found' : 'Empty directory' })) : (visibleItems.map((item, idx) => {
const actualIndex = scrollStart + idx;
const isHighlighted = actualIndex === selectedIndex;
const isFileSelected = selectedFiles.has(item.node.path);
return (_jsx(TreeItem, { item: item, isHighlighted: isHighlighted, isSelected: isFileSelected, selectedFiles: selectedFiles, colors: colors }, item.node.path));
})) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [selectedNode && (_jsx(Box, { children: _jsxs(Text, { color: colors.text, dimColor: true, children: [selectedNode.path, !selectedNode.isDirectory && selectedNode.size !== undefined && (_jsxs(Text, { children: [" (", formatSize(selectedNode.size), ")"] }))] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.secondary, dimColor: true, children: searchMode
? 'Type to filter | Backspace: delete | Esc: exit search'
: 'Up/Down: navigate | Enter: expand/preview | Space: select | /: search | Esc: done' }) })] })] }));
}
//# sourceMappingURL=index.js.map