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 {