UNPKG

@codewithmehmet/paul-cli

Version:

Intelligent project file scanner and Git change tracker with interactive interface

469 lines 19.5 kB
import React, { useState, useEffect, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; import { execSync } from 'child_process'; import { getGitStatus, getGitDiff, getGitInfo, formatGitStatus, getGitCommitChanges, getGitCommitDiff, getCommitsSince, getCommitMessage } from '../core/git.js'; import { generateOutputFilename } from '../utils/output.js'; import { copyToClipboard } from '../utils/clipboard.js'; import fs from 'fs/promises'; import { generateDiffHeader, generateSingleChangeWithDiff } from '../utils/diffOutput.js'; export default function DiffInteractive({ onBack }) { const [mode, setMode] = useState('choose'); // 'choose', 'commit-input', 'select', 'export' const [diffMode, setDiffMode] = useState('current'); // 'current', 'commit', 'since' const [commitHash, setCommitHash] = useState(''); const [choicePointer, setChoicePointer] = useState(0); const [changes, setChanges] = useState([]); const [selectedChanges, setSelectedChanges] = useState(new Set()); const [isLoading, setIsLoading] = useState(false); const [pointer, setPointer] = useState(0); const [message, setMessage] = useState(''); const [exportOption, setExportOption] = useState(0); const [showStaged, setShowStaged] = useState(true); const [showUnstaged, setShowUnstaged] = useState(true); const [fileNameInput, setFileNameInput] = useState(''); const [isTypingFileName, setIsTypingFileName] = useState(false); const [commitInfo, setCommitInfo] = useState(null); const [recentCommits, setRecentCommits] = useState([]); const [commitPointer, setCommitPointer] = useState(0); const [showCommitList, setShowCommitList] = useState(true); const loadRecentCommits = () => { try { const output = execSync('git log --oneline -n 20', { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); const commits = output.split('\n').map(line => { const [hash, ...messageParts] = line.split(' '); return { hash: hash, message: messageParts.join(' ') }; }); setRecentCommits(commits); } catch (error) { setRecentCommits([]); } }; useEffect(() => { if (mode === 'commit-input') { loadRecentCommits(); setShowCommitList(true); setCommitPointer(0); } }, [mode]); const loadCurrentChanges = async () => { setIsLoading(true); try { const gitChanges = getGitStatus(); setChanges(gitChanges); setSelectedChanges(new Set()); } catch (error) { setMessage(`Error: ${error.message}`); } setIsLoading(false); }; const loadCommitChanges = async (commit, since = false) => { setIsLoading(true); try { if (since) { const commits = getCommitsSince(commit); const allChanges = []; for (const c of commits) { const commitChanges = getGitCommitChanges(c); const msg = getCommitMessage(c); commitChanges.forEach(change => { change.commitHash = c; change.commitMessage = msg; }); allChanges.push(...commitChanges); } const currentChanges = getGitStatus(); currentChanges.forEach(change => { change.isCurrent = true; }); allChanges.push(...currentChanges); setChanges(allChanges); setCommitInfo({ mode: 'since', commit, count: commits.length }); } else { const commitChanges = getGitCommitChanges(commit); const msg = getCommitMessage(commit); setChanges(commitChanges); setCommitInfo({ mode: 'single', commit, message: msg }); } setSelectedChanges(new Set()); } catch (error) { setMessage(`Error: ${error.message}`); setMode('choose'); } setIsLoading(false); }; const handleModeSelection = () => { if (choicePointer === 0) { setDiffMode('current'); loadCurrentChanges(); setMode('select'); } else if (choicePointer === 1) { setDiffMode('commit'); setMode('commit-input'); } else if (choicePointer === 2) { setDiffMode('since'); setMode('commit-input'); } }; const handleCommitSubmit = () => { if (commitHash) { if (diffMode === 'since') { loadCommitChanges(commitHash, true); } else { loadCommitChanges(commitHash, false); } setMode('select'); } }; const handleCommitSelect = hash => { setCommitHash(hash); if (diffMode === 'since') { loadCommitChanges(hash, true); } else { loadCommitChanges(hash, false); } setMode('select'); }; const filteredChanges = changes.filter(change => { if (change.commitHash) return true; if (!showStaged && (change.type === 'staged' || change.type === 'both')) return false; if (!showUnstaged && (change.type === 'unstaged' || change.type === 'both')) return false; return true; }); const toggleSelection = useCallback(change => { const key = `${change.file}:${change.type}:${change.commitHash || 'current'}`; const newSelection = new Set(selectedChanges); if (newSelection.has(key)) { newSelection.delete(key); } else { newSelection.add(key); } setSelectedChanges(newSelection); }, [selectedChanges]); const selectAll = useCallback(() => { const allKeys = filteredChanges.map(c => `${c.file}:${c.type}:${c.commitHash || 'current'}`); setSelectedChanges(new Set(allKeys)); }, [filteredChanges]); const clearSelection = useCallback(() => { setSelectedChanges(new Set()); }, []); const handleExport = async type => { setMessage('Generating output...'); const { branch } = getGitInfo(); const output = generateDiffHeader(branch, commitInfo); if (commitInfo?.mode === 'since') { const changesByCommit = {}; filteredChanges.forEach(change => { const key = `${change.file}:${change.type}:${change.commitHash || 'current'}`; if (selectedChanges.has(key)) { const group = change.commitHash || 'current'; if (!changesByCommit[group]) changesByCommit[group] = []; changesByCommit[group].push(change); } }); Object.entries(changesByCommit).forEach(([commit, changes]) => { if (commit === 'current') { output.push('## 💻 Current uncommitted changes'); } else { const msg = changes[0].commitMessage || ''; output.push(`## 📦 Commit ${commit.substring(0, 7)}: ${msg}`); } output.push(''); changes.forEach(change => { const diff = commit === 'current' ? getGitDiff(change.file, change.type === 'staged') : getGitCommitDiff(change.file, commit); output.push(...generateSingleChangeWithDiff(change, diff)); }); }); } else { const selectedStaged = changes.filter(c => { const key = `${c.file}:${c.type}:${c.commitHash || 'current'}`; return selectedChanges.has(key) && (c.type === 'staged' || c.type === 'both'); }); const selectedUnstaged = changes.filter(c => { const key = `${c.file}:${c.type}:${c.commitHash || 'current'}`; return selectedChanges.has(key) && (c.type === 'unstaged' || c.type === 'both'); }); if (selectedStaged.length > 0) { output.push(commitInfo ? '## 📦 Commit Changes' : '## 📦 Staged Changes'); for (const change of selectedStaged) { const diff = commitInfo ? getGitCommitDiff(change.file, commitInfo.commit) : getGitDiff(change.file, true); output.push(...generateSingleChangeWithDiff(change, diff)); } } if (selectedUnstaged.length > 0) { output.push('## 💻 Unstaged Changes'); for (const change of selectedUnstaged) { const diff = getGitDiff(change.file, false); output.push(...generateSingleChangeWithDiff(change, diff)); } } } const content = output.join('\n'); if (type === 'clipboard') { const success = await copyToClipboard(content); if (success) { setMessage(`✅ Copied to clipboard (${selectedChanges.size} changes)`); setTimeout(() => onBack(), 2000); } else { setMessage('❌ Failed to copy to clipboard'); } } else if (type === 'file') { const filename = fileNameInput || generateOutputFilename('diff'); try { await fs.writeFile(filename, content, 'utf8'); setMessage(`✅ Saved to ${filename}`); setIsTypingFileName(false); setTimeout(() => onBack(), 2000); } catch (error) { setMessage(`❌ Failed to save: ${error.message}`); } } }; useInput((input, key) => { if (isTypingFileName && key.escape) { setIsTypingFileName(false); return; } if (mode === 'choose') { if (key.escape || input === 'q') { onBack(); } else if (key.upArrow) { setChoicePointer(Math.max(0, choicePointer - 1)); } else if (key.downArrow) { setChoicePointer(Math.min(2, choicePointer + 1)); } else if (key.return) { handleModeSelection(); } } else if (mode === 'commit-input') { if (showCommitList && recentCommits.length > 0) { if (key.escape) { setMode('choose'); setCommitHash(''); setShowCommitList(true); } else if (key.upArrow) { setCommitPointer(Math.max(0, commitPointer - 1)); } else if (key.downArrow) { setCommitPointer(Math.min(recentCommits.length - 1, commitPointer + 1)); } else if (key.return) { handleCommitSelect(recentCommits[commitPointer].hash); } else if (input === '/') { setShowCommitList(false); } } else if (key.escape) { if (commitHash) { setShowCommitList(true); setCommitHash(''); } else { setMode('choose'); } } else if (key.tab) { setShowCommitList(true); } } else if (mode === 'select') { if (key.escape || input === 'q') { if (commitInfo) { setMode('choose'); setCommitInfo(null); setChanges([]); } else { onBack(); } } else if (key.upArrow) { setPointer(Math.max(0, pointer - 1)); } else if (key.downArrow) { setPointer(Math.min(filteredChanges.length - 1, pointer + 1)); } else if (input === ' ') { if (filteredChanges[pointer]) { toggleSelection(filteredChanges[pointer]); } } else if (input === 'a') { selectAll(); } else if (input === 'c') { clearSelection(); } else if (input === 's' && !commitInfo) { setShowStaged(!showStaged); } else if (input === 'u' && !commitInfo) { setShowUnstaged(!showUnstaged); } else if (input === 'o' && selectedChanges.size > 0) { setMode('export'); } } else if (mode === 'export' && !isTypingFileName) { if (key.escape || input === 'q') { setMode('select'); setMessage(''); } else if (key.upArrow) { setExportOption(Math.max(0, exportOption - 1)); } else if (key.downArrow) { setExportOption(Math.min(1, exportOption + 1)); } else if (key.return) { if (exportOption === 0) { handleExport('clipboard'); } else { setIsTypingFileName(true); setFileNameInput(generateOutputFilename('diff')); } } } }); if (mode === 'choose') { const choices = [{ label: '📝 Current changes', value: 'current' }, { label: '📦 From specific commit', value: 'commit' }, { label: '📅 Since commit (all changes)', value: 'since' }]; return /*#__PURE__*/React.createElement(Box, { flexDirection: "column" }, /*#__PURE__*/React.createElement(Text, { color: "green", bold: true }, "\uD83D\uDD04 Git Changes Mode"), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, null, "Select what to view:"), /*#__PURE__*/React.createElement(Text, null, " "), choices.map((choice, index) => /*#__PURE__*/React.createElement(Text, { key: choice.value, color: index === choicePointer ? 'cyan' : 'white' }, index === choicePointer ? '▶ ' : ' ', choice.label)), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "\u2191\u2193 Navigate | \u23CE Select | ESC Back")); } if (mode === 'commit-input') { if (showCommitList && recentCommits.length > 0) { return /*#__PURE__*/React.createElement(Box, { flexDirection: "column" }, /*#__PURE__*/React.createElement(Text, { color: "green", bold: true }, diffMode === 'since' ? '📅 Select commit to view all changes since' : '📦 Select commit'), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "Recent commits (\u2191\u2193 to navigate, Enter to select, / to type manually):"), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Box, { flexDirection: "column", height: 15 }, recentCommits.slice(0, 15).map((commit, index) => /*#__PURE__*/React.createElement(Text, { key: commit.hash, color: index === commitPointer ? 'cyan' : 'white' }, index === commitPointer ? '▶ ' : ' ', /*#__PURE__*/React.createElement(Text, { color: "yellow" }, commit.hash), ' ', commit.message.substring(0, 50), commit.message.length > 50 ? '...' : ''))), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "\u23CE Select | / Type manually | ESC Back")); } return /*#__PURE__*/React.createElement(Box, { flexDirection: "column" }, /*#__PURE__*/React.createElement(Text, { color: "green", bold: true }, diffMode === 'since' ? '📅 Enter commit to view all changes since' : '📦 Enter commit hash'), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, null, "Commit hash (or HEAD~n):"), /*#__PURE__*/React.createElement(TextInput, { value: commitHash, onChange: setCommitHash, onSubmit: handleCommitSubmit }), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "Press Enter to continue, Tab to see list, ESC to go back")); } if (isLoading) { return /*#__PURE__*/React.createElement(Box, null, /*#__PURE__*/React.createElement(Text, { color: "yellow" }, "Loading changes...")); } if (mode === 'export' && isTypingFileName) { return /*#__PURE__*/React.createElement(Box, { flexDirection: "column" }, /*#__PURE__*/React.createElement(Text, { color: "green", bold: true }, "\uD83D\uDCC4 Save to File"), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, null, "Enter filename (or press Enter for default):"), /*#__PURE__*/React.createElement(TextInput, { value: fileNameInput, onChange: setFileNameInput, onSubmit: () => handleExport('file') }), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "Press ESC to cancel")); } if (mode === 'export') { const options = [{ label: '📋 Copy to clipboard', value: 'clipboard' }, { label: '📄 Save to file', value: 'file' }]; return /*#__PURE__*/React.createElement(Box, { flexDirection: "column" }, /*#__PURE__*/React.createElement(Text, { color: "green", bold: true }, "\uD83D\uDCE4 Export Options"), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, null, "Selected: ", selectedChanges.size, " changes"), /*#__PURE__*/React.createElement(Text, null, " "), options.map((option, index) => /*#__PURE__*/React.createElement(Text, { key: option.value, color: index === exportOption ? 'cyan' : 'white' }, index === exportOption ? '▶ ' : ' ', option.label)), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "\u2191\u2193 Navigate | \u23CE Select | ESC Back"), message && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "yellow" }, message))); } const visibleSelectedCount = filteredChanges.filter(change => { const key = `${change.file}:${change.type}:${change.commitHash || 'current'}`; return selectedChanges.has(key); }).length; return /*#__PURE__*/React.createElement(Box, { flexDirection: "column" }, /*#__PURE__*/React.createElement(Box, { flexDirection: "row" }, /*#__PURE__*/React.createElement(Text, { color: "green", bold: true }, "\uD83D\uDD04 Git Changes"), /*#__PURE__*/React.createElement(Box, { marginLeft: 2 }, /*#__PURE__*/React.createElement(Text, { color: "gray" }, commitInfo ? commitInfo.mode === 'since' ? `Since ${commitInfo.commit.substring(0, 7)} (${commitInfo.count} commits + current)` : `Commit ${commitInfo.commit.substring(0, 7)}` : `${visibleSelectedCount} selected | ${showStaged ? 'Staged ✓' : 'Staged ✗'} | ${showUnstaged ? 'Unstaged ✓' : 'Unstaged ✗'}`))), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, '─'.repeat(50)), /*#__PURE__*/React.createElement(Text, null, " "), filteredChanges.length === 0 ? /*#__PURE__*/React.createElement(Text, { color: "gray" }, "No changes found") : filteredChanges.map((change, index) => { const isPointed = index === pointer; const key = `${change.file}:${change.type}:${change.commitHash || 'current'}`; const isSelected = selectedChanges.has(key); const statusIcon = formatGitStatus(change.status); let label = ''; if (change.commitHash) { label = `[${change.commitHash.substring(0, 7)}]`; } else if (change.isCurrent) { const typeLabel = change.type === 'both' ? '[S+U]' : change.type === 'staged' ? '[S]' : '[U]'; label = typeLabel; } else { const typeLabel = change.type === 'both' ? '[S+U]' : change.type === 'staged' ? '[S]' : '[U]'; label = typeLabel; } return /*#__PURE__*/React.createElement(Text, { key: key, color: isPointed ? 'cyan' : 'white' }, isPointed ? '▶ ' : ' ', isSelected ? '[✓] ' : '[ ] ', statusIcon, " ", label, " ", change.file); }), /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "gray" }, '─'.repeat(50)), /*#__PURE__*/React.createElement(Text, { color: "gray" }, "\u2191\u2193 Navigate | \u2423 Select | a All | c Clear"), /*#__PURE__*/React.createElement(Text, { color: "gray" }, !commitInfo && 's Toggle staged | u Toggle unstaged | ', selectedChanges.size > 0 ? 'o Export | ' : '', "ESC Back"), message && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Text, null, " "), /*#__PURE__*/React.createElement(Text, { color: "yellow" }, message))); }