UNPKG

@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
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