From 7329f89c9643725b0f8d28c4b463041c823c4583 Mon Sep 17 00:00:00 2001 From: simos Date: Sat, 12 Jul 2025 22:02:59 +0000 Subject: [PATCH] Added pull and fetch on git panel Made UX enhancements --- package-lock.json | 151 ++++++++++++++- server/routes/git.js | 163 ++++++++++++++++ src/components/GitPanel.jsx | 366 +++++++++++++++++++++++++++--------- 3 files changed, 592 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed6cdfc..3d51519 100755 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,12 @@ "jsonwebtoken": "^9.0.2", "lucide-react": "^0.515.0", "mime-types": "^3.0.1", + "multer": "^2.0.1", "node-fetch": "^2.7.0", "node-pty": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.8.1", "tailwind-merge": "^3.3.1", @@ -2115,6 +2117,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2125,6 +2133,15 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -2366,6 +2383,23 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2677,6 +2711,21 @@ "node": ">= 6" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", @@ -3195,6 +3244,18 @@ "reusify": "^1.0.4" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4578,6 +4639,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -4588,6 +4661,24 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5049,6 +5140,17 @@ "node": ">=10" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5171,6 +5273,29 @@ "react": "^18.3.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -5966,6 +6091,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6407,8 +6540,7 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -6452,6 +6584,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6860,6 +6998,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/xterm": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", diff --git a/server/routes/git.js b/server/routes/git.js index aa67328..cb0523b 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -420,4 +420,167 @@ function generateSimpleCommitMessage(files, diff) { } } +// Get remote status (ahead/behind commits with smart remote detection) +router.get('/remote-status', async (req, res) => { + const { project } = req.query; + + if (!project) { + return res.status(400).json({ error: 'Project name is required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + // Get current branch + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const branch = currentBranch.trim(); + + // Check if there's a remote tracking branch (smart detection) + let trackingBranch; + let remoteName; + try { + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + trackingBranch = stdout.trim(); + remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") + } catch (error) { + // No upstream branch configured + return res.json({ + hasRemote: false, + branch, + message: 'No remote tracking branch configured' + }); + } + + // Get ahead/behind counts + const { stdout: countOutput } = await execAsync( + `git rev-list --count --left-right ${trackingBranch}...HEAD`, + { cwd: projectPath } + ); + + const [behind, ahead] = countOutput.trim().split('\t').map(Number); + + res.json({ + hasRemote: true, + branch, + remoteBranch: trackingBranch, + remoteName, + ahead: ahead || 0, + behind: behind || 0, + isUpToDate: ahead === 0 && behind === 0 + }); + } catch (error) { + console.error('Git remote status error:', error); + res.json({ error: error.message }); + } +}); + +// Fetch from remote (using smart remote detection) +router.post('/fetch', async (req, res) => { + const { project } = req.body; + + if (!project) { + return res.status(400).json({ error: 'Project name is required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + // Get current branch and its upstream remote + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const branch = currentBranch.trim(); + + let remoteName = 'origin'; // fallback + try { + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + remoteName = stdout.trim().split('/')[0]; // Extract remote name + } catch (error) { + // No upstream, try to fetch from origin anyway + console.log('No upstream configured, using origin as fallback'); + } + + const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath }); + + res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); + } catch (error) { + console.error('Git fetch error:', error); + res.status(500).json({ + error: 'Fetch failed', + details: error.message.includes('Could not resolve hostname') + ? 'Unable to connect to remote repository. Check your internet connection.' + : error.message.includes('fatal: \'origin\' does not appear to be a git repository') + ? 'No remote repository configured. Add a remote with: git remote add origin ' + : error.message + }); + } +}); + +// Pull from remote (fetch + merge using smart remote detection) +router.post('/pull', async (req, res) => { + const { project } = req.body; + + if (!project) { + return res.status(400).json({ error: 'Project name is required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + // Get current branch and its upstream remote + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const branch = currentBranch.trim(); + + let remoteName = 'origin'; // fallback + let remoteBranch = branch; // fallback + try { + const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const tracking = stdout.trim(); + remoteName = tracking.split('/')[0]; // Extract remote name + remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name + } catch (error) { + // No upstream, use fallback + console.log('No upstream configured, using origin/branch as fallback'); + } + + const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath }); + + res.json({ + success: true, + output: stdout || 'Pull completed successfully', + remoteName, + remoteBranch + }); + } catch (error) { + console.error('Git pull error:', error); + + // Enhanced error handling for common pull scenarios + let errorMessage = 'Pull failed'; + let details = error.message; + + if (error.message.includes('CONFLICT')) { + errorMessage = 'Merge conflicts detected'; + details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.'; + } else if (error.message.includes('Please commit your changes or stash them')) { + errorMessage = 'Uncommitted changes detected'; + details = 'Please commit or stash your local changes before pulling.'; + } else if (error.message.includes('Could not resolve hostname')) { + errorMessage = 'Network error'; + details = 'Unable to connect to remote repository. Check your internet connection.'; + } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) { + errorMessage = 'Remote not configured'; + details = 'No remote repository configured. Add a remote with: git remote add origin '; + } else if (error.message.includes('diverged')) { + errorMessage = 'Branches have diverged'; + details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.'; + } + + res.status(500).json({ + error: errorMessage, + details: details + }); + } +}); + export default router; \ No newline at end of file diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index 847bdd3..4ebf661 100755 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react'; +import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download } from 'lucide-react'; import { MicButton } from './MicButton.jsx'; import { authenticatedFetch } from '../utils/api'; @@ -24,6 +24,10 @@ function GitPanel({ selectedProject, isMobile }) { const [expandedCommits, setExpandedCommits] = useState(new Set()); const [commitDiffs, setCommitDiffs] = useState({}); const [isGeneratingMessage, setIsGeneratingMessage] = useState(false); + const [remoteStatus, setRemoteStatus] = useState(null); + const [isFetching, setIsFetching] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile const textareaRef = useRef(null); const dropdownRef = useRef(null); @@ -31,6 +35,7 @@ function GitPanel({ selectedProject, isMobile }) { if (selectedProject) { fetchGitStatus(); fetchBranches(); + fetchRemoteStatus(); if (activeView === 'history') { fetchRecentCommits(); } @@ -105,6 +110,24 @@ function GitPanel({ selectedProject, isMobile }) { } }; + const fetchRemoteStatus = async () => { + if (!selectedProject) return; + + try { + const response = await authenticatedFetch(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`); + const data = await response.json(); + + if (!data.error) { + setRemoteStatus(data); + } else { + setRemoteStatus(null); + } + } catch (error) { + console.error('Error fetching remote status:', error); + setRemoteStatus(null); + } + }; + const switchBranch = async (branchName) => { try { const response = await authenticatedFetch('/api/git/checkout', { @@ -161,6 +184,59 @@ function GitPanel({ selectedProject, isMobile }) { } }; + const handleFetch = async () => { + setIsFetching(true); + try { + const response = await authenticatedFetch('/api/git/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.name + }) + }); + + const data = await response.json(); + if (data.success) { + // Refresh status after successful fetch + fetchGitStatus(); + fetchRemoteStatus(); + } else { + console.error('Fetch failed:', data.error); + } + } catch (error) { + console.error('Error fetching from remote:', error); + } finally { + setIsFetching(false); + } + }; + + const handlePull = async () => { + setIsPulling(true); + try { + const response = await authenticatedFetch('/api/git/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.name + }) + }); + + const data = await response.json(); + if (data.success) { + // Refresh status after successful pull + fetchGitStatus(); + fetchRemoteStatus(); + } else { + console.error('Pull failed:', data.error); + // TODO: Show user-friendly error message + } + } catch (error) { + console.error('Error pulling from remote:', error); + } finally { + setIsPulling(false); + } + }; + const fetchFileDiff = async (filePath) => { try { const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`); @@ -384,22 +460,22 @@ function GitPanel({ selectedProject, isMobile }) { return (
-
+
toggleFileSelected(filePath)} onClick={(e) => e.stopPropagation()} - className="mr-2 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600" + className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`} />
toggleFileExpanded(filePath)} > -
+
{isExpanded ? : }
- {filePath} + {filePath}
- {isExpanded && diff && ( -
- {isMobile && ( -
+
+ {/* Operation header */} +
+
+ + {status} + + + {getStatusLabel(status)} + +
+ {isMobile && ( -
- )} + )} +
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
@@ -449,14 +544,36 @@ function GitPanel({ selectedProject, isMobile }) { return (
{/* Header */} -
+
@@ -495,16 +612,50 @@ function GitPanel({ selectedProject, isMobile }) { )}
- +
+ {/* Remote action buttons - smart logic based on ahead/behind status */} + {remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && ( + <> + {/* Pull button - show when behind (primary action) */} + {remoteStatus.behind > 0 && ( + + )} + + {/* Fetch button - show when ahead only or when diverged (secondary action) */} + {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( + + )} + + )} + + +
{/* Git Repository Not Found Message */} @@ -523,8 +674,12 @@ function GitPanel({ selectedProject, isMobile }) {
) : ( <> - {/* Tab Navigation - Only show when git is available */} -
+ {/* Tab Navigation - Only show when git is available and no files expanded */} +