@codewithmehmet/paul-cli
Version:
Intelligent project file scanner and Git change tracker with interactive interface
469 lines • 19.5 kB
JavaScript
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)));
}