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 ;
case 'D':
return ;
case 'M':
case 'U':
return ;
case 'R':
case 'C':
return ;
default:
return ;
}
};
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 (
{content}
);
}
return (
{lineNumber?.old ?? ''}
{lineNumber?.new ?? ''}
{prefix[type]}
{content || '\u00A0'}
);
}
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(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
// Commit message model override
const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' });
const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } =
commitModelOverride;
// File selection state
const [files, setFiles] = useState([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState>(new Set());
const [expandedFile, setExpandedFile] = useState(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
const [mergeState, setMergeState] = useState(undefined);
// Push after commit state
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const [remotes, setRemotes] = useState([]);
const [selectedRemote, setSelectedRemote] = useState('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [remotesFetched, setRemotesFetched] = useState(false);
const [remotesFetchError, setRemotesFetchError] = useState(null);
// Track whether the commit already succeeded so retries can skip straight to push
const [commitSucceeded, setCommitSucceeded] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Fetch remotes when push option is enabled
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);
// Auto-select 'origin' if available, otherwise first remote
if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
}
} else {
// API not available — mark fetch as complete with an error so the UI
// shows feedback instead of remaining in an empty/loading state.
setRemotesFetchError('Remote listing not available');
setRemotesFetched(true);
return;
}
} catch (err) {
if (signal?.cancelled) return;
// Don't mark as successfully fetched — show an error with retry instead
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]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
setMergeState(undefined);
// Reset push state
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 ?? [];
// Sort merge-affected files first when a merge is in progress
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);
// If any files are already staged, pre-select only staged files
// Otherwise select all files by default
const stagedFiles = fileList.filter((f) => {
const idx = f.indexStatus ?? ' ';
return idx !== ' ' && idx !== '?';
});
if (!cancelled) {
if (stagedFiles.length > 0) {
// Also include untracked files that are staged (A status)
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));
}, []);
/** Shared push helper — returns true if the push succeeded */
const performPush = async (
api: ReturnType,
worktreePath: string,
remoteName: string
): Promise => {
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 commit already succeeded on a previous attempt, skip straight to push (or close if no push needed)
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;
}
// Pass selected files if not all files are selected
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}`,
});
// Push after commit if enabled
let pushSucceeded = false;
if (pushAfterCommit && selectedRemote) {
pushSucceeded = await performPush(api, worktree.path, selectedRemote);
}
// Only close the dialog when no push was requested or the push completed successfully.
// If push failed, keep the dialog open so the user can retry.
if (!pushAfterCommit || pushSucceeded) {
onCommitted();
onOpenChange(false);
setMessage('');
} else {
// Commit succeeded but push failed — notify parent of commit but keep dialog open for retry
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);
}
};
// When the commit succeeded but push failed, allow retrying the push without
// requiring a commit message or file selection.
const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === 'Enter' &&
(e.metaKey || e.ctrlKey) &&
!isLoading &&
!isPushing &&
!isGenerating
) {
if (isPushRetry) {
// Push retry only needs a selected remote
if (selectedRemote) {
handleCommit();
}
} else if (
message.trim() &&
selectedFiles.size > 0 &&
!(pushAfterCommit && !selectedRemote)
) {
handleCommit();
}
}
};
// Generate AI commit message
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]);
// Keep a stable ref to generateCommitMessage so the open-dialog effect
// doesn't re-fire (and erase user edits) when the model override changes.
const generateCommitMessageRef = useRef(generateCommitMessage);
useEffect(() => {
generateCommitMessageRef.current = generateCommitMessage;
});
// Generate AI commit message when dialog opens (if enabled)
useEffect(() => {
if (open && worktree) {
// Reset state
setMessage('');
setError(null);
if (!enableAiCommitMessages) {
return;
}
generateCommitMessageRef.current();
}
}, [open, worktree, enableAiCommitMessages]);
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
// Prevent the dialog from being dismissed while a push or generation is in progress.
// Overlay clicks and Escape key both route through onOpenChange(false); we
// intercept those here so the UI stays open until the operation completes.
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && (isLoading || isPushing || isGenerating)) {
// Ignore close requests during an active commit, push, or generation.
return;
}
onOpenChange(nextOpen);
};
return (
);
}