import React from 'react'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useGitStore, useGitStatus, useGitBranches, useGitLog, useGitIdentity, useIsGitRepo, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { RiGitBranchLine, RiGitMergeLine, RiGitCommitLine, RiGitPullRequestLine, RiLoader4Line, RiSplitCellsHorizontal, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; // (dropdown menu used inside IntegrateCommitsSection) import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; import { ChangesSection } from './git/ChangesSection'; import { CommitSection } from './git/CommitSection'; import { GitEmptyState } from './git/GitEmptyState'; import { HistorySection } from './git/HistorySection'; import { PullRequestSection } from './git/PullRequestSection'; import { ConflictDialog } from './git/ConflictDialog'; import { StashDialog } from './git/StashDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; import type { GitRemote } from '@/lib/gitApi'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { cn } from '@/lib/utils'; import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBootstrapStatus } from '@/lib/gitApi'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree'; type HistoryBranchDivider = { insertBeforeIndex: number; branchName: string; direction: 'up' | 'down'; } | null; const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab'; const isActionTab = (value: unknown): value is ActionTab => value === 'commit' || value === 'branch' || value === 'pr' || value === 'worktree'; type GitViewSnapshot = { directory?: string; selectedPaths: string[]; commitMessage: string; generatedHighlights: string[]; }; type GitmojiEntry = { emoji: string; code: string; description: string; }; type GitmojiCachePayload = { gitmojis: GitmojiEntry[]; fetchedAt: number; version: string; }; const GITMOJI_CACHE_KEY = 'gitmojiCache'; const GITMOJI_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7; const GITMOJI_CACHE_VERSION = '1'; const GIT_DIFF_PRIORITY_PREFETCH_LIMIT = 40; const GIT_DIFF_PRIORITY_BASELINE_LIMIT = 20; const GITMOJI_SOURCE_URL = 'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json'; const KEYWORD_MAP: Record = { 'feat': ':sparkles:', 'feature': ':sparkles:', 'fix': ':bug:', 'bug': ':bug:', 'hotfix': ':ambulance:', 'docs': ':memo:', 'documentation': ':memo:', 'style': ':lipstick:', 'refactor': ':recycle:', 'perf': ':zap:', 'performance': ':zap:', 'test': ':white_check_mark:', 'tests': ':white_check_mark:', 'build': ':construction_worker:', 'ci': ':green_heart:', 'chore': ':wrench:', 'revert': ':rewind:', 'wip': ':construction:', 'security': ':lock:', 'release': ':bookmark:', 'merge': ':twisted_rightwards_arrows:', 'mv': ':truck:', 'move': ':truck:', 'rename': ':truck:', 'remove': ':fire:', 'delete': ':fire:', 'add': ':sparkles:', 'create': ':sparkles:', 'implement': ':sparkles:', 'update': ':recycle:', 'improve': ':zap:', 'optimize': ':zap:', 'upgrade': ':arrow_up:', 'downgrade': ':arrow_down:', 'deploy': ':rocket:', 'init': ':tada:', 'initial': ':tada:', }; const isGitmojiEntry = (value: unknown): value is GitmojiEntry => { if (!value || typeof value !== 'object') return false; const candidate = value as Record; return ( typeof candidate.emoji === 'string' && typeof candidate.code === 'string' && typeof candidate.description === 'string' ); }; const readGitmojiCache = (): GitmojiCachePayload | null => { if (typeof window === 'undefined') return null; try { const raw = localStorage.getItem(GITMOJI_CACHE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (!parsed || parsed.version !== GITMOJI_CACHE_VERSION || typeof parsed.fetchedAt !== 'number') { return null; } if (!Array.isArray(parsed.gitmojis)) return null; const gitmojis = parsed.gitmojis.filter(isGitmojiEntry); return { gitmojis, fetchedAt: parsed.fetchedAt, version: parsed.version }; } catch { return null; } }; const writeGitmojiCache = (gitmojis: GitmojiEntry[]) => { if (typeof window === 'undefined') return; try { const payload: GitmojiCachePayload = { gitmojis, fetchedAt: Date.now(), version: GITMOJI_CACHE_VERSION, }; localStorage.setItem(GITMOJI_CACHE_KEY, JSON.stringify(payload)); } catch { return; } }; const isGitmojiCacheFresh = (payload: GitmojiCachePayload) => Date.now() - payload.fetchedAt < GITMOJI_CACHE_TTL_MS; const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): GitmojiEntry | null => { const lowerSubject = subject.toLowerCase(); // 1. Check for conventional commit prefix (e.g. "feat:", "fix(scope):") const conventionalRegex = /^([a-z]+)(?:\(.*\))?!?:/; const match = lowerSubject.match(conventionalRegex); if (match) { const type = match[1]; // Map common types to gitmoji codes const mappedCode = KEYWORD_MAP[type]; if (mappedCode) { return gitmojis.find((g) => g.code === mappedCode) || null; } } // 2. Check for starting words (e.g. "Add", "Fix") const firstWord = lowerSubject.split(' ')[0]; const mappedCode = KEYWORD_MAP[firstWord]; if (mappedCode) { return gitmojis.find((g) => g.code === mappedCode) || null; } return null; }; const gitViewSnapshots = new Map(); const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); export const GitView: React.FC = () => { const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null); const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false); const { currentSessionId, worktreeMetadata: worktreeMap, availableWorktrees, newSessionDraft, setDraftBootstrapPendingDirectory, } = useSessionStore(); const normalizedCurrentDirectory = normalizePath(currentDirectory); const inferredWorktreeMetadata = React.useMemo(() => { if (!normalizedCurrentDirectory) { return undefined; } const fromAvailable = availableWorktrees.find( (metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory ); if (fromAvailable) { return fromAvailable; } for (const metadata of worktreeMap.values()) { if (normalizePath(metadata.path) === normalizedCurrentDirectory) { return metadata; } } return undefined; }, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]); const worktreeMetadata = React.useMemo(() => { if (currentSessionId) { return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata; } if (newSessionDraft?.open) { return inferredWorktreeMetadata; } return undefined; }, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]); const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } = useGitIdentitiesStore(); const isGitRepo = useIsGitRepo(currentDirectory ?? null); const status = useGitStatus(currentDirectory ?? null); const branches = useGitBranches(currentDirectory ?? null); const log = useGitLog(currentDirectory ?? null); const currentIdentity = useGitIdentity(currentDirectory ?? null); const isLoading = useGitStore((state) => state.isLoadingStatus); const isLogLoading = useGitStore((state) => state.isLoadingLog); const { setActiveDirectory, fetchAll, fetchStatus, fetchBranches, fetchLog, fetchIdentity, prefetchDiffs, setLogMaxCount, } = useGitStore(); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); const navigateToDiff = useUIStore((state) => state.navigateToDiff); const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen); const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null); React.useEffect(() => { if (!currentDirectory) { setWorktreeBootstrapStatus(null); setIsWaitingForGitRefreshAfterBootstrap(false); return; } let cancelled = false; let timeoutId: number | null = null; const poll = async () => { try { const next = await getGitWorktreeBootstrapStatus(currentDirectory); if (cancelled) { return; } setWorktreeBootstrapStatus(next.status); if (next.status === 'pending') { timeoutId = window.setTimeout(() => { void poll(); }, 500); } } catch { if (!cancelled) { setWorktreeBootstrapStatus(null); } } }; void poll(); return () => { cancelled = true; if (timeoutId !== null) { window.clearTimeout(timeoutId); } }; }, [currentDirectory]); React.useEffect(() => { const previous = previousBootstrapStatusRef.current; previousBootstrapStatusRef.current = worktreeBootstrapStatus; if (!currentDirectory || !git) { return; } if (previous === 'pending' && worktreeBootstrapStatus === 'ready') { setIsWaitingForGitRefreshAfterBootstrap(true); void fetchAll(currentDirectory, git).finally(() => { window.setTimeout(() => { setIsWaitingForGitRefreshAfterBootstrap(false); }, 1200); }); } if (worktreeBootstrapStatus === 'failed') { setDraftBootstrapPendingDirectory(null); setIsWaitingForGitRefreshAfterBootstrap(false); } }, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]); const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); const isDraftBootstrapPendingForCurrentDirectory = Boolean( currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory) ); const isPendingWorktreeSetup = Boolean( currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory) ); const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap; const initialSnapshot = React.useMemo(() => { if (!currentDirectory) return null; return gitViewSnapshots.get(currentDirectory) ?? null; }, [currentDirectory]); const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); const [rootBranchHint, setRootBranchHint] = React.useState(null); React.useEffect(() => { const projectRoot = worktreeMetadata?.projectDirectory; if (!projectRoot) { setRootBranchHint(null); return; } let cancelled = false; void getRootBranch(projectRoot) .then((branch) => { if (cancelled) return; const normalized = branch.trim(); setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null); }) .catch(() => { if (!cancelled) { setRootBranchHint(null); } }); return () => { cancelled = true; }; }, [worktreeMetadata?.projectDirectory]); const [commitMessage, setCommitMessage] = React.useState( initialSnapshot?.commitMessage ?? '' ); const [visibleChangePaths, setVisibleChangePaths] = React.useState([]); const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false); const actionPanelScrollRef = React.useRef(null); const [syncAction, setSyncAction] = React.useState(null); const [commitAction, setCommitAction] = React.useState(null); const [logMaxCountLocal, setLogMaxCountLocal] = React.useState(25); const [isSettingIdentity, setIsSettingIdentity] = React.useState(false); const { triggerFireworks } = useFireworksCelebration(); const autoAppliedDefaultRef = React.useRef>(new Map()); const identityApplyCountRef = React.useRef(0); const beginIdentityApply = React.useCallback(() => { identityApplyCountRef.current += 1; setIsSettingIdentity(true); }, []); const endIdentityApply = React.useCallback(() => { identityApplyCountRef.current = Math.max(0, identityApplyCountRef.current - 1); if (identityApplyCountRef.current === 0) { setIsSettingIdentity(false); } }, []); const [selectedPaths, setSelectedPaths] = React.useState>( () => new Set(initialSnapshot?.selectedPaths ?? []) ); const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false); const [revertingPaths, setRevertingPaths] = React.useState>(new Set()); const [isRevertingAll, setIsRevertingAll] = React.useState(false); const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0); const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false); const [generatedHighlights, setGeneratedHighlights] = React.useState( initialSnapshot?.generatedHighlights ?? [] ); const scrollActionPanelToBottom = React.useCallback(() => { const scrollTarget = actionPanelScrollRef.current; if (!scrollTarget) return; requestAnimationFrame(() => { requestAnimationFrame(() => { scrollTarget.scrollTo({ top: scrollTarget.scrollHeight, behavior: 'smooth' }); }); }); }, []); const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null; const sourceBranchForIntegrate = status?.current || null; const shouldShowIntegrateCommits = React.useMemo(() => { // For PR worktrees from forks we set upstream to a non-origin remote (e.g. pr--). // Re-integrate commits is intended for local scratch branches -> base branch, not fork PR branches. const tracking = status?.tracking; if (!tracking) return true; return tracking.startsWith('origin/'); }, [status?.tracking]); const defaultTargetBranch = React.useMemo(() => { const fromMeta = worktreeMetadata?.createdFromBranch; const normalizedFromMeta = typeof fromMeta === 'string' ? fromMeta.trim() : ''; const current = typeof status?.current === 'string' ? status.current.trim() : ''; const normalizedRoot = typeof rootBranchHint === 'string' ? rootBranchHint.trim() : ''; if (normalizedFromMeta) { const looksLikeCorruptedSelfTarget = normalizedFromMeta === current && normalizedFromMeta.startsWith('opencode/') && normalizedRoot.length > 0 && normalizedRoot !== normalizedFromMeta; if (looksLikeCorruptedSelfTarget) { return normalizedRoot; } return normalizedFromMeta; } if (normalizedRoot) { return normalizedRoot; } if (current) { return current; } return 'HEAD'; }, [worktreeMetadata?.createdFromBranch, status, rootBranchHint]); const clearGeneratedHighlights = React.useCallback(() => { setGeneratedHighlights([]); }, []); const [expandedCommitHashes, setExpandedCommitHashes] = React.useState>(new Set()); const [commitFilesMap, setCommitFilesMap] = React.useState>(new Map()); const [loadingCommitHashes, setLoadingCommitHashes] = React.useState>(new Set()); const [historyBranchDivider, setHistoryBranchDivider] = React.useState(null); const [remoteUrl, setRemoteUrl] = React.useState(null); const [gitmojiEmojis, setGitmojiEmojis] = React.useState([]); const [gitmojiSearch, setGitmojiSearch] = React.useState(''); const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false); const actionTabItems = React.useMemo(() => [ { id: 'commit', label: 'Commit', icon: }, { id: 'branch', label: 'Update', icon: }, { id: 'pr', label: 'PR', icon: }, { id: 'worktree', label: 'Worktree', icon: }, ], []); const [actionTab, setActionTab] = React.useState(() => { if (typeof window === 'undefined') { return 'commit'; } const stored = window.localStorage.getItem(GIT_ACTION_TAB_STORAGE_KEY); return isActionTab(stored) ? stored : 'commit'; }); const [remotes, setRemotes] = React.useState([]); const [removingRemoteName, setRemovingRemoteName] = React.useState(null); const [branchOperation, setBranchOperation] = React.useState(null); const [operationLogs, setOperationLogs] = React.useState([]); const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false); const [conflictFiles, setConflictFiles] = React.useState([]); const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge'); // Conflict state persistence key const conflictStorageKey = React.useMemo(() => { if (!currentSessionId) return null; return `openchamber.conflict:${currentSessionId}`; }, [currentSessionId]); // Save conflict state to localStorage const persistConflictState = React.useCallback(( directory: string, files: string[], operation: 'merge' | 'rebase' ) => { if (!conflictStorageKey || typeof window === 'undefined') return; const payload = { directory, conflictFiles: files, operation }; window.localStorage.setItem(conflictStorageKey, JSON.stringify(payload)); }, [conflictStorageKey]); // Clear conflict state from localStorage const clearConflictState = React.useCallback(() => { if (!conflictStorageKey || typeof window === 'undefined') return; window.localStorage.removeItem(conflictStorageKey); }, [conflictStorageKey]); React.useEffect(() => { if (typeof window === 'undefined') { return; } window.localStorage.setItem(GIT_ACTION_TAB_STORAGE_KEY, actionTab); }, [actionTab]); // Restore conflict state from localStorage on mount React.useEffect(() => { if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return; const raw = window.localStorage.getItem(conflictStorageKey); if (!raw) return; try { const parsed = JSON.parse(raw) as { directory: string; conflictFiles: string[]; operation: 'merge' | 'rebase'; }; // Validate the stored state matches current directory if (parsed.directory !== currentDirectory) { window.localStorage.removeItem(conflictStorageKey); return; } // Restore conflict state setConflictFiles(parsed.conflictFiles ?? []); setConflictOperation(parsed.operation ?? 'merge'); setConflictDialogOpen(true); } catch { window.localStorage.removeItem(conflictStorageKey); } }, [conflictStorageKey, currentDirectory]); const [stashDialogOpen, setStashDialogOpen] = React.useState(false); const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge'); const [stashDialogBranch, setStashDialogBranch] = React.useState(''); const handleCopyCommitHash = React.useCallback((hash: string) => { void copyTextToClipboard(hash).then((result) => { if (result.ok) { toast.success('Commit hash copied'); return; } toast.error('Failed to copy'); }); }, []); const handleToggleCommit = React.useCallback((hash: string) => { setExpandedCommitHashes((prev) => { const next = new Set(prev); if (next.has(hash)) { next.delete(hash); } else { next.add(hash); } return next; }); }, []); React.useEffect(() => { if (!currentDirectory || !git) return; // Find hashes that are expanded but not yet loaded or loading const hashesToLoad = Array.from(expandedCommitHashes).filter( (hash) => !commitFilesMap.has(hash) && !loadingCommitHashes.has(hash) ); if (hashesToLoad.length === 0) return; setLoadingCommitHashes((prev) => { const next = new Set(prev); for (const hash of hashesToLoad) { next.add(hash); } return next; }); for (const hash of hashesToLoad) { git .getCommitFiles(currentDirectory, hash) .then((response) => { setCommitFilesMap((prev) => new Map(prev).set(hash, response.files)); }) .catch((error) => { console.error('Failed to fetch commit files:', error); setCommitFilesMap((prev) => new Map(prev).set(hash, [])); }) .finally(() => { setLoadingCommitHashes((prev) => { const next = new Set(prev); next.delete(hash); return next; }); }); } }, [expandedCommitHashes, currentDirectory, git, commitFilesMap, loadingCommitHashes]); React.useEffect(() => { if (!currentDirectory) return; gitViewSnapshots.set(currentDirectory, { directory: currentDirectory, selectedPaths: Array.from(selectedPaths), commitMessage, generatedHighlights, }); }, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]); React.useEffect(() => { loadProfiles(); loadGlobalIdentity(); loadDefaultGitIdentityId(); }, [loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]); React.useEffect(() => { if (!currentDirectory || !git?.getRemoteUrl) { setRemoteUrl(null); return; } git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null)); }, [currentDirectory, git]); const refreshRemotes = React.useCallback(async () => { if (!currentDirectory || !git?.getRemotes) { setRemotes([]); return; } try { const remoteList = await git.getRemotes(currentDirectory); setRemotes(remoteList); } catch { setRemotes([]); } }, [currentDirectory, git]); React.useEffect(() => { void refreshRemotes(); }, [refreshRemotes]); React.useEffect(() => { if (!settingsGitmojiEnabled) { setGitmojiEmojis([]); return; } let cancelled = false; const cached = readGitmojiCache(); if (cached) { setGitmojiEmojis(cached.gitmojis); if (isGitmojiCacheFresh(cached)) { return () => { cancelled = true; }; } } const loadGitmojis = async () => { try { const response = await fetch(GITMOJI_SOURCE_URL); if (!response.ok) { throw new Error(`Failed to load gitmojis: ${response.statusText}`); } const payload = (await response.json()) as { gitmojis?: GitmojiEntry[] }; const gitmojis = Array.isArray(payload.gitmojis) ? payload.gitmojis.filter(isGitmojiEntry) : []; if (!cancelled) { setGitmojiEmojis(gitmojis); writeGitmojiCache(gitmojis); } } catch (error) { if (!cancelled) { console.warn('Failed to load gitmoji list:', error); } } }; void loadGitmojis(); return () => { cancelled = true; }; }, [settingsGitmojiEnabled]); React.useEffect(() => { if (currentDirectory) { setActiveDirectory(currentDirectory); const dirState = useGitStore.getState().directories.get(currentDirectory); if (!dirState?.status) { void fetchAll(currentDirectory, git, { force: true }); } else { void fetchStatus(currentDirectory, git, { silent: true }); } } }, [currentDirectory, setActiveDirectory, fetchAll, fetchStatus, git]); const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { if (!currentDirectory) return; try { await Promise.all([ fetchStatus(currentDirectory, git), fetchBranches(currentDirectory, git), ]); } catch (err) { if (showErrors) { const message = err instanceof Error ? err.message : 'Failed to refresh repository state'; toast.error(message); } } }, [currentDirectory, git, fetchStatus, fetchBranches] ); const refreshLog = React.useCallback(async () => { if (!currentDirectory) return; await fetchLog(currentDirectory, git, logMaxCountLocal); }, [currentDirectory, git, fetchLog, logMaxCountLocal]); const refreshIdentity = React.useCallback(async () => { if (!currentDirectory) return; await fetchIdentity(currentDirectory, git); }, [currentDirectory, git, fetchIdentity]); React.useEffect(() => { if (!currentDirectory) return; if (!git?.hasLocalIdentity) return; if (isGitRepo !== true) return; const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : ''; if (!defaultId || defaultId === 'global') return; const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory); if (previousAttempt === defaultId) return; let cancelled = false; const run = async () => { try { const hasLocal = await git.hasLocalIdentity?.(currentDirectory); if (cancelled) return; if (hasLocal === true) return; beginIdentityApply(); await git.setGitIdentity(currentDirectory, defaultId); autoAppliedDefaultRef.current.set(currentDirectory, defaultId); await refreshIdentity(); } catch (error) { console.warn('Failed to auto-apply default git identity:', error); } finally { if (!cancelled) { endIdentityApply(); } } }; void run(); return () => { cancelled = true; }; }, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); const changeEntries = React.useMemo(() => { if (!status) return []; const files = status.files ?? []; const unique = new Map(); for (const file of files) { unique.set(file.path, file); } return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path)); }, [status]); React.useEffect(() => { if (!currentDirectory || changeEntries.length === 0) { return; } const orderedPaths: string[] = []; const seen = new Set(); const pushPath = (path: string) => { if (!path || seen.has(path)) { return; } seen.add(path); orderedPaths.push(path); }; Array.from(selectedPaths).forEach(pushPath); visibleChangePaths.forEach(pushPath); changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path)); if (orderedPaths.length === 0) { return; } const timeoutId = window.setTimeout(() => { void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT }); }, 120); return () => { window.clearTimeout(timeoutId); }; }, [changeEntries, currentDirectory, git, prefetchDiffs, selectedPaths, visibleChangePaths]); React.useEffect(() => { if (!status || changeEntries.length === 0) { setSelectedPaths(new Set()); setHasUserAdjustedSelection(false); return; } setSelectedPaths((previous) => { const next = new Set(); const previousSet = previous ?? new Set(); for (const file of changeEntries) { if (previousSet.has(file.path)) { next.add(file.path); } else if (!hasUserAdjustedSelection) { next.add(file.path); } } return next; }); }, [status, changeEntries, hasUserAdjustedSelection]); const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { if (!currentDirectory) return; setSyncAction(action); try { if (action === 'fetch') { if (!remote) { throw new Error('No remote available for fetch'); } await git.gitFetch(currentDirectory, { remote: remote.name }); toast.success(`Fetched from ${remote.name}`); } else if (action === 'pull') { if (!remote) { throw new Error('No remote available for pull'); } const result = await git.gitPull(currentDirectory, { remote: remote.name }); toast.success( `Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}` ); } else if (action === 'push') { await git.gitPush(currentDirectory); toast.success('Pushed to upstream'); } await refreshStatusAndBranches(false); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : `Failed to ${action === 'pull' ? 'pull' : action}`; toast.error(message); } finally { setSyncAction(null); } }; const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => { if (!currentDirectory) return; const remoteName = remote.name.trim(); if (!remoteName) { toast.error('Remote name is required'); return; } if (remoteName === 'origin') { toast.error('Cannot remove origin remote'); return; } setRemovingRemoteName(remoteName); try { await git.removeRemote(currentDirectory, { remote: remoteName }); toast.success(`Removed ${remoteName} remote`); await Promise.all([ refreshStatusAndBranches(false), refreshRemotes(), ]); } catch (error) { const message = error instanceof Error ? error.message : `Failed to remove ${remoteName}`; toast.error(message); } finally { setRemovingRemoteName(null); } }, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches]); const handleCommit = async (options: { pushAfter?: boolean } = {}) => { if (!currentDirectory) return; if (!commitMessage.trim()) { toast.error('Please enter a commit message'); return; } const filesToCommit = Array.from(selectedPaths).sort(); if (filesToCommit.length === 0) { toast.error('Select at least one file to commit'); return; } const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit'; setCommitAction(action); try { await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit, }); toast.success('Commit created successfully'); setCommitMessage(''); setSelectedPaths(new Set()); setHasUserAdjustedSelection(false); clearGeneratedHighlights(); await refreshStatusAndBranches(); if (options.pushAfter) { await git.gitPush(currentDirectory); toast.success('Pushed to upstream'); triggerFireworks(); await refreshStatusAndBranches(false); } else { await refreshStatusAndBranches(false); } await refreshLog(); setIntegrateRefreshKey((v) => v + 1); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create commit'; toast.error(message); } finally { setCommitAction(null); } }; const handleGenerateCommitMessage = React.useCallback(async () => { if (!currentDirectory) return; if (selectedPaths.size === 0) { toast.error('Select at least one file to describe'); return; } console.error('[git-generation][browser] generate button clicked', { directory: currentDirectory, selectedFiles: selectedPaths.size, }); setIsGeneratingMessage(true); try { const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths)); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; if (subject) { let finalSubject = subject; if (settingsGitmojiEnabled && gitmojiEmojis.length > 0) { const match = matchGitmojiFromSubject(subject, gitmojiEmojis); if (match) { const { code, emoji } = match; if (!subject.startsWith(code) && !subject.startsWith(emoji)) { finalSubject = `${code} ${subject}`; } } } setCommitMessage(finalSubject); } setGeneratedHighlights(highlights); scrollActionPanelToBottom(); } catch (error) { console.error('[git-generation][browser] GitView generate handler failed', { message: error instanceof Error ? error.message : String(error), error, }); const message = error instanceof Error ? error.message : 'Failed to generate commit message'; toast.error(message); } finally { setIsGeneratingMessage(false); } }, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]); const handleCreateBranch = async (branchName: string, remote?: GitRemote) => { if (!currentDirectory || !status) return; const checkoutBase = status.current ?? null; const remoteName = remote?.name ?? 'origin'; try { await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD'); toast.success(`Created branch ${branchName}`); // Checkout the new branch and stay on it await git.checkoutBranch(currentDirectory, branchName); let pushSucceeded = false; try { await git.gitPush(currentDirectory, { remote: remoteName, branch: branchName, options: ['--set-upstream'], }); pushSucceeded = true; } catch (pushError) { const message = pushError instanceof Error ? pushError.message : `Unable to push new branch to ${remoteName}.`; toast.warning('Branch created locally', { description: ( Upstream setup failed: {message} ), }); } await refreshStatusAndBranches(); await refreshLog(); if (pushSucceeded) { toast.success(`Upstream set for ${branchName} on ${remoteName}`); } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create branch'; toast.error(message); throw err; } }; const handleRenameBranch = async (oldName: string, newName: string) => { if (!currentDirectory) return; try { await git.renameBranch(currentDirectory, oldName, newName); toast.success(`Renamed branch ${oldName} to ${newName}`); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : `Failed to rename branch ${oldName} to ${newName}`; toast.error(message); } }; const handleCheckoutBranch = async (branch: string) => { if (!currentDirectory) return; const normalized = branch.replace(/^remotes\//, ''); if (status?.current === normalized) { return; } try { await git.checkoutBranch(currentDirectory, normalized); toast.success(`Checked out ${normalized}`); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : `Failed to checkout ${normalized}`; toast.error(message); } }; const handleApplyIdentity = async (profile: GitIdentityProfile) => { if (!currentDirectory) return; beginIdentityApply(); try { await git.setGitIdentity(currentDirectory, profile.id); toast.success(`Applied "${profile.name}" to repository`); await refreshIdentity(); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to apply git identity'; toast.error(message); } finally { endIdentityApply(); } }; const localBranches = React.useMemo(() => { if (!branches?.all) return []; return branches.all .filter((branchName: string) => !branchName.startsWith('remotes/')) .sort(); }, [branches]); const remoteBranches = React.useMemo(() => { if (!branches?.all) return []; return branches.all .filter((branchName: string) => branchName.startsWith('remotes/')) .map((branchName: string) => branchName.replace(/^remotes\//, '')) .sort(); }, [branches]); const effectiveRemotes = React.useMemo(() => { if (remotes.length > 0) { return remotes; } const inferredNames = new Set(); const tracking = status?.tracking?.trim(); if (tracking && tracking.includes('/')) { inferredNames.add(tracking.split('/')[0]); } for (const branchName of remoteBranches) { const slashIndex = branchName.indexOf('/'); if (slashIndex > 0) { inferredNames.add(branchName.slice(0, slashIndex)); } } if (inferredNames.size === 0 && remoteUrl) { inferredNames.add('origin'); } return Array.from(inferredNames).map((name) => ({ name, fetchUrl: remoteUrl ?? '', pushUrl: remoteUrl ?? '', })); }, [remotes, remoteBranches, remoteUrl, status?.tracking]); const baseBranch = React.useMemo(() => { const remoteNames = new Set(effectiveRemotes.map((remote) => remote.name)); const normalizeBaseCandidate = (value: string): string => { if (!value) { return ''; } let normalized = value.trim(); if (!normalized || normalized === 'HEAD') { return ''; } if (localBranches.includes(normalized)) { return normalized; } if (normalized.startsWith('refs/heads/')) { normalized = normalized.slice('refs/heads/'.length); } if (normalized.startsWith('heads/')) { normalized = normalized.slice('heads/'.length); } if (normalized.startsWith('remotes/')) { normalized = normalized.slice('remotes/'.length); } const slashIndex = normalized.indexOf('/'); if (slashIndex > 0) { const maybeRemote = normalized.slice(0, slashIndex); if (remoteNames.has(maybeRemote)) { const withoutRemote = normalized.slice(slashIndex + 1).trim(); if (withoutRemote) { normalized = withoutRemote; } } } return normalized; }; const fromMeta = normalizeBaseCandidate( typeof worktreeMetadata?.createdFromBranch === 'string' ? worktreeMetadata.createdFromBranch : '' ); if (fromMeta) return fromMeta; const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : ''); if (fromHint) return fromHint; if (localBranches.includes('main')) return 'main'; if (localBranches.includes('master')) return 'master'; if (localBranches.includes('develop')) return 'develop'; return 'main'; }, [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]); const availableIdentities = React.useMemo(() => { const unique = new Map(); if (globalIdentity) { unique.set(globalIdentity.id, globalIdentity); } let repoHostPath: string | null = null; if (remoteUrl) { try { let normalized = remoteUrl.trim(); if (normalized.startsWith('git@')) { normalized = `https://${normalized.slice(4).replace(':', '/')}`; } if (normalized.endsWith('.git')) { normalized = normalized.slice(0, -4); } const url = new URL(normalized); repoHostPath = url.hostname + url.pathname; } catch { /* ignore */ } } for (const profile of profiles) { if (profile.authType !== 'token') { unique.set(profile.id, profile); continue; } const profileHost = profile.host; if (!profileHost) { unique.set(profile.id, profile); continue; } if (!profileHost.includes('/')) { unique.set(profile.id, profile); continue; } if (repoHostPath && repoHostPath === profileHost) { unique.set(profile.id, profile); } } return Array.from(unique.values()); }, [profiles, globalIdentity, remoteUrl]); const activeIdentityProfile = React.useMemo((): GitIdentityProfile | null => { if (currentIdentity?.userName && currentIdentity?.userEmail) { const match = profiles.find( (profile) => profile.userName === currentIdentity.userName && profile.userEmail === currentIdentity.userEmail ); if (match) { return match; } if ( globalIdentity && globalIdentity.userName === currentIdentity.userName && globalIdentity.userEmail === currentIdentity.userEmail ) { return globalIdentity; } return { id: 'local-config', name: currentIdentity.userName, userName: currentIdentity.userName, userEmail: currentIdentity.userEmail, sshKey: currentIdentity.sshCommand?.replace('ssh -i ', '') ?? null, color: 'info', icon: 'user', }; } return globalIdentity ?? null; }, [currentIdentity, profiles, globalIdentity]); const selectedCount = selectedPaths.size; const isBusy = isLoading || syncAction !== null || commitAction !== null; const currentBranch = status?.current ?? null; const canShowIntegrateCommitsSection = Boolean( worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ); const canShowPullRequestSection = Boolean( currentDirectory && currentBranch && status?.tracking && currentBranch !== baseBranch ); const canShowBranchWorkflows = Boolean(currentBranch); const integrateCommitsProps = canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata ? { repoRoot: repoRootForIntegrate, sourceBranch: sourceBranchForIntegrate, worktreeMetadata, } : null; const pullRequestProps = React.useMemo(() => { if (!canShowPullRequestSection || !currentDirectory || !currentBranch) { return null; } return { directory: currentDirectory, branch: currentBranch, }; }, [canShowPullRequestSection, currentBranch, currentDirectory]); React.useEffect(() => { if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) { setHistoryBranchDivider(null); return; } let cancelled = false; const resolveBranchDivider = async () => { try { const branchOnlyLog = await git.getGitLog(currentDirectory, { from: baseBranch, to: 'HEAD', maxCount: logMaxCountLocal, }); if (cancelled) { return; } const branchHashes = new Set( (branchOnlyLog?.all ?? []) .map((entry) => entry.hash) .filter((hash) => typeof hash === 'string' && hash.length > 0) ); if (branchHashes.size === 0) { setHistoryBranchDivider(null); return; } const insertBeforeIndex = log.all.findIndex((entry) => !branchHashes.has(entry.hash)); if (insertBeforeIndex === 0) { setHistoryBranchDivider(null); return; } if (insertBeforeIndex === -1) { setHistoryBranchDivider({ insertBeforeIndex: log.all.length, branchName: currentBranch, direction: 'up', }); return; } setHistoryBranchDivider({ insertBeforeIndex, branchName: currentBranch, direction: 'up', }); } catch { if (!cancelled) { setHistoryBranchDivider(null); } } }; void resolveBranchDivider(); return () => { cancelled = true; }; }, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. const toggleFileSelection = (path: string) => { setSelectedPaths((previous) => { const next = new Set(previous); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); setHasUserAdjustedSelection(true); }; const selectAll = () => { const next = new Set(changeEntries.map((file) => file.path)); setSelectedPaths(next); setHasUserAdjustedSelection(true); }; const clearSelection = () => { setSelectedPaths(new Set()); setHasUserAdjustedSelection(true); }; const handleRevertFile = React.useCallback( async (filePath: string) => { if (!currentDirectory) return; setRevertingPaths((previous) => { const next = new Set(previous); next.add(filePath); return next; }); try { await git.revertGitFile(currentDirectory, filePath); toast.success(`Reverted ${filePath}`); await refreshStatusAndBranches(false); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to revert changes'; toast.error(message); } finally { setRevertingPaths((previous) => { const next = new Set(previous); next.delete(filePath); return next; }); } }, [currentDirectory, refreshStatusAndBranches, git] ); const handleRevertAll = React.useCallback( async (paths: string[]) => { if (!currentDirectory || paths.length === 0 || isRevertingAll) { return; } const uniquePaths = Array.from(new Set(paths)); setIsRevertingAll(true); setRevertingPaths((previous) => { const next = new Set(previous); uniquePaths.forEach((path) => next.add(path)); return next; }); const failed: Array<{ path: string; message: string }> = []; try { await Promise.all(uniquePaths.map(async (filePath) => { try { await git.revertGitFile(currentDirectory, filePath); } catch (err) { failed.push({ path: filePath, message: err instanceof Error ? err.message : 'Failed to revert changes', }); } })); await refreshStatusAndBranches(false); if (failed.length === 0) { toast.success(`Reverted ${uniquePaths.length} file${uniquePaths.length === 1 ? '' : 's'}`); } else if (failed.length === uniquePaths.length) { toast.error(failed[0]?.message || 'Failed to revert changes'); } else { const successCount = uniquePaths.length - failed.length; toast.warning(`Reverted ${successCount} file${successCount === 1 ? '' : 's'}, ${failed.length} failed`); } } finally { setRevertingPaths((previous) => { const next = new Set(previous); uniquePaths.forEach((path) => next.delete(path)); return next; }); setIsRevertingAll(false); } }, [currentDirectory, git, isRevertingAll, refreshStatusAndBranches] ); const handleInsertHighlights = React.useCallback(() => { if (generatedHighlights.length === 0) return; const normalizedHighlights = generatedHighlights .map((text) => text.trim()) .filter(Boolean); if (normalizedHighlights.length === 0) { clearGeneratedHighlights(); return; } setCommitMessage((current) => { const base = current.trim(); const separator = base.length > 0 ? '\n\n' : ''; return `${base}${separator}${normalizedHighlights.join('\n')}`.trim(); }); }, [generatedHighlights, clearGeneratedHighlights]); const handleSelectGitmoji = React.useCallback((emoji: string, code: string) => { const token = code || emoji; setCommitMessage((current) => { const trimmed = current.trimStart(); if (trimmed.startsWith(emoji) || (code && trimmed.startsWith(code))) { return current; } const prefix = token.endsWith(' ') ? token : `${token} `; return `${prefix}${current}`.trimStart(); }); setGitmojiSearch(''); setIsGitmojiPickerOpen(false); }, []); const handleLogMaxCountChange = React.useCallback( (count: number) => { setLogMaxCountLocal(count); if (currentDirectory) { setLogMaxCount(currentDirectory, count); fetchLog(currentDirectory, git, count); } }, [currentDirectory, setLogMaxCount, fetchLog, git] ); const isUncommittedChangesError = React.useCallback((error: unknown): boolean => { const message = error instanceof Error ? error.message.toLowerCase() : ''; return ( message.includes('uncommitted changes') || message.includes('local changes') || message.includes('your local changes would be overwritten') || message.includes('please commit your changes or stash them') || message.includes('cannot rebase: you have unstaged changes') || message.includes('error: cannot pull with rebase') ); }, []); // Helper to add/update operation logs const addOperationLog = React.useCallback((message: string, status: OperationLogEntry['status']) => { setOperationLogs(prev => [...prev, { message, status, timestamp: Date.now() }]); }, []); const updateLastLog = React.useCallback((status: OperationLogEntry['status'], message?: string) => { setOperationLogs(prev => { if (prev.length === 0) return prev; const updated = [...prev]; updated[updated.length - 1] = { ...updated[updated.length - 1], status, ...(message ? { message } : {}), }; return updated; }); }, []); // Called at start of operation to reset logs const resetOperationLogs = React.useCallback(() => { setOperationLogs([]); }, []); // Called when dialog is closed to fully reset state const handleOperationComplete = React.useCallback(() => { setOperationLogs([]); setBranchOperation(null); }, []); const handleMerge = React.useCallback( async (branch: string) => { if (!currentDirectory) return; setBranchOperation('merge'); resetOperationLogs(); const currentBranch = status?.current; try { // If it's a remote branch (contains '/'), fetch latest first const slashIndex = branch.indexOf('/'); if (slashIndex > 0) { const remote = branch.substring(0, slashIndex); const remoteBranch = branch.substring(slashIndex + 1); addOperationLog(`Fetching ${remote}/${remoteBranch}...`, 'running'); await git.gitFetch(currentDirectory, { remote, branch: remoteBranch }); updateLastLog('done', `Fetched ${remote}/${remoteBranch}`); } addOperationLog(`Merging ${branch} into ${currentBranch}...`, 'running'); const result = await git.merge(currentDirectory, { branch }); if (result.conflict) { updateLastLog('error', `Merge conflicts detected`); setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); } else { updateLastLog('done', `Merged ${branch} into ${currentBranch}`); clearConflictState(); addOperationLog('Refreshing repository status...', 'running'); await refreshStatusAndBranches(); await refreshLog(); updateLastLog('done', 'Repository status updated'); } } catch (err) { if (isUncommittedChangesError(err)) { updateLastLog('error', 'Uncommitted changes detected'); setStashDialogOperation('merge'); setStashDialogBranch(branch); setStashDialogOpen(true); } else { const message = err instanceof Error ? err.message : `Failed to merge ${branch}`; updateLastLog('error', message); } } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] ); const handleRebase = React.useCallback( async (branch: string) => { if (!currentDirectory) return; setBranchOperation('rebase'); resetOperationLogs(); const currentBranch = status?.current; try { // If it's a remote branch (contains '/'), fetch latest first const slashIndex = branch.indexOf('/'); if (slashIndex > 0) { const remote = branch.substring(0, slashIndex); const remoteBranch = branch.substring(slashIndex + 1); addOperationLog(`Fetching ${remote}/${remoteBranch}...`, 'running'); await git.gitFetch(currentDirectory, { remote, branch: remoteBranch }); updateLastLog('done', `Fetched ${remote}/${remoteBranch}`); } addOperationLog(`Rebasing ${currentBranch} onto ${branch}...`, 'running'); const result = await git.rebase(currentDirectory, { onto: branch }); if (result.conflict) { updateLastLog('error', `Rebase conflicts detected`); setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); } else { updateLastLog('done', `Rebased ${currentBranch} onto ${branch}`); clearConflictState(); addOperationLog('Refreshing repository status...', 'running'); await refreshStatusAndBranches(); await refreshLog(); updateLastLog('done', 'Repository status updated'); } } catch (err) { if (isUncommittedChangesError(err)) { updateLastLog('error', 'Uncommitted changes detected'); setStashDialogOperation('rebase'); setStashDialogBranch(branch); setStashDialogOpen(true); } else { const message = err instanceof Error ? err.message : `Failed to rebase onto ${branch}`; updateLastLog('error', message); } } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs] ); const handleAbortConflict = React.useCallback(async () => { if (!currentDirectory) return; try { if (conflictOperation === 'merge') { await git.abortMerge(currentDirectory); toast.success('Merge aborted'); } else { await git.abortRebase(currentDirectory); toast.success('Rebase aborted'); } clearConflictState(); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : `Failed to abort ${conflictOperation}`; toast.error(message); } }, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState]); // Check if there are unresolved conflicts (files with 'U' status) const hasUnresolvedConflicts = React.useMemo(() => { if (!status?.files) return false; return status.files.some((f) => (f.index === 'U' || f.working_dir === 'U') || (f.index === 'A' && f.working_dir === 'A') || (f.index === 'D' && f.working_dir === 'D') ); }, [status?.files]); const handleContinueOperation = React.useCallback(async () => { if (!currentDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; const isRebase = !!(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto); if (isMerge) { const result = await git.continueMerge(currentDirectory); if (result.conflict) { setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge'); toast.error('Merge conflicts detected'); } else { clearConflictState(); toast.success('Merge completed'); await refreshStatusAndBranches(); await refreshLog(); } } else if (isRebase) { const result = await git.continueRebase(currentDirectory); if (result.conflict) { setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase'); toast.error('Rebase conflicts detected'); } else { clearConflictState(); toast.success('Rebase step completed'); await refreshStatusAndBranches(); await refreshLog(); } } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to continue operation'; toast.error(message); } }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState]); const handleAbortOperation = React.useCallback(async () => { if (!currentDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; if (isMerge) { await git.abortMerge(currentDirectory); toast.success('Merge aborted'); } else { await git.abortRebase(currentDirectory); toast.success('Rebase aborted'); } clearConflictState(); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to abort operation'; toast.error(message); } }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState]); const handleResolveWithAIFromBanner = React.useCallback(() => { if (!currentDirectory) return; // Determine operation type from status const isMerge = !!status?.mergeInProgress?.head; const operation = isMerge ? 'merge' : 'rebase'; // Get conflict files from status (files with 'U' status indicate unmerged/conflicted) const filesWithConflicts = status?.files ?.filter((f) => f.index === 'U' || f.working_dir === 'U') .map((f) => f.path) ?? []; // Update conflict state and open dialog if (filesWithConflicts.length > 0) { setConflictFiles(filesWithConflicts); } setConflictOperation(operation); setConflictDialogOpen(true); }, [currentDirectory, status]); const handleStashAndRetry = React.useCallback( async (restoreAfter: boolean) => { if (!currentDirectory) return; const currentBranch = status?.current; const operation = stashDialogOperation; const branch = stashDialogBranch; // Stash changes try { await git.stash(currentDirectory, { message: `Auto-stash before ${operation} with ${branch}`, includeUntracked: true, }); } catch (stashErr) { const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes'; toast.error(msg); return; } let operationSucceeded = false; let hasConflict = false; try { // Perform the operation if (operation === 'merge') { const result = await git.merge(currentDirectory, { branch }); if (result.conflict) { hasConflict = true; setConflictFiles(result.conflictFiles ?? []); setConflictOperation('merge'); setConflictDialogOpen(true); } else { operationSucceeded = true; toast.success(`Merged ${branch} into ${currentBranch}`); } } else { const result = await git.rebase(currentDirectory, { onto: branch }); if (result.conflict) { hasConflict = true; setConflictFiles(result.conflictFiles ?? []); setConflictOperation('rebase'); setConflictDialogOpen(true); } else { operationSucceeded = true; toast.success(`Rebased ${currentBranch} onto ${branch}`); } } // Restore stashed changes if requested and operation succeeded if (restoreAfter && operationSucceeded) { try { await git.stashPop(currentDirectory); toast.success('Stashed changes restored'); } catch (popErr) { const popMessage = popErr instanceof Error ? popErr.message : 'Failed to restore stashed changes'; toast.error(popMessage); } } else if (restoreAfter && hasConflict) { toast.info('Stashed changes will need to be restored manually after resolving conflicts'); } await refreshStatusAndBranches(); await refreshLog(); } catch (err) { // If the operation failed (not due to conflicts), try to restore stash if (restoreAfter) { try { await git.stashPop(currentDirectory); } catch { // Ignore stash pop errors in this case } } throw err; } }, [currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog] ); if (!currentDirectory) { return (

Select a session or directory to view repository details.

); } if (isLoading && isGitRepo === null) { return (
Checking repository...
); } if (isGitRepo === false) { if (shouldHideNotGitState) { return (

Worktree setup is in progress

Git tools will appear as soon as the new worktree is ready.

); } return (

Not a Git repository

Choose a different directory or initialize Git to use this workspace.

); } return (
handleSyncAction('fetch', remote)} onPull={(remote) => handleSyncAction('pull', remote)} onPush={() => handleSyncAction('push')} onRemoveRemote={handleRemoveRemote} removingRemoteName={removingRemoteName} onCheckoutBranch={handleCheckoutBranch} onCreateBranch={handleCreateBranch} onRenameBranch={handleRenameBranch} activeIdentityProfile={activeIdentityProfile} availableIdentities={availableIdentities} onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} onOpenHistory={() => setIsHistoryDialogOpen(true)} /> {/* In-progress operation banner */} {currentDirectory && ( (status?.mergeInProgress?.head) || (status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto) ) && ( )}
setActionTab(tabID as ActionTab)} layoutMode="fit" variant="active-pill" inactiveTabsIconOnly={isMobile} className="h-full" />
{actionTab === 'commit' ? (
{(changeEntries?.length ?? 0) > 0 ? ( <> { if (currentDirectory && !isMobile) { openContextDiff(currentDirectory, path); return; } navigateToDiff(path); if (isMobile) { setRightSidebarOpen(false); } }} onRevertFile={handleRevertFile} isRevertingAll={isRevertingAll} /> handleCommit({ pushAfter: false })} onCommitAndPush={() => handleCommit({ pushAfter: true })} commitAction={commitAction} isBusy={isBusy} gitmojiEnabled={settingsGitmojiEnabled} onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} /> ) : ( 0 ? (status?.behind ?? 0) : 0} isPulling={syncAction === 'pull'} onPull={() => { const remote = effectiveRemotes[0]; if (!remote) { return; } void handleSyncAction('pull', remote); }} /> )}
) : null} {actionTab === 'branch' ? (
{canShowBranchWorkflows ? ( ) : (

Branch actions unavailable.

)}
) : null} {actionTab === 'worktree' ? (
{integrateCommitsProps ? ( { if (!currentDirectory) return; fetchStatus(currentDirectory, git); fetchBranches(currentDirectory, git); fetchLog(currentDirectory, git, logMaxCountLocal); }} /> ) : (
Re-integrate commits
Available in worktree mode.
)}
) : null} {actionTab === 'pr' ? (
{pullRequestProps ? ( ) : (
Pull Request
Push a non-base branch (with upstream) to create a PR.
)}
) : null}
History Browse recent commits and inspect file-level changes.
Pick a gitmoji No gitmojis found. {(gitmojiEmojis.length === 0 ? [] : gitmojiEmojis.filter((entry) => { const term = gitmojiSearch.trim().toLowerCase(); if (!term) return true; return ( entry.emoji.includes(term) || entry.code.toLowerCase().includes(term) || entry.description.toLowerCase().includes(term) ); }) ).map((entry) => ( handleSelectGitmoji(entry.emoji, entry.code)} > {entry.emoji} {entry.code} {entry.description} ))} {currentDirectory && ( )}
); };