From b039b745bebe6e7903fb4bb7a192c78def4ac63f Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:37:13 -0500 Subject: [PATCH] feat: add discard changes functionality for worktrees - Introduced a new POST /discard-changes endpoint to discard all uncommitted changes in a worktree, including resetting staged changes, discarding modifications to tracked files, and removing untracked files. - Implemented a corresponding handler in the UI to confirm and execute the discard operation, enhancing user control over worktree changes. - Added a ViewWorktreeChangesDialog component to display changes in the worktree, improving the user experience for managing worktree states. - Updated the WorktreePanel and WorktreeActionsDropdown components to integrate the new functionality, allowing users to view and discard changes directly from the UI. This update streamlines the management of worktree changes, providing users with essential tools for version control. --- apps/server/src/routes/worktree/index.ts | 9 ++ .../routes/worktree/routes/discard-changes.ts | 112 ++++++++++++++++++ .../views/board-view/dialogs/index.ts | 1 + .../dialogs/view-worktree-changes-dialog.tsx | 68 +++++++++++ .../components/worktree-actions-dropdown.tsx | 36 +++++- .../components/worktree-tab.tsx | 6 + .../worktree-panel/worktree-panel.tsx | 103 ++++++++++++++++ apps/ui/src/lib/electron.ts | 14 +++ apps/ui/src/lib/http-api-client.ts | 2 + apps/ui/src/types/electron.d.ts | 13 ++ 10 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/routes/worktree/routes/discard-changes.ts create mode 100644 apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 854e5c60..d4358b65 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -48,6 +48,7 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import { createDiscardChangesHandler } from './routes/discard-changes.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -148,5 +149,13 @@ export function createWorktreeRoutes( createRunInitScriptHandler(events) ); + // Discard changes route + router.post( + '/discard-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createDiscardChangesHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts new file mode 100644 index 00000000..4f15e053 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -0,0 +1,112 @@ +/** + * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree + * + * This performs a destructive operation that: + * 1. Resets staged changes (git reset HEAD) + * 2. Discards modified tracked files (git checkout .) + * 3. Removes untracked files and directories (git clean -fd) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createDiscardChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Check for uncommitted changes first + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (!status.trim()) { + res.json({ + success: true, + result: { + discarded: false, + message: 'No changes to discard', + }, + }); + return; + } + + // Count the files that will be affected + const lines = status.trim().split('\n').filter(Boolean); + const fileCount = lines.length; + + // Get branch name before discarding + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const branchName = branchOutput.trim(); + + // Discard all changes: + // 1. Reset any staged changes + await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there's nothing staged + }); + + // 2. Discard changes in tracked files + await execAsync('git checkout .', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no tracked changes + }); + + // 3. Remove untracked files and directories + await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no untracked files + }); + + // Verify all changes were discarded + const { stdout: finalStatus } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (finalStatus.trim()) { + // Some changes couldn't be discarded (possibly ignored files or permission issues) + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + }, + }); + } + } catch (error) { + logError(error, 'Discard changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 659f4d7e..84027daf 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog'; export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog'; export { MassEditDialog } from './mass-edit-dialog'; +export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog'; diff --git a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx new file mode 100644 index 00000000..1b49b23d --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx @@ -0,0 +1,68 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { FileText } from 'lucide-react'; +import { GitDiffPanel } from '@/components/ui/git-diff-panel'; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface ViewWorktreeChangesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + projectPath: string; +} + +export function ViewWorktreeChangesDialog({ + open, + onOpenChange, + worktree, + projectPath, +}: ViewWorktreeChangesDialogProps) { + if (!worktree) return null; + + return ( + + + + + + View Changes + + + Changes in the{' '} + {worktree.branch} worktree. + {worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && ( + + ({worktree.changedFilesCount} file + {worktree.changedFilesCount > 1 ? 's' : ''} changed) + + )} + + + +
+
+ +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 97c6ecc5..f33ceba8 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -25,11 +25,13 @@ import { AlertCircle, RefreshCw, Copy, + Eye, ScrollText, Terminal, SquarePlus, SplitSquareHorizontal, Zap, + Undo2, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -65,6 +67,8 @@ interface WorktreeActionsDropdownProps { onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -99,6 +103,8 @@ export function WorktreeActionsDropdown({ onOpenInEditor, onOpenInIntegratedTerminal, onOpenInExternalTerminal, + onViewChanges, + onDiscardChanges, onCommit, onCreatePR, onAddressPRComments, @@ -434,6 +440,13 @@ export function WorktreeActionsDropdown({ )} + + {worktree.hasChanges && ( + onViewChanges(worktree)} className="text-xs"> + + View Changes + + )} {worktree.hasChanges && ( )} + + {worktree.hasChanges && ( + + gitRepoStatus.isGitRepo && onDiscardChanges(worktree)} + disabled={!gitRepoStatus.isGitRepo} + className={cn( + 'text-xs text-destructive focus:text-destructive', + !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed' + )} + > + + Discard Changes + {!gitRepoStatus.isGitRepo && ( + + )} + + + )} {!worktree.isMain && ( <> - onDeleteWorktree(worktree)} className="text-xs text-destructive focus:text-destructive" diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index accc5799..6c05bf8c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -42,6 +42,8 @@ interface WorktreeTabProps { onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -90,6 +92,8 @@ export function WorktreeTab({ onOpenInEditor, onOpenInIntegratedTerminal, onOpenInExternalTerminal, + onViewChanges, + onDiscardChanges, onCommit, onCreatePR, onAddressPRComments, @@ -375,6 +379,8 @@ export function WorktreeTab({ onOpenInEditor={onOpenInEditor} onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} onOpenInExternalTerminal={onOpenInExternalTerminal} + onViewChanges={onViewChanges} + onDiscardChanges={onDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index a79bf621..0214092c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -22,6 +22,9 @@ import { BranchSwitchDropdown, } from './components'; import { useAppStore } from '@/store/app-store'; +import { ViewWorktreeChangesDialog } from '../dialogs'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { Undo2 } from 'lucide-react'; export function WorktreePanel({ projectPath, @@ -156,6 +159,14 @@ export function WorktreePanel({ // Track whether init script exists for the project const [hasInitScript, setHasInitScript] = useState(false); + // View changes dialog state + const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false); + const [viewChangesWorktree, setViewChangesWorktree] = useState(null); + + // Discard changes confirmation dialog state + const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false); + const [discardChangesWorktree, setDiscardChangesWorktree] = useState(null); + // Log panel state management const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelWorktree, setLogPanelWorktree] = useState(null); @@ -242,6 +253,41 @@ export function WorktreePanel({ [projectPath] ); + const handleViewChanges = useCallback((worktree: WorktreeInfo) => { + setViewChangesWorktree(worktree); + setViewChangesDialogOpen(true); + }, []); + + const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => { + setDiscardChangesWorktree(worktree); + setDiscardChangesDialogOpen(true); + }, []); + + const handleConfirmDiscardChanges = useCallback(async () => { + if (!discardChangesWorktree) return; + + try { + const api = getHttpApiClient(); + const result = await api.worktree.discardChanges(discardChangesWorktree.path); + + if (result.success) { + toast.success('Changes discarded', { + description: `Discarded changes in ${discardChangesWorktree.branch}`, + }); + // Refresh worktrees to update the changes status + fetchWorktrees({ silent: true }); + } else { + toast.error('Failed to discard changes', { + description: result.error || 'Unknown error', + }); + } + } catch (error) { + toast.error('Failed to discard changes', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [discardChangesWorktree, fetchWorktrees]); + // Handle opening the log panel for a specific worktree const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => { setLogPanelWorktree(worktree); @@ -312,6 +358,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -357,6 +405,36 @@ export function WorktreePanel({ )} + + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + + {/* Dev Server Logs Panel */} + ); } @@ -403,6 +481,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -465,6 +545,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -511,6 +593,27 @@ export function WorktreePanel({ )} + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + {/* Dev Server Logs Panel */} { + console.log('[Mock] Discarding changes:', { worktreePath }); + return { + success: true, + result: { + discarded: true, + filesDiscarded: 0, + filesRemaining: 0, + branch: 'main', + message: 'Mock: Changes discarded successfully', + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 149c5532..e6292bd7 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1851,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI { this.httpDelete('/api/worktree/init-script', { projectPath }), runInitScript: (projectPath: string, worktreePath: string, branch: string) => this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), + discardChanges: (worktreePath: string) => + this.post('/api/worktree/discard-changes', { worktreePath }), onInitScriptEvent: ( callback: (event: { type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index ebaf5f59..e01f3588 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -1218,6 +1218,19 @@ export interface WorktreeAPI { payload: unknown; }) => void ) => () => void; + + // Discard changes for a worktree + discardChanges: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + discarded: boolean; + filesDiscarded: number; + filesRemaining: number; + branch: string; + message: string; + }; + error?: string; + }>; } export interface GitAPI {