UNPKG

rxcc

Version:

To install dependencies:

879 lines (865 loc) 26.8 kB
#!/usr/bin/env node // src/App.tsx import { useEffect as useEffect3 } from "react"; // src/components/FileExplorer.tsx import { Box as Box2, Text as Text2, useInput, useStdout } from "ink"; import { useCallback as useCallback2, useState as useState3 } from "react"; // src/hooks/useFileExplorer.ts import { useState, useEffect, useCallback } from "react"; // src/utils/fileSystem.ts import fs from "fs"; import path2 from "path"; // src/utils/tokenCounting.ts import { runDefaultAction, setLogLevel } from "repomix"; import path from "path"; var tokenCountsCache = null; async function getTokenCounts(cwd = ".") { if (tokenCountsCache) { return tokenCountsCache; } try { setLogLevel(-1); const result = await runDefaultAction(["."], cwd, { tokenCountTree: true, quiet: true }); if (!result || !result.packResult || !result.packResult.fileTokenCounts) { throw new Error("No token counts from repomix"); } tokenCountsCache = result.packResult.fileTokenCounts; return tokenCountsCache; } catch (error) { console.error("Failed to get token counts:", error); tokenCountsCache = {}; return tokenCountsCache; } } function getFileTokenCount(filePath, tokenCounts) { const relativePath = path.relative(".", filePath); const normalizedPath = relativePath.replace(/\\/g, "/"); const result = tokenCounts[normalizedPath] || tokenCounts[relativePath] || tokenCounts[filePath] || tokenCounts[`./${normalizedPath}`] || 0; return result; } function calculateDirectoryTokensFromTokenCounts(dirPath, tokenCounts) { const relativeDirPath = path.relative(".", dirPath); const normalizedDirPath = relativeDirPath.replace(/\\/g, "/"); let total = 0; for (const [filePath, tokens] of Object.entries(tokenCounts)) { if (filePath.startsWith(normalizedDirPath + "/") || filePath.startsWith(relativeDirPath + "/") || filePath.startsWith(dirPath + "/")) { total += tokens; } } return total; } function calculateSelectedTokenCount(item, tokenCounts) { if (!item.isSelected) { return 0; } if (!item.isDirectory) { return item.tokenCount; } if (item.children && item.children.length > 0) { let total = 0; for (const child of item.children) { total += calculateSelectedTokenCount(child, tokenCounts); } return total; } if (tokenCounts) { return calculateDirectoryTokensFromTokenCounts(item.path, tokenCounts); } return 0; } function updateTokenCountsRecursively(items, tokenCounts) { return items.map((item) => { const updatedItem = { ...item }; if (item.isDirectory) { updatedItem.tokenCount = calculateDirectoryTokensFromTokenCounts(item.path, tokenCounts); if (item.children) { updatedItem.children = updateTokenCountsRecursively( item.children, tokenCounts ); } } else { updatedItem.tokenCount = getFileTokenCount(item.path, tokenCounts); } updatedItem.selectedTokenCount = calculateSelectedTokenCount(updatedItem, tokenCounts); return updatedItem; }); } function propagateSelectionTokenCounts(items, tokenCounts = {}) { const updateParentTokenCounts = (item) => { if (!item.isDirectory) { return item.isSelected ? item.tokenCount : 0; } let selectedTokens = 0; if (item.children && item.children.length > 0) { for (const child of item.children) { selectedTokens += updateParentTokenCounts(child); } } else if (item.isSelected) { selectedTokens = calculateDirectoryTokensFromTokenCounts(item.path, tokenCounts); } item.selectedTokenCount = selectedTokens; return selectedTokens; }; const updatedItems = [...items]; for (const item of updatedItems) { updateParentTokenCounts(item); } return updatedItems; } function getTotalSelectedTokens(items, tokenCounts = {}) { let total = 0; const traverse = (itemList) => { for (const item of itemList) { if (item.isSelected && !item.isDirectory) { total += item.tokenCount; } else if (item.isSelected && item.isDirectory && (!item.children || item.children.length === 0)) { total += calculateDirectoryTokensFromTokenCounts(item.path, tokenCounts); } if (item.children && item.children.length > 0) { traverse(item.children); } } }; traverse(items); return total; } // src/utils/ignorePatterns.ts var defaultIgnoreList = [ // Version control ".git/**", ".hg/**", ".hgignore", ".svn/**", // Dependency directories "**/node_modules/**", "**/bower_components/**", "**/jspm_packages/**", "vendor/**", "**/.bundle/**", "**/.gradle/**", "target/**", // Logs "logs/**", "**/*.log", "**/npm-debug.log*", "**/yarn-debug.log*", "**/yarn-error.log*", // Runtime data "pids/**", "*.pid", "*.seed", "*.pid.lock", // Directory for instrumented libs generated by jscoverage/JSCover "lib-cov/**", // Coverage directory used by tools like istanbul "coverage/**", // nyc test coverage ".nyc_output/**", // Grunt intermediate storage ".grunt/**", // node-waf configuration ".lock-wscript", // Compiled binary addons "build/Release/**", // TypeScript v1 declaration files "typings/**", // Optional npm cache directory "**/.npm/**", // Cache directories ".eslintcache", ".rollup.cache/**", ".webpack.cache/**", ".parcel-cache/**", ".sass-cache/**", "*.cache", // Optional REPL history ".node_repl_history", // Output of 'npm pack' "*.tgz", // Yarn files "**/.yarn/**", // Yarn Integrity file "**/.yarn-integrity", // dotenv environment variables file ".env", // next.js build output ".next/**", // nuxt.js build output ".nuxt/**", // vuepress build output ".vuepress/dist/**", // Serverless directories ".serverless/**", // FuseBox cache ".fusebox/**", // DynamoDB Local files ".dynamodb/**", // TypeScript output "dist/**", // OS generated files "**/.DS_Store", "**/Thumbs.db", // Editor directories and files ".idea/**", ".vscode/**", "**/*.swp", "**/*.swo", "**/*.swn", "**/*.bak", // Build outputs "build/**", "out/**", // Temporary files "tmp/**", "temp/**", // repomix output "**/repomix-output.*", "**/repopack-output.*", // Legacy // Essential Node.js-related entries "**/package-lock.json", "**/yarn-error.log", "**/yarn.lock", "**/pnpm-lock.yaml", "**/bun.lockb", "**/bun.lock", // Essential Python-related entries "**/__pycache__/**", "**/*.py[cod]", "**/venv/**", "**/.venv/**", "**/.pytest_cache/**", "**/.mypy_cache/**", "**/.ipynb_checkpoints/**", "**/Pipfile.lock", "**/poetry.lock", "**/uv.lock", // Essential Rust-related entries "**/Cargo.lock", "**/Cargo.toml.orig", "**/target/**", "**/*.rs.bk", // Essential PHP-related entries "**/composer.lock", // Essential Ruby-related entries "**/Gemfile.lock", // Essential Go-related entries "**/go.sum", // Essential Elixir-related entries "**/mix.lock", // Essential Haskell-related entries "**/stack.yaml.lock", "**/cabal.project.freeze" ]; function matchesPattern(filePath, pattern, _depth = 0) { if (_depth > 10) return false; const normalizedPath = filePath.replace(/^\.\//, "").replace(/\\/g, "/"); let normalizedPattern = pattern.replace(/^\.\//, "").replace(/\\/g, "/"); if (normalizedPattern.startsWith("**/")) { const subPattern = normalizedPattern.slice(3); const pathParts = normalizedPath.split("/"); for (let i = 0; i < pathParts.length; i++) { const subPath = pathParts.slice(i).join("/"); if (matchesPattern(subPath, subPattern, _depth + 1)) { return true; } } return matchesPattern(normalizedPath, subPattern, _depth + 1); } if (normalizedPattern.endsWith("/**")) { const directoryPattern = normalizedPattern.slice(0, -3); return matchesPattern(normalizedPath, directoryPattern, _depth + 1) || normalizedPath.startsWith(directoryPattern + "/"); } let regexPattern = normalizedPattern.replace(/\*\*/g, "\xA7DOUBLESTAR\xA7").replace(/\*/g, "[^/]*").replace(/§DOUBLESTAR§/g, ".*").replace(/\./g, "\\.").replace(/\?/g, "."); regexPattern = `^${regexPattern}$`; const regex = new RegExp(regexPattern); return regex.test(normalizedPath); } function shouldIgnore(filePath, ignorePatterns = defaultIgnoreList) { const normalizedPath = filePath.replace(/^\.\//, "").replace(/\\/g, "/"); for (const pattern of ignorePatterns) { if (matchesPattern(normalizedPath, pattern)) { return true; } } return false; } // src/utils/fileSystem.ts function readDirectory(dirPath, depth = 0, parent, tokenCounts = {}) { try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); return entries.filter((entry) => { const itemPath = path2.join(dirPath, entry.name); const relativePath = path2.relative(".", itemPath); return !shouldIgnore(relativePath); }).sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }).map((entry) => { const itemPath = path2.join(dirPath, entry.name); const tokenCount = entry.isDirectory() ? 0 : getFileTokenCount(itemPath, tokenCounts); return { name: entry.name, path: itemPath, isDirectory: entry.isDirectory(), isExpanded: false, isSelected: false, children: entry.isDirectory() ? [] : void 0, parent, depth, tokenCount, selectedTokenCount: 0 }; }); } catch { return []; } } function flattenItems(items) { const result = []; const traverse = (itemList) => { for (const item of itemList) { result.push(item); if (item.isDirectory && item.isExpanded && item.children) { traverse(item.children); } } }; traverse(items); return result; } // src/utils/itemOperations.ts function selectAllChildren(item, isSelected) { const updatedItem = { ...item, isSelected }; if (updatedItem.children) { updatedItem.children = updatedItem.children.map( (child) => selectAllChildren(child, isSelected) ); } return updatedItem; } function updateParentSelectionState(item) { if (!item.children || item.children.length === 0) { return { ...item, isPartiallySelected: false }; } const updatedChildren = item.children.map(updateParentSelectionState); const allChildrenSelected = updatedChildren.every((child) => child.isSelected); const anyChildrenSelected = updatedChildren.some( (child) => child.isSelected || child.isPartiallySelected ); return { ...item, children: updatedChildren, isSelected: allChildrenSelected, // Parent is selected only if ALL children are selected isPartiallySelected: !allChildrenSelected && anyChildrenSelected // Partially selected if some but not all children are selected }; } function expandFolder(item, items, tokenCounts = {}) { if (!item.isDirectory || item.isExpanded) return items; const children = readDirectory(item.path, item.depth + 1, item, tokenCounts); const inheritedChildren = item.isSelected ? children.map((child) => selectAllChildren(child, true)) : children; const updateItems = (itemList) => { return itemList.map((currentItem) => { if (currentItem === item) { return { ...currentItem, isExpanded: true, children: inheritedChildren }; } if (currentItem.children) { return { ...currentItem, children: updateItems(currentItem.children) }; } return currentItem; }); }; return updateItems(items); } function collapseFolder(item, items) { if (!item.isDirectory || !item.isExpanded) return items; const updateItems = (itemList) => { return itemList.map((currentItem) => { if (currentItem === item) { return { ...currentItem, isExpanded: false }; } if (currentItem.children) { return { ...currentItem, children: updateItems(currentItem.children) }; } return currentItem; }); }; return updateItems(items); } function toggleSelection(item, items, tokenCounts = {}) { const updateItems = (itemList) => { return itemList.map((currentItem) => { if (currentItem === item) { const newSelectionState = !currentItem.isSelected; return selectAllChildren(currentItem, newSelectionState); } if (currentItem.children) { const updatedItem = { ...currentItem, children: updateItems(currentItem.children) }; return updateParentSelectionState(updatedItem); } return currentItem; }); }; let updatedItems = updateItems(items); updatedItems = updatedItems.map(updateParentSelectionState); const updatedItemsWithTokens = propagateSelectionTokenCounts(updatedItems, tokenCounts); const totalSelectedTokens = getTotalSelectedTokens(updatedItemsWithTokens, tokenCounts); const flatItems = flattenItems(updatedItemsWithTokens); const selectedCount = flatItems.filter((item2) => item2.isSelected).length; return [updatedItemsWithTokens, selectedCount, totalSelectedTokens]; } function toggleAll(items, tokenCounts = {}) { const flatItems = flattenItems(items); const allSelected = flatItems.every((item) => item.isSelected); let selectedCount = 0; const updateItems = (itemList) => { return itemList.map((item) => { const newItem = { ...item, isSelected: !allSelected }; if (newItem.isSelected) selectedCount++; if (item.children) { return { ...newItem, children: updateItems(item.children) }; } return newItem; }); }; const updatedItems = updateItems(items); const updatedItemsWithTokens = propagateSelectionTokenCounts(updatedItems, tokenCounts); const totalSelectedTokens = getTotalSelectedTokens(updatedItemsWithTokens, tokenCounts); return [updatedItemsWithTokens, selectedCount, totalSelectedTokens]; } // src/hooks/useFileExplorer.ts function useFileExplorer() { const [state, setState] = useState({ items: [], currentIndex: 0, selectedCount: 0, totalSelectedTokens: 0 }); const [tokenCounts, setTokenCounts] = useState({}); const [isLoading, setIsLoading] = useState(true); const currentDir = process.cwd(); useEffect(() => { const initializeData = async () => { try { const tokenData = await getTokenCounts(currentDir); setTokenCounts(tokenData); const initialItems = readDirectory(currentDir, 0, void 0, tokenData); const itemsWithTokens = updateTokenCountsRecursively(initialItems, tokenData); setState({ items: itemsWithTokens, currentIndex: 0, selectedCount: 0, totalSelectedTokens: 0 }); } finally { setIsLoading(false); } }; initializeData(); }, [currentDir]); const handleExpandFolder = useCallback( (item) => { const updatedItems = expandFolder(item, state.items, tokenCounts); const itemsWithTokens = updateTokenCountsRecursively(updatedItems, tokenCounts); setState((prev) => ({ ...prev, items: itemsWithTokens })); }, [state.items, tokenCounts] ); const handleCollapseFolder = useCallback( (item) => { const updatedItems = collapseFolder(item, state.items); setState((prev) => ({ ...prev, items: updatedItems })); }, [state.items] ); const handleToggleSelection = useCallback( (item) => { const [updatedItems, newSelectedCount, newTotalSelectedTokens] = toggleSelection( item, state.items, tokenCounts ); setState((prev) => ({ ...prev, items: updatedItems, selectedCount: newSelectedCount, totalSelectedTokens: newTotalSelectedTokens })); }, [state.items, tokenCounts] ); const handleToggleAll = useCallback(() => { const [updatedItems, newSelectedCount, newTotalSelectedTokens] = toggleAll(state.items, tokenCounts); setState((prev) => ({ ...prev, items: updatedItems, selectedCount: newSelectedCount, totalSelectedTokens: newTotalSelectedTokens })); }, [state.items, tokenCounts]); const moveUp = useCallback(() => { setState((prev) => ({ ...prev, currentIndex: Math.max(0, prev.currentIndex - 1) })); }, []); const moveDown = useCallback(() => { const flatItems = flattenItems(state.items); setState((prev) => ({ ...prev, currentIndex: Math.min(flatItems.length - 1, prev.currentIndex + 1) })); }, [state.items]); const navigateToIndex = useCallback((index) => { const flatItems = flattenItems(state.items); const clampedIndex = Math.max(0, Math.min(flatItems.length - 1, index)); setState((prev) => ({ ...prev, currentIndex: clampedIndex })); }, [state.items]); return { state, currentDir, flattenItems, handleExpandFolder, handleCollapseFolder, handleToggleSelection, handleToggleAll, moveUp, moveDown, navigateToIndex, isLoading }; } // src/utils/repomixRunner.ts import { runCli, setLogLevel as setLogLevel2 } from "repomix"; function getSelectedFilePaths(items) { const selectedPaths = []; function traverse(items2) { for (const item of items2) { if (item.isSelected) { selectedPaths.push(item.path); } if (item.children) { traverse(item.children); } } } traverse(items); return selectedPaths; } async function runRepomixWithSelection({ selectedFiles, cwd }) { if (selectedFiles.length === 0) { throw new Error("No files selected"); } const options = { include: selectedFiles.join(","), copy: true, quiet: true }; const originalCwd = process.cwd(); try { setLogLevel2(-1); process.chdir(cwd); await runCli([cwd], cwd, options); } finally { process.chdir(originalCwd); } } // src/components/LoadingScreen.tsx import { Box, Text } from "ink"; import { useState as useState2, useEffect as useEffect2 } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; function LoadingScreen() { const [dots, setDots] = useState2(0); useEffect2(() => { const interval = setInterval(() => { setDots((prev) => (prev + 1) % 4); }, 400); return () => { clearInterval(interval); }; }, []); const dotString = ".".repeat(dots); return /* @__PURE__ */ jsxs( Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: 24, children: [ /* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "Crimson Phoenix" }), /* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsxs(Text, { color: "gray", children: [ "Loading", dotString ] }) ] } ); } // src/components/FileExplorer.tsx import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; function FileExplorer() { const { state, currentDir, flattenItems: flattenItems2, handleExpandFolder, handleCollapseFolder, handleToggleSelection, handleToggleAll, moveUp, moveDown, navigateToIndex, isLoading } = useFileExplorer(); const { stdout } = useStdout(); const [isExecuting, setIsExecuting] = useState3(false); const [executionResult, setExecutionResult] = useState3(null); const navigateToParent = useCallback2(() => { const flatItems2 = flattenItems2(state.items); const currentItem = flatItems2[state.currentIndex]; if (currentItem && currentItem.depth > 0) { for (let i = state.currentIndex - 1; i >= 0; i--) { const item = flatItems2[i]; if (!item) { continue; } if (item.depth < currentItem.depth) { navigateToIndex(i); return true; } } } return false; }, [state.currentIndex, state.items, navigateToIndex]); const executeRepomix = useCallback2(async () => { try { setIsExecuting(true); setExecutionResult(null); const selectedFiles = getSelectedFilePaths(state.items); if (selectedFiles.length === 0) { setExecutionResult("\u274C No files selected"); return; } await runRepomixWithSelection({ selectedFiles, cwd: currentDir }); setExecutionResult("\u2705 Complete"); } catch (error) { setExecutionResult( `\u274C ${error instanceof Error ? error.message : "Error"}` ); } finally { setIsExecuting(false); } }, [state.items, currentDir]); useInput((input, key) => { if (executionResult) { setExecutionResult(null); return; } if (state.items.length === 0 || isExecuting) return; const flatItems2 = flattenItems2(state.items); const currentItem = flatItems2[state.currentIndex]; if (key.return) { executeRepomix(); } else if (key.upArrow) { moveUp(); } else if (key.downArrow) { moveDown(); } else if (key.rightArrow) { if (currentItem?.isDirectory && !currentItem.isExpanded) { handleExpandFolder(currentItem); } else { moveDown(); } } else if (key.leftArrow) { if (currentItem?.isDirectory && currentItem.isExpanded) { handleCollapseFolder(currentItem); } else if (currentItem && currentItem.depth > 0) { navigateToParent(); } } else if (input === " " && currentItem) { handleToggleSelection(currentItem); } else if (input === "a") { handleToggleAll(); } }); const formatTokenCount = (count) => { if (count === 0) return ""; if (count < 1e3) return `${count}`; if (count < 1e6) return `${(count / 1e3).toFixed(1)}k`; return `${(count / 1e6).toFixed(1)}m`; }; const renderItem = (item, index, isActive) => { const indent = " ".repeat(item.depth); let checkbox = "[ ]"; let checkboxColor = void 0; if (item.isSelected) { checkbox = "[\u2713]"; checkboxColor = "green"; } else if (item.isPartiallySelected) { checkbox = "[\u25D0]"; checkboxColor = "yellow"; } const icon = item.isDirectory ? item.isExpanded ? "\u25BE" : "\u25B8" : "\xB7"; const cursor = isActive ? "\u258D " : " "; let tokenDisplay = ""; if (item.isDirectory && item.isPartiallySelected) { const selectedTokens2 = formatTokenCount(item.selectedTokenCount); const totalTokens = formatTokenCount(item.tokenCount); tokenDisplay = `${selectedTokens2}/${totalTokens}`; } else if (item.isDirectory && item.isSelected) { tokenDisplay = formatTokenCount(item.selectedTokenCount); } else { tokenDisplay = formatTokenCount(item.tokenCount); } const tokenColor = item.isSelected || item.isPartiallySelected ? "cyan" : "gray"; const nameColor = item.isSelected ? "white" : void 0; return /* @__PURE__ */ jsxs2( Text2, { children: [ cursor, /* @__PURE__ */ jsx2(Text2, { color: checkboxColor, children: checkbox }), " ", indent, /* @__PURE__ */ jsxs2(Text2, { color: nameColor, children: [ icon, " ", item.name ] }), tokenDisplay && /* @__PURE__ */ jsxs2(Text2, { color: tokenColor, children: [ " (", tokenDisplay, ")" ] }) ] }, `${item.path}-${index}-${item.isSelected}-${item.isPartiallySelected}` ); }; const flatItems = flattenItems2(state.items); const visibleHeight = (stdout?.rows || 24) - 3; const startIndex = Math.max( 0, Math.min( state.currentIndex - Math.floor(visibleHeight / 2), flatItems.length - visibleHeight ) ); const endIndex = Math.min(flatItems.length, startIndex + visibleHeight); const visibleItems = flatItems.slice(startIndex, endIndex); const getTotalAvailableTokens = () => { return flatItems.reduce((total, item) => { if (item.isDirectory && item.isExpanded) { return total; } return total + item.tokenCount; }, 0); }; const totalAvailable = getTotalAvailableTokens(); const selectedTokens = state.totalSelectedTokens; const statusLine = `${state.selectedCount} selected \u2022 ${formatTokenCount( selectedTokens )}/${formatTokenCount(totalAvailable)} tokens \u2022 ${state.currentIndex + 1}/${flatItems.length} \u2022 ${currentDir}`; if (isLoading) { return /* @__PURE__ */ jsx2(LoadingScreen, {}); } if (isExecuting) { return /* @__PURE__ */ jsx2( Box2, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: stdout?.rows || 24, children: /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "Processing..." }) } ); } if (executionResult) { return /* @__PURE__ */ jsxs2( Box2, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: stdout?.rows || 24, children: [ /* @__PURE__ */ jsx2(Text2, { children: executionResult }), /* @__PURE__ */ jsx2(Text2, { children: " " }), /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "Press any key to continue..." }) ] } ); } return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", height: stdout?.rows || 24, children: [ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u2191/\u2193 move \u2022 \u2192 expand \u2022 \u2190 collapse/parent \u2022 Space select \u2022 a toggle all \u2022 Enter execute" }), /* @__PURE__ */ jsx2(Text2, { children: " " }), /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", flexGrow: 1, children: visibleItems.map((item, index) => { const actualIndex = startIndex + index; return renderItem( item, actualIndex, actualIndex === state.currentIndex ); }) }), /* @__PURE__ */ jsx2(Text2, { color: "gray", children: statusLine }) ] }); } // src/App.tsx import { render } from "ink"; import { jsx as jsx3 } from "react/jsx-runtime"; function App() { useEffect3(() => { process.stdout.write("\x1B[2J\x1B[3J\x1B[H"); }, []); return /* @__PURE__ */ jsx3(FileExplorer, {}); } render(/* @__PURE__ */ jsx3(App, {})); export { App };