| import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; |
| import { |
| Dialog, |
| DialogContent, |
| DialogDescription, |
| DialogFooter, |
| DialogHeader, |
| DialogTitle, |
| } from '@/components/ui/dialog'; |
| import { Button } from '@/components/ui/button'; |
| import { Textarea } from '@/components/ui/textarea'; |
| import { Label } from '@/components/ui/label'; |
| import { Checkbox } from '@/components/ui/checkbox'; |
| import { |
| Select, |
| SelectContent, |
| SelectItem, |
| SelectTrigger, |
| SelectValue, |
| } from '@/components/ui/select'; |
| import { |
| GitCommit, |
| GitMerge, |
| Sparkles, |
| FilePlus, |
| FileX, |
| FilePen, |
| FileText, |
| File, |
| ChevronDown, |
| ChevronRight, |
| Upload, |
| RefreshCw, |
| } from 'lucide-react'; |
| import { Spinner } from '@/components/ui/spinner'; |
| import { getElectronAPI } from '@/lib/electron'; |
| import { getHttpApiClient } from '@/lib/http-api-client'; |
| import { toast } from 'sonner'; |
| import { useAppStore } from '@/store/app-store'; |
| import { resolveModelString } from '@automaker/model-resolver'; |
| import { cn } from '@/lib/utils'; |
| import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; |
| import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; |
| import type { FileStatus, MergeStateInfo } from '@/types/electron'; |
| import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; |
|
|
| interface RemoteInfo { |
| name: string; |
| url: string; |
| } |
|
|
| interface WorktreeInfo { |
| path: string; |
| branch: string; |
| isMain: boolean; |
| hasChanges?: boolean; |
| changedFilesCount?: number; |
| } |
|
|
| interface CommitWorktreeDialogProps { |
| open: boolean; |
| onOpenChange: (open: boolean) => void; |
| worktree: WorktreeInfo | null; |
| onCommitted: () => void; |
| } |
|
|
| const getFileIcon = (status: string) => { |
| switch (status) { |
| case 'A': |
| case '?': |
| return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />; |
| case 'D': |
| return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />; |
| case 'M': |
| case 'U': |
| return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />; |
| case 'R': |
| case 'C': |
| return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />; |
| default: |
| return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />; |
| } |
| }; |
|
|
| const getStatusLabel = (status: string) => { |
| switch (status) { |
| case 'A': |
| return 'Added'; |
| case '?': |
| return 'Untracked'; |
| case 'D': |
| return 'Deleted'; |
| case 'M': |
| return 'Modified'; |
| case 'U': |
| return 'Updated'; |
| case 'R': |
| return 'Renamed'; |
| case 'C': |
| return 'Copied'; |
| default: |
| return 'Changed'; |
| } |
| }; |
|
|
| const getStatusBadgeColor = (status: string) => { |
| switch (status) { |
| case 'A': |
| case '?': |
| return 'bg-green-500/20 text-green-400 border-green-500/30'; |
| case 'D': |
| return 'bg-red-500/20 text-red-400 border-red-500/30'; |
| case 'M': |
| case 'U': |
| return 'bg-amber-500/20 text-amber-400 border-amber-500/30'; |
| case 'R': |
| case 'C': |
| return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; |
| default: |
| return 'bg-muted text-muted-foreground border-border'; |
| } |
| }; |
|
|
| const getMergeTypeLabel = (mergeType?: string) => { |
| switch (mergeType) { |
| case 'both-modified': |
| return 'Both Modified'; |
| case 'added-by-us': |
| return 'Added by Us'; |
| case 'added-by-them': |
| return 'Added by Them'; |
| case 'deleted-by-us': |
| return 'Deleted by Us'; |
| case 'deleted-by-them': |
| return 'Deleted by Them'; |
| case 'both-added': |
| return 'Both Added'; |
| case 'both-deleted': |
| return 'Both Deleted'; |
| default: |
| return 'Merge'; |
| } |
| }; |
|
|
| function DiffLine({ |
| type, |
| content, |
| lineNumber, |
| }: { |
| type: 'context' | 'addition' | 'deletion' | 'header'; |
| content: string; |
| lineNumber?: { old?: number; new?: number }; |
| }) { |
| const bgClass = { |
| context: 'bg-transparent', |
| addition: 'bg-green-500/10', |
| deletion: 'bg-red-500/10', |
| header: 'bg-blue-500/10', |
| }; |
|
|
| const textClass = { |
| context: 'text-foreground-secondary', |
| addition: 'text-green-400', |
| deletion: 'text-red-400', |
| header: 'text-blue-400', |
| }; |
|
|
| const prefix = { |
| context: ' ', |
| addition: '+', |
| deletion: '-', |
| header: '', |
| }; |
|
|
| if (type === 'header') { |
| return ( |
| <div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}> |
| {content} |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className={cn('flex font-mono text-xs', bgClass[type])}> |
| <span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]"> |
| {lineNumber?.old ?? ''} |
| </span> |
| <span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]"> |
| {lineNumber?.new ?? ''} |
| </span> |
| <span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}> |
| {prefix[type]} |
| </span> |
| <span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}> |
| {content || '\u00A0'} |
| </span> |
| </div> |
| ); |
| } |
|
|
| export function CommitWorktreeDialog({ |
| open, |
| onOpenChange, |
| worktree, |
| onCommitted, |
| }: CommitWorktreeDialogProps) { |
| const [message, setMessage] = useState(''); |
| const [isLoading, setIsLoading] = useState(false); |
| const [isGenerating, setIsGenerating] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
| const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages); |
|
|
| |
| const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' }); |
| const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } = |
| commitModelOverride; |
|
|
| |
| const [files, setFiles] = useState<FileStatus[]>([]); |
| const [diffContent, setDiffContent] = useState(''); |
| const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); |
| const [expandedFile, setExpandedFile] = useState<string | null>(null); |
| const [isLoadingDiffs, setIsLoadingDiffs] = useState(false); |
| const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined); |
|
|
| |
| const [pushAfterCommit, setPushAfterCommit] = useState(false); |
| const [remotes, setRemotes] = useState<RemoteInfo[]>([]); |
| const [selectedRemote, setSelectedRemote] = useState<string>(''); |
| const [isLoadingRemotes, setIsLoadingRemotes] = useState(false); |
| const [isPushing, setIsPushing] = useState(false); |
| const [remotesFetched, setRemotesFetched] = useState(false); |
| const [remotesFetchError, setRemotesFetchError] = useState<string | null>(null); |
| |
| const [commitSucceeded, setCommitSucceeded] = useState(false); |
|
|
| |
| const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]); |
|
|
| |
| const diffsByFile = useMemo(() => { |
| const map = new Map<string, ParsedFileDiff>(); |
| for (const diff of parsedDiffs) { |
| map.set(diff.filePath, diff); |
| } |
| return map; |
| }, [parsedDiffs]); |
|
|
| |
| const fetchRemotesForWorktree = useCallback( |
| async (worktreePath: string, signal?: { cancelled: boolean }) => { |
| setIsLoadingRemotes(true); |
| setRemotesFetchError(null); |
| try { |
| const api = getElectronAPI(); |
| if (api?.worktree?.listRemotes) { |
| const result = await api.worktree.listRemotes(worktreePath); |
| if (signal?.cancelled) return; |
| setRemotesFetched(true); |
| if (result.success && result.result) { |
| const remoteInfos = result.result.remotes.map((r) => ({ |
| name: r.name, |
| url: r.url, |
| })); |
| setRemotes(remoteInfos); |
| |
| if (remoteInfos.length > 0) { |
| const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0]; |
| setSelectedRemote(defaultRemote.name); |
| } |
| } |
| } else { |
| |
| |
| setRemotesFetchError('Remote listing not available'); |
| setRemotesFetched(true); |
| return; |
| } |
| } catch (err) { |
| if (signal?.cancelled) return; |
| |
| setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes'); |
| console.warn('Failed to fetch remotes:', err); |
| } finally { |
| if (!signal?.cancelled) setIsLoadingRemotes(false); |
| } |
| }, |
| [] |
| ); |
|
|
| useEffect(() => { |
| if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) { |
| const signal = { cancelled: false }; |
| fetchRemotesForWorktree(worktree.path, signal); |
| return () => { |
| signal.cancelled = true; |
| }; |
| } |
| }, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]); |
|
|
| |
| useEffect(() => { |
| if (open && worktree) { |
| setIsLoadingDiffs(true); |
| setFiles([]); |
| setDiffContent(''); |
| setSelectedFiles(new Set()); |
| setExpandedFile(null); |
| setMergeState(undefined); |
| |
| setPushAfterCommit(false); |
| setRemotes([]); |
| setSelectedRemote(''); |
| setIsPushing(false); |
| setRemotesFetched(false); |
| setRemotesFetchError(null); |
| setCommitSucceeded(false); |
|
|
| let cancelled = false; |
|
|
| const loadDiffs = async () => { |
| try { |
| const api = getElectronAPI(); |
| if (api?.git?.getDiffs) { |
| const result = await api.git.getDiffs(worktree.path); |
| if (result.success) { |
| const fileList = result.files ?? []; |
| |
| if (result.mergeState?.isMerging) { |
| const mergeSet = new Set(result.mergeState.mergeAffectedFiles); |
| fileList.sort((a, b) => { |
| const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false); |
| const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false); |
| if (aIsMerge && !bIsMerge) return -1; |
| if (!aIsMerge && bIsMerge) return 1; |
| return 0; |
| }); |
| } |
| if (!cancelled) setFiles(fileList); |
| if (!cancelled) setDiffContent(result.diff ?? ''); |
| if (!cancelled) setMergeState(result.mergeState); |
| |
| |
| const stagedFiles = fileList.filter((f) => { |
| const idx = f.indexStatus ?? ' '; |
| return idx !== ' ' && idx !== '?'; |
| }); |
| if (!cancelled) { |
| if (stagedFiles.length > 0) { |
| |
| setSelectedFiles(new Set(stagedFiles.map((f) => f.path))); |
| } else { |
| setSelectedFiles(new Set(fileList.map((f) => f.path))); |
| } |
| } |
| } else { |
| const errorMsg = result.error ?? 'Failed to load diffs'; |
| console.warn('Failed to load diffs for commit dialog:', errorMsg); |
| if (!cancelled) { |
| setError(errorMsg); |
| toast.error(errorMsg); |
| } |
| } |
| } |
| } catch (err) { |
| console.error('Failed to load diffs for commit dialog:', err); |
| if (!cancelled) { |
| const errorMsg = err instanceof Error ? err.message : 'Failed to load diffs'; |
| setError(errorMsg); |
| toast.error(errorMsg); |
| } |
| } finally { |
| if (!cancelled) setIsLoadingDiffs(false); |
| } |
| }; |
|
|
| loadDiffs(); |
|
|
| return () => { |
| cancelled = true; |
| }; |
| } |
| }, [open, worktree]); |
|
|
| const handleToggleFile = useCallback((filePath: string) => { |
| setSelectedFiles((prev) => { |
| const next = new Set(prev); |
| if (next.has(filePath)) { |
| next.delete(filePath); |
| } else { |
| next.add(filePath); |
| } |
| return next; |
| }); |
| }, []); |
|
|
| const handleToggleAll = useCallback(() => { |
| setSelectedFiles((prev) => { |
| if (prev.size === files.length) { |
| return new Set(); |
| } |
| return new Set(files.map((f) => f.path)); |
| }); |
| }, [files]); |
|
|
| const handleFileClick = useCallback((filePath: string) => { |
| setExpandedFile((prev) => (prev === filePath ? null : filePath)); |
| }, []); |
|
|
| |
| const performPush = async ( |
| api: ReturnType<typeof getElectronAPI>, |
| worktreePath: string, |
| remoteName: string |
| ): Promise<boolean> => { |
| if (!api?.worktree?.push) { |
| toast.error('Push API not available'); |
| return false; |
| } |
| setIsPushing(true); |
| try { |
| const pushResult = await api.worktree.push(worktreePath, false, remoteName); |
| if (pushResult.success && pushResult.result) { |
| toast.success('Pushed to remote', { |
| description: pushResult.result.message, |
| }); |
| return true; |
| } else { |
| toast.error(pushResult.error || 'Failed to push to remote'); |
| return false; |
| } |
| } catch (pushErr) { |
| toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote'); |
| return false; |
| } finally { |
| setIsPushing(false); |
| } |
| }; |
|
|
| const handleCommit = async () => { |
| if (!worktree) return; |
|
|
| const api = getElectronAPI(); |
|
|
| |
| if (commitSucceeded) { |
| if (pushAfterCommit && selectedRemote) { |
| const ok = await performPush(api, worktree.path, selectedRemote); |
| if (ok) { |
| onCommitted(); |
| onOpenChange(false); |
| setMessage(''); |
| } |
| } else { |
| onCommitted(); |
| onOpenChange(false); |
| setMessage(''); |
| } |
| return; |
| } |
|
|
| if (!message.trim() || selectedFiles.size === 0) return; |
|
|
| setIsLoading(true); |
| setError(null); |
|
|
| try { |
| if (!api?.worktree?.commit) { |
| setError('Worktree API not available'); |
| return; |
| } |
|
|
| |
| const filesToCommit = |
| selectedFiles.size === files.length ? undefined : Array.from(selectedFiles); |
|
|
| const result = await api.worktree.commit(worktree.path, message, filesToCommit); |
|
|
| if (result.success && result.result) { |
| if (result.result.committed) { |
| setCommitSucceeded(true); |
| toast.success('Changes committed', { |
| description: `Commit ${result.result.commitHash} on ${result.result.branch}`, |
| }); |
|
|
| |
| let pushSucceeded = false; |
| if (pushAfterCommit && selectedRemote) { |
| pushSucceeded = await performPush(api, worktree.path, selectedRemote); |
| } |
|
|
| |
| |
| if (!pushAfterCommit || pushSucceeded) { |
| onCommitted(); |
| onOpenChange(false); |
| setMessage(''); |
| } else { |
| |
| onCommitted(); |
| } |
| } else { |
| toast.info('No changes to commit', { |
| description: result.result.message, |
| }); |
| } |
| } else { |
| setError(result.error || 'Failed to commit changes'); |
| } |
| } catch (err) { |
| setError(err instanceof Error ? err.message : 'Failed to commit'); |
| } finally { |
| setIsLoading(false); |
| } |
| }; |
|
|
| |
| |
| const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing; |
|
|
| const handleKeyDown = (e: React.KeyboardEvent) => { |
| if ( |
| e.key === 'Enter' && |
| (e.metaKey || e.ctrlKey) && |
| !isLoading && |
| !isPushing && |
| !isGenerating |
| ) { |
| if (isPushRetry) { |
| |
| if (selectedRemote) { |
| handleCommit(); |
| } |
| } else if ( |
| message.trim() && |
| selectedFiles.size > 0 && |
| !(pushAfterCommit && !selectedRemote) |
| ) { |
| handleCommit(); |
| } |
| } |
| }; |
|
|
| |
| const generateCommitMessage = useCallback(async () => { |
| if (!worktree) return; |
|
|
| setIsGenerating(true); |
| try { |
| const resolvedCommitModel = resolveModelString(commitEffectiveModel); |
| const api = getHttpApiClient(); |
| const result = await api.worktree.generateCommitMessage( |
| worktree.path, |
| resolvedCommitModel, |
| commitEffectiveModelEntry?.thinkingLevel, |
| commitEffectiveModelEntry?.providerId |
| ); |
|
|
| if (result.success && result.message) { |
| setMessage(result.message); |
| } else { |
| console.warn('Failed to generate commit message:', result.error); |
| toast.error('Failed to generate commit message', { |
| description: result.error || 'Unknown error', |
| }); |
| } |
| } catch (err) { |
| console.warn('Error generating commit message:', err); |
| toast.error('Failed to generate commit message', { |
| description: err instanceof Error ? err.message : 'Unknown error', |
| }); |
| } finally { |
| setIsGenerating(false); |
| } |
| }, [worktree, commitEffectiveModel, commitEffectiveModelEntry]); |
|
|
| |
| |
| const generateCommitMessageRef = useRef(generateCommitMessage); |
| useEffect(() => { |
| generateCommitMessageRef.current = generateCommitMessage; |
| }); |
|
|
| |
| useEffect(() => { |
| if (open && worktree) { |
| |
| setMessage(''); |
| setError(null); |
|
|
| if (!enableAiCommitMessages) { |
| return; |
| } |
|
|
| generateCommitMessageRef.current(); |
| } |
| }, [open, worktree, enableAiCommitMessages]); |
|
|
| if (!worktree) return null; |
|
|
| const allSelected = selectedFiles.size === files.length && files.length > 0; |
|
|
| |
| |
| |
| const handleOpenChange = (nextOpen: boolean) => { |
| if (!nextOpen && (isLoading || isPushing || isGenerating)) { |
| |
| return; |
| } |
| onOpenChange(nextOpen); |
| }; |
|
|
| return ( |
| <Dialog open={open} onOpenChange={handleOpenChange}> |
| <DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col"> |
| <DialogHeader> |
| <DialogTitle className="flex items-center gap-2"> |
| <GitCommit className="w-5 h-5" /> |
| Commit Changes |
| </DialogTitle> |
| <DialogDescription> |
| Commit changes in the{' '} |
| <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree. |
| </DialogDescription> |
| </DialogHeader> |
| |
| <div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden"> |
| {/* Merge state banner */} |
| {mergeState?.isMerging && ( |
| <div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20"> |
| <GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" /> |
| <div className="text-sm"> |
| <span className="font-medium text-purple-400"> |
| {mergeState.mergeOperationType === 'cherry-pick' |
| ? 'Cherry-pick' |
| : mergeState.mergeOperationType === 'rebase' |
| ? 'Rebase' |
| : 'Merge'}{' '} |
| in progress |
| </span> |
| {mergeState.conflictFiles.length > 0 ? ( |
| <span className="text-purple-400/80 ml-1"> |
| — {mergeState.conflictFiles.length} file |
| {mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts |
| </span> |
| ) : mergeState.isCleanMerge ? ( |
| <span className="text-purple-400/80 ml-1"> |
| — Clean merge, {mergeState.mergeAffectedFiles.length} file |
| {mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected |
| </span> |
| ) : null} |
| </div> |
| </div> |
| )} |
| |
| {/* File Selection */} |
| <div className="flex flex-col min-h-0"> |
| <div className="flex items-center justify-between mb-1.5"> |
| <Label className="text-sm font-medium flex items-center gap-2"> |
| Files to commit |
| {isLoadingDiffs ? ( |
| <Spinner size="sm" /> |
| ) : ( |
| <span className="text-xs text-muted-foreground font-normal"> |
| ({selectedFiles.size}/{files.length} selected) |
| </span> |
| )} |
| </Label> |
| {files.length > 0 && ( |
| <button |
| onClick={handleToggleAll} |
| className="text-xs text-muted-foreground hover:text-foreground transition-colors" |
| > |
| {allSelected ? 'Deselect all' : 'Select all'} |
| </button> |
| )} |
| </div> |
| |
| {isLoadingDiffs ? ( |
| <div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg"> |
| <Spinner size="sm" className="mr-2" /> |
| <span className="text-sm">Loading changes...</span> |
| </div> |
| ) : files.length === 0 ? ( |
| <div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg"> |
| <span className="text-sm">No changes detected</span> |
| </div> |
| ) : ( |
| <div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible"> |
| {files.map((file) => { |
| const isChecked = selectedFiles.has(file.path); |
| const isExpanded = expandedFile === file.path; |
| const fileDiff = diffsByFile.get(file.path); |
| const additions = fileDiff?.additions ?? 0; |
| const deletions = fileDiff?.deletions ?? 0; |
| // Determine staging state from index/worktree status |
| const idx = file.indexStatus ?? ' '; |
| const wt = file.workTreeStatus ?? ' '; |
| const isStaged = idx !== ' ' && idx !== '?'; |
| const isUnstaged = wt !== ' ' && wt !== '?'; |
| const isUntracked = idx === '?' && wt === '?'; |
| const isMergeFile = |
| file.isMergeAffected || |
| (mergeState?.mergeAffectedFiles?.includes(file.path) ?? false); |
| |
| return ( |
| <div |
| key={file.path} |
| className={cn( |
| 'border-b last:border-b-0', |
| isMergeFile ? 'border-purple-500/30' : 'border-border' |
| )} |
| > |
| <div |
| className={cn( |
| 'flex items-center gap-2 px-3 py-1.5 transition-colors group', |
| isMergeFile |
| ? 'bg-purple-500/5 hover:bg-purple-500/10' |
| : 'hover:bg-accent/50', |
| isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30') |
| )} |
| > |
| {/* Checkbox */} |
| <Checkbox |
| checked={isChecked} |
| onCheckedChange={() => handleToggleFile(file.path)} |
| className="flex-shrink-0" |
| /> |
| |
| {/* Clickable file row to show diff */} |
| <button |
| onClick={() => handleFileClick(file.path)} |
| className="flex items-center gap-2 flex-1 min-w-0 text-left" |
| > |
| {isExpanded ? ( |
| <ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" /> |
| ) : ( |
| <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" /> |
| )} |
| {isMergeFile ? ( |
| <GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" /> |
| ) : ( |
| getFileIcon(file.status) |
| )} |
| <TruncatedFilePath |
| path={file.path} |
| className="text-xs font-mono flex-1 text-foreground" |
| /> |
| {isMergeFile && ( |
| <span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5"> |
| <GitMerge className="w-2.5 h-2.5" /> |
| {getMergeTypeLabel(file.mergeType)} |
| </span> |
| )} |
| <span |
| className={cn( |
| 'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0', |
| getStatusBadgeColor(file.status) |
| )} |
| > |
| {getStatusLabel(file.status)} |
| </span> |
| {isStaged && !isUnstaged && !isUntracked && ( |
| <span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-green-500/15 text-green-400 border-green-500/30"> |
| Staged |
| </span> |
| )} |
| {isStaged && isUnstaged && ( |
| <span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-amber-500/15 text-amber-400 border-amber-500/30"> |
| Partial |
| </span> |
| )} |
| {additions > 0 && ( |
| <span className="text-[10px] text-green-400 flex-shrink-0"> |
| +{additions} |
| </span> |
| )} |
| {deletions > 0 && ( |
| <span className="text-[10px] text-red-400 flex-shrink-0"> |
| -{deletions} |
| </span> |
| )} |
| </button> |
| </div> |
| |
| {/* Expanded diff view */} |
| {isExpanded && fileDiff && ( |
| <div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible"> |
| {fileDiff.hunks.map((hunk, hunkIndex) => ( |
| <div |
| key={hunkIndex} |
| className="border-b border-border-glass last:border-b-0" |
| > |
| {hunk.lines.map((line, lineIndex) => ( |
| <DiffLine |
| key={lineIndex} |
| type={line.type} |
| content={line.content} |
| lineNumber={line.lineNumber} |
| /> |
| ))} |
| </div> |
| ))} |
| </div> |
| )} |
| {isExpanded && !fileDiff && ( |
| <div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border"> |
| {file.status === '?' ? ( |
| <span>New file - diff preview not available</span> |
| ) : file.status === 'D' ? ( |
| <span>File deleted</span> |
| ) : ( |
| <span>Diff content not available</span> |
| )} |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| |
| {/* Commit Message */} |
| <div className="grid gap-1.5"> |
| <div className="flex items-center justify-between"> |
| <Label htmlFor="commit-message" className="flex items-center gap-2"> |
| Commit Message |
| {isGenerating && ( |
| <span className="flex items-center gap-1 text-xs text-muted-foreground"> |
| <Sparkles className="w-3 h-3 animate-pulse" /> |
| Generating... |
| </span> |
| )} |
| </Label> |
| <div className="flex items-center gap-1"> |
| {enableAiCommitMessages && ( |
| <> |
| <Button |
| variant="ghost" |
| size="sm" |
| onClick={generateCommitMessage} |
| disabled={isGenerating || isLoading} |
| className="h-6 px-2 text-xs" |
| title="Regenerate commit message" |
| > |
| {isGenerating ? ( |
| <Spinner size="xs" className="mr-1" /> |
| ) : ( |
| <RefreshCw className="w-3 h-3 mr-1" /> |
| )} |
| Regenerate |
| </Button> |
| <ModelOverrideTrigger |
| currentModelEntry={commitModelOverride.effectiveModelEntry} |
| onModelChange={commitModelOverride.setOverride} |
| phase="commitMessageModel" |
| isOverridden={commitModelOverride.isOverridden} |
| size="sm" |
| variant="icon" |
| /> |
| </> |
| )} |
| </div> |
| </div> |
| <Textarea |
| id="commit-message" |
| placeholder={ |
| isGenerating ? 'Generating commit message...' : 'Describe your changes...' |
| } |
| value={message} |
| onChange={(e) => { |
| setMessage(e.target.value); |
| setError(null); |
| }} |
| onKeyDown={handleKeyDown} |
| className="min-h-[80px] font-mono text-sm" |
| autoFocus |
| disabled={isGenerating} |
| /> |
| {error && <p className="text-sm text-destructive">{error}</p>} |
| </div> |
| |
| {/* Push after commit option */} |
| <div className="flex flex-col gap-2"> |
| <div className="flex items-center gap-2"> |
| <Checkbox |
| id="push-after-commit" |
| checked={pushAfterCommit} |
| onCheckedChange={(checked) => setPushAfterCommit(checked === true)} |
| /> |
| <Label |
| htmlFor="push-after-commit" |
| className="text-sm font-medium cursor-pointer flex items-center gap-1.5" |
| > |
| <Upload className="w-3.5 h-3.5" /> |
| Push to remote after commit |
| </Label> |
| </div> |
| |
| {pushAfterCommit && ( |
| <div className="ml-6 flex flex-col gap-1.5"> |
| {isLoadingRemotes || (!remotesFetched && !remotesFetchError) ? ( |
| <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| <Spinner size="sm" /> |
| <span>Loading remotes...</span> |
| </div> |
| ) : remotesFetchError ? ( |
| <div className="flex items-center gap-2 text-sm text-destructive"> |
| <span>Failed to load remotes.</span> |
| <button |
| className="text-xs underline hover:text-foreground transition-colors" |
| onClick={() => { |
| if (worktree) { |
| setRemotesFetchError(null); |
| } |
| }} |
| > |
| Retry |
| </button> |
| </div> |
| ) : remotes.length === 0 && remotesFetched ? ( |
| <p className="text-sm text-muted-foreground"> |
| No remotes configured for this repository. |
| </p> |
| ) : remotes.length > 0 ? ( |
| <div className="flex items-center gap-2"> |
| <Label |
| htmlFor="remote-select" |
| className="text-xs text-muted-foreground whitespace-nowrap" |
| > |
| Remote: |
| </Label> |
| <Select value={selectedRemote} onValueChange={setSelectedRemote}> |
| <SelectTrigger id="remote-select" className="h-8 text-xs flex-1"> |
| <SelectValue placeholder="Select remote" /> |
| </SelectTrigger> |
| <SelectContent> |
| {remotes.map((remote) => ( |
| <SelectItem |
| key={remote.name} |
| value={remote.name} |
| description={ |
| <span className="text-xs text-muted-foreground truncate w-full block"> |
| {remote.url} |
| </span> |
| } |
| > |
| <span className="font-medium">{remote.name}</span> |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| ) : null} |
| </div> |
| )} |
| </div> |
| |
| <p className="text-xs text-muted-foreground"> |
| Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to |
| commit{pushAfterCommit ? ' & push' : ''} |
| </p> |
| </div> |
| |
| <DialogFooter> |
| <Button |
| variant="ghost" |
| onClick={() => onOpenChange(false)} |
| disabled={isLoading || isPushing || isGenerating} |
| > |
| Cancel |
| </Button> |
| <Button |
| onClick={handleCommit} |
| disabled={ |
| isLoading || |
| isPushing || |
| isGenerating || |
| (isPushRetry |
| ? !selectedRemote |
| : !message.trim() || |
| selectedFiles.size === 0 || |
| (pushAfterCommit && !selectedRemote)) |
| } |
| > |
| {isLoading || isPushing ? ( |
| <> |
| <Spinner size="sm" className="mr-2" /> |
| {isPushing ? 'Pushing...' : 'Committing...'} |
| </> |
| ) : isPushRetry ? ( |
| <> |
| <Upload className="w-4 h-4 mr-2" /> |
| Retry Push |
| </> |
| ) : ( |
| <> |
| {pushAfterCommit ? ( |
| <Upload className="w-4 h-4 mr-2" /> |
| ) : ( |
| <GitCommit className="w-4 h-4 mr-2" /> |
| )} |
| {pushAfterCommit ? 'Commit & Push' : 'Commit'} |
| {selectedFiles.size > 0 && selectedFiles.size < files.length |
| ? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})` |
| : ''} |
| </> |
| )} |
| </Button> |
| </DialogFooter> |
| </DialogContent> |
| </Dialog> |
| ); |
| } |
|
|