mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 09:27:22 +00:00
feat(git): add revert latest local commit action in git panel
Add a complete revert-local-commit flow so users can undo the most recent local commit directly from the Git header, placed before the refresh icon. Backend - add POST /api/git/revert-local-commit endpoint in server/routes/git.js - validate project input and repository state before executing git operations - revert latest commit with `git reset --soft HEAD~1` to keep changes staged - handle initial-commit edge case by deleting HEAD ref when no parent exists - return clear success and error responses for UI consumption Frontend - add useRevertLocalCommit hook to encapsulate API call and loading state - wire hook into GitPanel and refresh git data after successful revert - add new toolbar action in GitPanelHeader before refresh icon - route action through existing confirmation modal flow - disable action while request is in flight and show activity indicator Shared UI and typing updates - extend ConfirmActionType with `revertLocalCommit` - add confirmation title, label, and style mappings for new action - render RotateCcw icon for revert action in ConfirmActionModal Result - users can safely undo the latest local commit from the UI - reverted commit changes remain staged for immediate recommit/edit workflows
This commit is contained in:
@@ -420,6 +420,53 @@ router.post('/commit', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Revert latest local commit (keeps changes staged)
|
||||
router.post('/revert-local-commit', 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);
|
||||
|
||||
try {
|
||||
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No local commit to revert',
|
||||
details: 'This repository has no commit yet.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Soft reset rewinds one commit while preserving all file changes in the index.
|
||||
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
|
||||
} catch (error) {
|
||||
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
|
||||
const isInitialCommit = errorDetails.includes('HEAD~1') &&
|
||||
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
|
||||
|
||||
if (!isInitialCommit) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
|
||||
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: 'Latest local commit reverted successfully. Changes were kept staged.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git revert local commit error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of branches
|
||||
router.get('/branches', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
|
||||
pull: 'Confirm Pull',
|
||||
push: 'Confirm Push',
|
||||
publish: 'Publish Branch',
|
||||
revertLocalCommit: 'Revert Local Commit',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||
pull: 'Pull',
|
||||
push: 'Push',
|
||||
publish: 'Publish',
|
||||
revertLocalCommit: 'Revert Commit',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
pull: 'bg-green-600 hover:bg-green-700',
|
||||
push: 'bg-orange-600 hover:bg-orange-700',
|
||||
publish: 'bg-purple-600 hover:bg-purple-700',
|
||||
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
|
||||
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
push: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
pull: 'text-yellow-600 dark:text-yellow-400',
|
||||
push: 'text-yellow-600 dark:text-yellow-400',
|
||||
publish: 'text-yellow-600 dark:text-yellow-400',
|
||||
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
|
||||
};
|
||||
|
||||
48
src/components/git-panel/hooks/useRevertLocalCommit.ts
Normal file
48
src/components/git-panel/hooks/useRevertLocalCommit.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { GitOperationResponse } from '../types/types';
|
||||
|
||||
type UseRevertLocalCommitOptions = {
|
||||
projectName: string | null;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
|
||||
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
|
||||
|
||||
const revertLatestLocalCommit = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRevertingLocalCommit(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/revert-local-commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project: projectName }),
|
||||
});
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
|
||||
if (!data.success) {
|
||||
console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Error reverting local commit:', error);
|
||||
} finally {
|
||||
setIsRevertingLocalCommit(false);
|
||||
}
|
||||
}, [onSuccess, projectName]);
|
||||
|
||||
return {
|
||||
isRevertingLocalCommit,
|
||||
revertLatestLocalCommit,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Project } from '../../../types/app';
|
||||
export type GitPanelView = 'changes' | 'history';
|
||||
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
||||
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
|
||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
|
||||
|
||||
export type FileDiffInfo = {
|
||||
old_string: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useGitPanelController } from '../hooks/useGitPanelController';
|
||||
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
|
||||
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
||||
import ChangesView from '../view/changes/ChangesView';
|
||||
import HistoryView from '../view/history/HistoryView';
|
||||
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
onFileOpen,
|
||||
});
|
||||
|
||||
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
|
||||
projectName: selectedProject?.name ?? null,
|
||||
onSuccess: refreshAll,
|
||||
});
|
||||
|
||||
const executeConfirmedAction = useCallback(async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isPublishing={isPublishing}
|
||||
isRevertingLocalCommit={isRevertingLocalCommit}
|
||||
onRefresh={refreshAll}
|
||||
onRevertLocalCommit={revertLatestLocalCommit}
|
||||
onSwitchBranch={switchBranch}
|
||||
onCreateBranch={createBranch}
|
||||
onFetch={handleFetch}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
|
||||
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||
import NewBranchModal from './modals/NewBranchModal';
|
||||
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isPublishing: boolean;
|
||||
isRevertingLocalCommit: boolean;
|
||||
onRefresh: () => void;
|
||||
onRevertLocalCommit: () => Promise<void>;
|
||||
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||
onFetch: () => Promise<void>;
|
||||
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
|
||||
isPulling,
|
||||
isPushing,
|
||||
isPublishing,
|
||||
isRevertingLocalCommit,
|
||||
onRefresh,
|
||||
onRevertLocalCommit,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
onFetch,
|
||||
@@ -88,6 +92,14 @@ export default function GitPanelHeader({
|
||||
});
|
||||
};
|
||||
|
||||
const requestRevertLocalCommitConfirmation = () => {
|
||||
onRequestConfirmation({
|
||||
type: 'revertLocalCommit',
|
||||
message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',
|
||||
onConfirm: onRevertLocalCommit,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwitchBranch = async (branchName: string) => {
|
||||
try {
|
||||
const success = await onSwitchBranch(branchName);
|
||||
@@ -240,6 +252,17 @@ export default function GitPanelHeader({
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={requestRevertLocalCommitConfirmation}
|
||||
disabled={isRevertingLocalCommit}
|
||||
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||
title="Revert latest local commit"
|
||||
>
|
||||
<RotateCcw
|
||||
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check, Download, Trash2, Upload } from 'lucide-react';
|
||||
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||
import {
|
||||
CONFIRMATION_ACTION_LABELS,
|
||||
CONFIRMATION_BUTTON_CLASSES,
|
||||
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
|
||||
return <Download className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (actionType === 'revertLocalCommit') {
|
||||
return <RotateCcw className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <Upload className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user