feature: Commit Log Viewer (#7)

* style: slightly decrease font / button size for repo container

* feat: view commit modal to view local commit details

* fix: allow staging and comitting deleted files

* feat: handle modify-delete edge case
- local side deleted, remote modified
- let user pick between restore, keep deleted
- special handling for editing

* feat: handle empty state for commits modal
This commit is contained in:
Sam Chau
2024-11-19 02:02:48 +10:30
committed by Sam Chau
parent ca84a1c95b
commit 529072dc6c
13 changed files with 1172 additions and 237 deletions

View File

@@ -1,9 +1,11 @@
// ConflictRow.jsx
import React, {useState} from 'react';
import {AlertTriangle, GitMerge, Check, Edit2} from 'lucide-react';
import Tooltip from '../../ui/Tooltip';
import ResolveConflicts from './modal/ResolveConflicts';
const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
console.log('ConflictRow change:', JSON.stringify(change, null, 2));
const [showChanges, setShowChanges] = useState(false);
const handleResolveConflicts = e => {
@@ -22,6 +24,15 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
const isResolved = change.status === 'RESOLVED';
// Check if this is a modify/delete conflict
const fileConflict = change.conflict_details?.conflicting_parameters?.find(
param => param.parameter === 'file'
);
const isModifyDelete = !!fileConflict;
// Determine if button should be disabled
const isButtonDisabled = isModifyDelete && isResolved;
return (
<>
<tr className='border-t border-gray-600'>
@@ -58,12 +69,23 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
<td className='px-4 py-2 text-left align-middle'>
<Tooltip
content={
isResolved ? 'Edit resolution' : 'Resolve conflicts'
isButtonDisabled
? 'Abort to try again'
: isResolved
? 'Edit resolution'
: 'Resolve conflicts'
}>
<button
onClick={handleResolveConflicts}
className={`flex items-center justify-center px-2 py-1 rounded hover:bg-gray-700 transition-colors text-xs w-full ${
isResolved
onClick={
isButtonDisabled
? undefined
: handleResolveConflicts
}
disabled={isButtonDisabled}
className={`flex items-center justify-center px-2 py-1 rounded transition-colors text-xs w-full ${
isButtonDisabled
? 'bg-gray-500 text-gray-400 cursor-not-allowed'
: isResolved
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 hover:bg-gray-700'
}`}>
@@ -82,18 +104,19 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
</Tooltip>
</td>
</tr>
<ResolveConflicts
key={`${change.file_path}-changes`}
isOpen={showChanges}
onClose={() => setShowChanges(false)}
change={change}
isIncoming={false}
isMergeConflict={true}
fetchGitStatus={fetchGitStatus}
isDevMode={isDevMode}
/>
{!isButtonDisabled && (
<ResolveConflicts
key={`${change.file_path}-changes`}
isOpen={showChanges}
onClose={() => setShowChanges(false)}
change={change}
isIncoming={false}
isMergeConflict={true}
fetchGitStatus={fetchGitStatus}
isDevMode={isDevMode}
/>
)}
</>
);
};
export default ConflictRow;

View File

@@ -1,37 +1,59 @@
// ConflictTable.jsx
import ConflictRow from './ConflictRow';
import {Check} from 'lucide-react';
const EmptyState = () => (
<div className='flex flex-col items-center justify-center p-8 text-gray-300'>
<div className='bg-green-500/10 rounded-full p-3 mb-3'>
<Check className='w-6 h-6 text-green-500' />
</div>
<p className='text-lg font-medium'>No conflicts to resolve</p>
<p className='text-sm text-gray-400'>Everything is up to date!</p>
</div>
);
const ConflictTable = ({conflicts, isDevMode, fetchGitStatus}) => {
const hasUnresolvedConflicts = conflicts.some(
conflict => conflict.status !== 'RESOLVED'
);
return (
<div className='border border-gray-600 rounded-md overflow-hidden mt-3'>
<table className='w-full text-sm'>
<thead className='bg-gray-600'>
<tr>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Status
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Type
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/2'>
Name
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Actions
</th>
</tr>
</thead>
<tbody>
{conflicts.map((conflict, index) => (
<ConflictRow
key={`conflict-${index}`}
change={conflict}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
))}
</tbody>
</table>
{conflicts.length === 0 ? (
<EmptyState />
) : (
<>
<table className='w-full text-sm'>
<thead className='bg-gray-600'>
<tr>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Status
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Type
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/2'>
Name
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Actions
</th>
</tr>
</thead>
<tbody>
{conflicts.map((conflict, index) => (
<ConflictRow
key={`conflict-${index}`}
change={conflict}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
))}
</tbody>
</table>
</>
)}
</div>
);
};

View File

@@ -14,6 +14,7 @@ import Alert from '../../ui/Alert';
import LinkRepo from './modal/LinkRepo';
import UnlinkRepo from './modal/UnlinkRepo';
import ViewBranches from './modal/ViewBranches';
import ViewCommits from './modal/ViewCommits';
const RepoContainer = ({
settings,
@@ -26,6 +27,7 @@ const RepoContainer = ({
const [showLinkModal, setShowLinkModal] = useState(false);
const [showUnlinkRepo, setShowUnlinkRepo] = useState(false);
const [showBranchModal, setShowBranchModal] = useState(false);
const [showCommitModal, setShowCommitModal] = useState(false);
const handleLinkRepo = () => {
setLoadingAction('link_repo');
@@ -63,13 +65,11 @@ const RepoContainer = ({
}
};
// Handler to close the LinkRepo modal and reset loadingAction
const closeLinkModal = () => {
setShowLinkModal(false);
setLoadingAction('');
};
// Handler to close the UnlinkRepo modal and reset loadingAction
const closeUnlinkModal = () => {
setShowUnlinkRepo(false);
setLoadingAction('');
@@ -81,8 +81,8 @@ const RepoContainer = ({
<div className='flex flex-col sm:flex-row items-start sm:items-center justify-between space-y-3 sm:space-y-0'>
<div className='flex flex-col sm:flex-row sm:items-center'>
<div className='flex items-center'>
<Github className='mr-2 text-blue-400' size={14} />
<h3 className='text-m font-semibold text-gray-100 mr-2 mb-1 sm:mb-0'>
<Github className='mr-2 text-blue-400' size={13} />
<h3 className='text-sm font-semibold text-gray-100 mr-2 mb-1 sm:mb-0'>
{settings
? 'Connected Repository:'
: 'Repository:'}
@@ -93,11 +93,11 @@ const RepoContainer = ({
href={settings.gitRepo}
target='_blank'
rel='noopener noreferrer'
className='text-blue-400 hover:text-blue-300 transition-colors text-m font-medium truncate max-w-xs sm:max-w-md'>
className='text-blue-400 hover:text-blue-300 transition-colors text-sm font-medium truncate max-w-xs sm:max-w-md'>
{settings.gitRepo}
</a>
) : (
<span className='text-gray-400 text-sm'>
<span className='text-gray-400 text-xs'>
No repository linked
</span>
)}
@@ -112,16 +112,16 @@ const RepoContainer = ({
? () => setShowUnlinkRepo(true)
: handleLinkRepo
}
className={`flex items-center px-4 py-2 ${
className={`flex items-center px-3 py-1.5 ${
settings
? 'bg-red-600 hover:bg-red-700'
: 'bg-blue-600 hover:bg-blue-700'
} text-white rounded-md transition-colors duration-200 ease-in-out text-m font-medium shadow-sm`}
} text-white rounded-md transition-colors duration-200 ease-in-out text-sm font-medium shadow-sm`}
disabled={loadingAction !== ''}>
{settings ? (
<Unlink size={16} className='mr-2' />
<Unlink size={14} className='mr-2' />
) : (
<Link size={16} className='mr-2' />
<Link size={14} className='mr-2' />
)}
{settings ? 'Unlink' : 'Link Repository'}
</button>
@@ -132,32 +132,41 @@ const RepoContainer = ({
<div className='flex items-center'>
<GitBranch
className='mr-2 text-blue-400'
size={14}
size={13}
/>
<h3 className='text-m font-semibold text-gray-100 mr-2'>
<h3 className='text-sm font-semibold text-gray-100 mr-2'>
Current Branch:
</h3>
{status ? (
<span className='text-m font-medium'>
<span className='text-sm font-medium'>
{status.branch}
</span>
) : (
<span className='text-gray-400 text-m flex items-center'>
<span className='text-gray-400 text-sm flex items-center'>
<Loader
className='animate-spin mr-2'
size={14}
size={13}
/>
Loading branch information...
</span>
)}
</div>
<button
onClick={() => setShowBranchModal(true)}
className='flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-m'
disabled={!status}>
<Eye size={14} className='mr-2' />
View Branches
</button>
<div className='flex space-x-2'>
<button
onClick={() => setShowBranchModal(true)}
className='flex items-center px-2.5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-sm'
disabled={!status}>
<Eye size={13} className='mr-2' />
View Branches
</button>
<button
onClick={() => setShowCommitModal(true)}
className='flex items-center px-2.5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-sm'
disabled={!status}>
<GitCommit size={13} className='mr-2' />
View Commits
</button>
</div>
</div>
)}
</div>
@@ -172,14 +181,26 @@ const RepoContainer = ({
onSubmit={handleUnlinkRepo}
/>
{settings && status && (
<ViewBranches
isOpen={showBranchModal}
onClose={() => setShowBranchModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
onBranchChange={fetchGitStatus}
isDevMode={isDevMode}
/>
<>
<ViewBranches
isOpen={showBranchModal}
onClose={() => setShowBranchModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
onBranchChange={fetchGitStatus}
isDevMode={isDevMode}
/>
<ViewCommits
isOpen={showCommitModal}
onClose={() => setShowCommitModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
localCommits={status.local_commits || []}
remoteCommits={status.remote_commits || []}
outgoingChanges={status.outgoing_changes || []}
incomingChanges={status.incoming_changes || []}
/>
</>
)}
</div>
);

View File

@@ -278,9 +278,62 @@ const ResolveConflicts = ({
);
};
const renderModifyDeleteConflict = () => {
if (change.status !== 'MODIFY_DELETE') return null;
return renderTable(
'File Status Conflict',
[
{label: 'Status', width: 'w-1/4'},
{label: 'Local Version', width: 'w-1/4'},
{label: 'Remote Version', width: 'w-1/4'},
{label: 'Resolution', width: 'w-1/4'}
],
[change.conflict_details.conflicting_parameters[0]], // There's only one parameter for modify/delete
({parameter, local_value, incoming_value}) => (
<tr key={parameter} className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>File</td>
<td className='px-4 py-2.5 text-gray-300'>
{local_value === 'deleted' ? 'Deleted' : 'Present'}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{incoming_value === 'deleted' ? 'Deleted' : 'Present'}
</td>
<td className='px-4 py-2.5'>
<select
value={conflictResolutions['file'] || ''}
onChange={e =>
handleResolutionChange('file', e.target.value)
}
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
<option value='' disabled>
Select
</option>
<option value='local'>
{change.deleted_in_head
? 'Keep Deleted'
: 'Keep File'}
</option>
<option value='incoming'>
{change.deleted_in_head
? 'Restore File'
: 'Delete File'}
</option>
</select>
</td>
</tr>
)
);
};
const areAllConflictsResolved = () => {
if (!isMergeConflict) return true;
// For modify/delete conflicts, only need to resolve the file status
if (change.status === 'MODIFY_DELETE') {
return !!conflictResolutions['file'];
}
const requiredResolutions = [];
// Basic fields
@@ -385,9 +438,17 @@ const ResolveConflicts = ({
title={titleContent}
width='5xl'>
<div className='space-y-4'>
{renderBasicFields()}
{renderCustomFormatConflicts()}
{renderTagConflicts()}
{change.status === 'MODIFY_DELETE' ? (
// For modify/delete conflicts, only show the file status
renderModifyDeleteConflict()
) : (
// For regular conflicts, show all the existing sections
<>
{renderBasicFields()}
{renderCustomFormatConflicts()}
{renderTagConflicts()}
</>
)}
{isMergeConflict && (
<div className='flex justify-end'>

View File

@@ -93,6 +93,28 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
);
};
const formatValue = value => {
if (value === null || value === undefined) return '-';
if (Array.isArray(value)) {
return value
.map(item => {
if (
typeof item === 'object' &&
item.id !== undefined &&
item.score !== undefined
) {
return `Format ${item.id}: ${item.score}`;
}
return String(item);
})
.join(', ');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
};
const renderChanges = () => {
const isNewFile = change.status === 'New';
@@ -157,15 +179,15 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
</td>
{isNewFile ? (
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
{formatValue(value)}
</td>
) : (
<>
<td className='px-4 py-2.5 text-gray-300'>
{from ?? '-'}
{formatValue(from)}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
{formatValue(to ?? value)}
</td>
</>
)}

View File

@@ -0,0 +1,295 @@
import React, {useState, useEffect} from 'react';
import {
GitBranch,
GitCommit,
ExternalLink,
ArrowUpRight,
ArrowDownRight,
GitMerge,
Loader,
User,
Clock,
Hash
} from 'lucide-react';
import Modal from '../../../ui/Modal';
import {getCommitHistory} from '../../../../api/api';
import Alert from '../../../ui/Alert';
const ViewCommits = ({isOpen, onClose, repoUrl, currentBranch}) => {
const [selectedCommit, setSelectedCommit] = useState(null);
const [loading, setLoading] = useState(true);
const [commits, setCommits] = useState([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
useEffect(() => {
if (isOpen) {
fetchCommitHistory();
}
}, [isOpen, currentBranch]);
const fetchCommitHistory = async () => {
setLoading(true);
try {
const response = await getCommitHistory(currentBranch);
if (response.success) {
setCommits(response.data.local_commits);
setAheadCount(response.data.ahead_count);
setBehindCount(response.data.behind_count);
} else {
Alert.error(response.error || 'Failed to fetch commit history');
}
} catch (error) {
Alert.error('Failed to fetch commit history');
} finally {
setLoading(false);
}
};
const formatDate = dateStr => {
const date = new Date(dateStr);
return {
date: date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}),
time: date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
};
};
const getCommitStatus = commit => {
if (commit.isMerge) {
return {
icon: <GitMerge size={14} className='text-purple-500' />,
text: 'Merge'
};
}
const isAheadCommit =
aheadCount > 0 && commits.indexOf(commit) < aheadCount;
if (isAheadCommit) {
return {
icon: <ArrowUpRight size={14} className='text-green-500' />,
text: 'Outgoing'
};
}
return {
icon: <GitCommit size={14} className='text-gray-400' />,
text: 'Synced'
};
};
const renderContent = () => {
if (loading) {
return (
<div className='flex items-center justify-center h-40'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
);
}
if (commits.length === 0) {
return (
<div className='flex flex-col items-center justify-center h-40 text-gray-400'>
<GitCommit size={24} className='mb-2' />
<p className='text-sm'>No commits found in this branch</p>
</div>
);
}
return (
<div className='space-y-2'>
{commits.map(commit => {
const status = getCommitStatus(commit);
const formattedDate = formatDate(commit.date);
const isExpanded = selectedCommit === commit.hash;
return (
<div
key={commit.hash}
className={`p-3 rounded-lg border ${
isExpanded
? 'border-blue-500 dark:bg-gray-700'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
} transition-colors cursor-pointer`}
onClick={() => setSelectedCommit(commit.hash)}>
<div>
{/* Top section: Message and Hash */}
<div
className={`flex justify-between items-center ${
isExpanded ? 'mb-3' : ''
}`}>
<div className='flex items-center space-x-3 flex-1'>
{status.icon}
<span className='text-xs font-medium font-mono truncate'>
{commit.message}
</span>
</div>
<div className='flex items-center space-x-1.5 text-xs text-gray-400 ml-4'>
<Hash
size={12}
className='text-gray-500'
/>
<span className='font-mono'>
{commit.hash.substring(0, 7)}
</span>
</div>
</div>
{/* Details section */}
{isExpanded && (
<>
<div className='w-full border-t border-gray-700 mb-3'></div>
<div className='mb-3 text-sm text-gray-400 py-3'>
<div className='grid grid-cols-2 gap-4 text-xs'>
<div>
<div className='font-semibold mb-1'>
Files Changed (
{
commit.details
.files_changed
.length
}
):
</div>
<ul className='list-disc list-inside'>
{commit.details.files_changed.map(
(file, idx) => (
<li
key={idx}
className='truncate font-mono'>
{file}
</li>
)
)}
</ul>
</div>
<div>
<div className='font-semibold mb-1'>
Changes:
</div>
{commit.details.insertions >
0 && (
<div className='text-green-400'>
+
{
commit.details
.insertions
}{' '}
lines added
</div>
)}
{commit.details.deletions >
0 && (
<div className='text-red-400'>
-
{
commit.details
.deletions
}{' '}
lines removed
</div>
)}
{commit.details
.insertions === 0 &&
commit.details
.deletions ===
0 && (
<div className='text-gray-400'>
No line changes
detected
</div>
)}
</div>
</div>
</div>
<div className='w-full border-t border-gray-700 mb-3'></div>
</>
)}
{/* Bottom section: Author and date */}
<div className='flex justify-between items-center text-xs text-gray-400'>
<div className='flex items-center space-x-1.5'>
<User
size={12}
className='text-gray-500'
/>
<span className='truncate'>
{commit.author}
</span>
</div>
<div className='flex items-center space-x-1.5'>
<Clock
size={12}
className='text-gray-500'
/>
<span>
{formattedDate.date} at{' '}
{formattedDate.time}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={
<div className='flex items-center justify-between w-full'>
<div className='flex items-center space-x-2'>
<GitBranch size={16} className='text-blue-400' />
<span className='text-base'>
Commit History - {currentBranch}
</span>
</div>
{(aheadCount > 0 || behindCount > 0) && (
<div className='flex items-center space-x-3 text-sm px-2'>
{aheadCount > 0 && (
<div className='flex items-center text-green-400 bg-green-400/10 px-2 py-1 rounded'>
<ArrowUpRight size={14} className='mr-1' />
<span>
{aheadCount} commit
{aheadCount !== 1 ? 's' : ''} ahead
</span>
</div>
)}
{behindCount > 0 && (
<div className='flex items-center text-blue-400 bg-blue-400/10 px-2 py-1 rounded'>
<ArrowDownRight
size={14}
className='mr-1'
/>
<span>
{behindCount} commit
{behindCount !== 1 ? 's' : ''} behind
</span>
</div>
)}
</div>
)}
</div>
}
width='screen-xl'
height='lg'>
<div className='space-y-4'>
<div className='overflow-y-auto max-h-[60vh]'>
{renderContent()}
</div>
</div>
</Modal>
);
};
export default ViewCommits;