import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useShallow } from 'zustand/react/shallow'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitmojiList } from '@/hooks/useGitmojiList'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useGitStore, useGitStatus, useGitBranches, useGitLog, useGitIdentity, useIsGitRepo, useGitLoadingStatus, useGitLoadingLog, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; 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 { Icon } from "@/components/icon/Icon"; import { Button } from '@/components/ui/button'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { getSessionWorktreeRepairActions, getMutationBlockingReasons } from '@/sync/session-worktree-contract'; import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; import { StashesDialog } from './git/StashesDialog'; import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel'; import { CommitSection } from './git/CommitSection'; import { GitEmptyState } from './git/GitEmptyState'; import { HistorySection } from './git/HistorySection'; import { ConflictDialog } from './git/ConflictDialog'; import { StashDialog } from './git/StashDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; import { deriveBaseBranch } from './git/baseBranch'; import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue'; 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'; import { sessionEvents } from '@/lib/sessionEvents'; import { useI18n } from '@/lib/i18n'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; type GitLogDialogMode = 'history' | 'graph'; type HistoryBranchDivider = { insertBeforeIndex: number; branchName: string; direction: 'up' | 'down'; } | null; const GIT_RECONCILE_DELAY_MS = 15000; type GitViewSnapshot = { directory?: string; commitMessage: string; generatedHighlights: string[]; }; type GitmojiEntry = { emoji: string; code: string; description: string; }; const GIT_DIFF_PRIORITY_PREFETCH_LIMIT = 40; const GIT_DIFF_PRIORITY_BASELINE_LIMIT = 20; 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 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 GIT_VIEW_SNAPSHOTS_CAP = 20; const gitViewSnapshots = new Map(); const rememberSnapshot = (key: string, snapshot: GitViewSnapshot) => { // Touch-on-write LRU: deleting before re-inserting promotes the key to // the Map's insertion order, so the oldest key falls off the end. gitViewSnapshots.delete(key); gitViewSnapshots.set(key, snapshot); if (gitViewSnapshots.size > GIT_VIEW_SNAPSHOTS_CAP) { const oldest = gitViewSnapshots.keys().next().value; if (oldest !== undefined) { gitViewSnapshots.delete(oldest); } } }; const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => { const indexStatus = file.index?.trim(); return Boolean(indexStatus && indexStatus !== '?'); }; const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => { const workingStatus = file.working_dir?.trim(); const indexStatus = file.index?.trim(); return Boolean(workingStatus || indexStatus === '?'); }; type GitViewProps = { isActive: boolean; }; export const GitView: React.FC = ({ isActive }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null); const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees); 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 storeWorktreeMetadata = 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(useShallow((s) => ({ profiles: s.profiles, globalIdentity: s.globalIdentity, defaultGitIdentityId: s.defaultGitIdentityId, loadProfiles: s.loadProfiles, loadGlobalIdentity: s.loadGlobalIdentity, loadDefaultGitIdentityId: s.loadDefaultGitIdentityId, }))); const isGitRepo = useIsGitRepo(currentDirectory ?? null); const status = useGitStatus(currentDirectory ?? null); // Authoritative session↔worktree attachment for repair action display const worktreeAttachment = useSessionWorktreeStore((s) => currentSessionId ? s.getAttachment(currentSessionId) : undefined ); const repairActions = worktreeAttachment ? getSessionWorktreeRepairActions(worktreeAttachment) : []; // When an authoritative attachment exists, derive worktree-related fields from it // rather than from the live detected worktree metadata. const authoritativeProjectRoot = worktreeAttachment && !worktreeAttachment.degraded && !worktreeAttachment.legacy ? worktreeAttachment.worktreeRoot ?? undefined : undefined; const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); const branches = useGitBranches(currentDirectory ?? null); const log = useGitLog(currentDirectory ?? null); const currentIdentity = useGitIdentity(currentDirectory ?? null); const isLoading = useGitLoadingStatus(currentDirectory ?? null); const isLogLoading = useGitLoadingLog(currentDirectory ?? null); const { setActiveDirectory, fetchAll, ensureAll, fetchStatus, fetchBranches, fetchLog, setLogMaxCount, fetchIdentity, prefetchDiffs, moveStatusPathsOptimistically, restoreStatus, bumpIndexRevision, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, fetchAll: state.fetchAll, ensureAll: state.ensureAll, fetchStatus: state.fetchStatus, fetchBranches: state.fetchBranches, fetchLog: state.fetchLog, setLogMaxCount: state.setLogMaxCount, fetchIdentity: state.fetchIdentity, prefetchDiffs: state.prefetchDiffs, moveStatusPathsOptimistically: state.moveStatusPathsOptimistically, restoreStatus: state.restoreStatus, bumpIndexRevision: state.bumpIndexRevision, }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); const openContextSurface = useUIStore((state) => state.openContextSurface); const prStatusBranch = status?.current ?? null; const prChipStatus = useGitHubPrStatusStore((state) => { if (!currentDirectory || !prStatusBranch) { return null; } return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch); }); const navigateToDiff = useUIStore((state) => state.navigateToDiff); const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null); const gitReconcileTimeoutRef = React.useRef(null); const gitMutationFlushTimeoutRef = React.useRef(null); const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null); const mountedRef = React.useRef(true); React.useEffect(() => () => { mountedRef.current = false; }, []); const clearScheduledGitReconcile = React.useCallback(() => { if (gitReconcileTimeoutRef.current === null) { return; } window.clearTimeout(gitReconcileTimeoutRef.current); gitReconcileTimeoutRef.current = null; }, []); const scheduleGitReconcile = React.useCallback((directory: string) => { clearScheduledGitReconcile(); gitReconcileTimeoutRef.current = window.setTimeout(() => { gitReconcileTimeoutRef.current = null; if (normalizePath(directory) !== normalizePath(currentDirectory)) { return; } void fetchStatus(directory, git, { silent: true }); }, GIT_RECONCILE_DELAY_MS); }, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]); React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]); const clearScheduledGitMutationFlush = React.useCallback(() => { if (gitMutationFlushTimeoutRef.current === null) { return; } window.clearTimeout(gitMutationFlushTimeoutRef.current); gitMutationFlushTimeoutRef.current = null; }, []); const scheduleGitMutationFlush = React.useCallback(() => { if (gitMutationFlushTimeoutRef.current !== null) { return; } gitMutationFlushTimeoutRef.current = window.setTimeout(() => { gitMutationFlushTimeoutRef.current = null; flushQueuedGitMutationsRef.current?.(); }, 0); }, []); const runGitIndexMutation = React.useCallback(async ( directory: string, direction: GitIndexMutationDirection, paths: string[] ) => { if (direction === 'stage') { if (git.stageGitFiles) { await git.stageGitFiles(directory, paths); return; } await Promise.all(paths.map((filePath) => git.stageGitFile(directory, filePath))); return; } if (git.unstageGitFiles) { await git.unstageGitFiles(directory, paths); return; } await Promise.all(paths.map((filePath) => git.unstageGitFile(directory, filePath))); }, [git]); const gitIndexMutationQueue = React.useMemo(() => createGitIndexMutationQueue({ runMutation: ({ directory, direction, paths }) => runGitIndexMutation(directory, direction, paths), onMutationComplete: ({ directory }) => { bumpIndexRevision(directory); scheduleGitReconcile(directory); }, onMutationError: ({ directory, direction, rollback }, error) => { rollback?.(); bumpIndexRevision(directory); scheduleGitReconcile(directory); const fallback = direction === 'stage' ? t('gitView.toast.stageFileFailed') : t('gitView.toast.unstageFileFailed'); toast.error(error instanceof Error ? error.message : fallback); }, onPathsComplete: (paths) => { setMovingChangePaths((previous) => { const updated = new Set(previous); paths.forEach((path) => updated.delete(path)); return updated; }); }, scheduleFlush: scheduleGitMutationFlush, }), [bumpIndexRevision, runGitIndexMutation, scheduleGitMutationFlush, scheduleGitReconcile, t]); React.useEffect(() => { flushQueuedGitMutationsRef.current = gitIndexMutationQueue.flush; return () => { flushQueuedGitMutationsRef.current = null; }; }, [gitIndexMutationQueue]); React.useEffect(() => () => gitIndexMutationQueue.clear(), [gitIndexMutationQueue]); React.useEffect(() => clearScheduledGitMutationFlush, [clearScheduledGitMutationFlush]); React.useEffect(() => { if (!isActive) return; 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); } }; }, [isActive, 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); const { gitmojis: gitmojiEmojis } = useGitmojiList(settingsGitmojiEnabled); React.useEffect(() => { const projectRoot = authoritativeProjectRoot || 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; }; }, [authoritativeProjectRoot, 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 [isStashesDialogOpen, setIsStashesDialogOpen] = React.useState(false); 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; if (mountedRef.current) { setIsSettingIdentity(true); } }, []); const endIdentityApply = React.useCallback(() => { identityApplyCountRef.current = Math.max(0, identityApplyCountRef.current - 1); if (mountedRef.current && identityApplyCountRef.current === 0) { setIsSettingIdentity(false); } }, []); const [revertingPaths, setRevertingPaths] = React.useState>(new Set()); const [movingChangePaths, setMovingChangePaths] = 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 hasPendingIndexMutation = movingChangePaths.size > 0 || gitIndexMutationQueue.size() > 0 || gitIndexMutationQueue.isRunning(); const scrollActionPanelToBottom = React.useCallback(() => { const scrollTarget = actionPanelScrollRef.current; if (!scrollTarget) return; requestAnimationFrame(() => { requestAnimationFrame(() => { scrollTarget.scrollTo({ top: scrollTarget.scrollHeight, behavior: 'smooth' }); }); }); }, []); const repoRootForIntegrate = authoritativeProjectRoot || 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 commitFilesMapRef = React.useRef(commitFilesMap); const loadingCommitHashesRef = React.useRef(loadingCommitHashes); const [historyBranchDivider, setHistoryBranchDivider] = React.useState(null); const [remoteUrl, setRemoteUrl] = React.useState(null); const [gitmojiSearch, setGitmojiSearch] = React.useState(''); const [gitLogDialogMode, setGitLogDialogMode] = React.useState(null); const [isUpdateBranchDialogOpen, setIsUpdateBranchDialogOpen] = React.useState(false); const [isIntegrateCommitsDialogOpen, setIsIntegrateCommitsDialogOpen] = React.useState(false); 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'); const [graphLog, setGraphLog] = React.useState(null); const [graphLogLoading, setGraphLogLoading] = React.useState(false); const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100); const [graphLogRefreshToken, setGraphLogRefreshToken] = React.useState(0); // 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]); // 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(t('gitView.toast.commitHashCopied')); return; } toast.error(t('gitView.toast.copyFailed')); }); }, [t]); 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(() => { commitFilesMapRef.current = commitFilesMap; }, [commitFilesMap]); React.useEffect(() => { loadingCommitHashesRef.current = loadingCommitHashes; }, [loadingCommitHashes]); React.useEffect(() => { if (!currentDirectory || !git) return; // Find hashes that are expanded but not yet loaded or loading const hashesToLoad = Array.from(expandedCommitHashes).filter( (hash) => !commitFilesMapRef.current.has(hash) && !loadingCommitHashesRef.current.has(hash) ); if (hashesToLoad.length === 0) return; let cancelled = false; setLoadingCommitHashes((prev) => { const next = new Set(prev); for (const hash of hashesToLoad) { next.add(hash); } loadingCommitHashesRef.current = next; return next; }); void Promise.all( hashesToLoad.map((hash) => git .getCommitFiles(currentDirectory, hash) .then((response) => ({ hash, files: response.files })) .catch((error) => { console.error('Failed to fetch commit files:', error); return { hash, files: [] as CommitFileEntry[] }; }) ) ).then((results) => { if (cancelled) return; setCommitFilesMap((prev) => { const next = new Map(prev); for (const { hash, files } of results) { next.set(hash, files); } commitFilesMapRef.current = next; return next; }); setLoadingCommitHashes((prev) => { const next = new Set(prev); for (const { hash } of results) { next.delete(hash); } loadingCommitHashesRef.current = next; return next; }); }); return () => { cancelled = true; setLoadingCommitHashes((prev) => { let changed = false; const next = new Set(prev); for (const hash of hashesToLoad) { if (next.delete(hash)) { changed = true; } } if (!changed) { return prev; } loadingCommitHashesRef.current = next; return next; }); }; }, [expandedCommitHashes, currentDirectory, git]); React.useEffect(() => { if (!currentDirectory) return; rememberSnapshot(currentDirectory, { directory: currentDirectory, commitMessage, generatedHighlights, }); }, [commitMessage, currentDirectory, generatedHighlights]); React.useEffect(() => { if (!isActive) return; loadProfiles(); loadGlobalIdentity(); loadDefaultGitIdentityId(); }, [isActive, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]); React.useEffect(() => { if (!isActive) return; if (!currentDirectory || !git?.getRemoteUrl) { setRemoteUrl(null); return; } let cancelled = false; git .getRemoteUrl(currentDirectory) .then((url) => { if (!cancelled) setRemoteUrl(url); }) .catch(() => { if (!cancelled) setRemoteUrl(null); }); return () => { cancelled = true; }; }, [isActive, currentDirectory, git]); const refreshRemotes = React.useCallback(async () => { if (!currentDirectory || !git?.getRemotes) { setRemotes([]); return; } try { const remoteList = await git.getRemotes(currentDirectory); if (mountedRef.current) { setRemotes(remoteList); } } catch { if (mountedRef.current) { setRemotes([]); } } }, [currentDirectory, git]); React.useEffect(() => { if (!isActive) return; void refreshRemotes(); }, [isActive, refreshRemotes]); React.useEffect(() => { if (!isActive) return; if (currentDirectory) { setActiveDirectory(currentDirectory); void ensureAll(currentDirectory, git); } }, [isActive, currentDirectory, setActiveDirectory, ensureAll, git]); React.useEffect(() => { if (!isActive) return; if (!currentDirectory) { return; } return sessionEvents.onGitRefreshHint((hint) => { if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) { return; } void fetchStatus(currentDirectory, git); }); }, [isActive, currentDirectory, 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 : t('gitView.toast.refreshRepositoryFailed'); toast.error(message); } } }, [currentDirectory, git, fetchStatus, fetchBranches, t] ); 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 (!isActive) return; 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; }; }, [isActive, beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]); const changeEntries = React.useMemo(() => { if (!status) return []; const files = status.files ?? []; // GitStatus.files is already unique by `path` per the server contract; // a defensive dedup pass would only mask real upstream bugs. return [...files].sort((a, b) => a.path.localeCompare(b.path)); }, [status]); const stagedChangeEntries = React.useMemo( () => changeEntries.filter(isStagedStatusFile), [changeEntries] ); const unstagedChangeEntries = React.useMemo( () => changeEntries.filter(isUnstagedStatusFile), [changeEntries] ); 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); }; stagedChangeEntries.forEach((entry) => pushPath(entry.path)); 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, stagedChangeEntries, visibleChangePaths]); const getPushedRemoteName = (result?: Awaited>) => { return result?.pushed[0]?.remote || status?.tracking?.split('/')[0] || effectiveRemotes.find((remote) => remote.name === 'origin')?.name || effectiveRemotes[0]?.name || 'origin'; }; const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { if (!currentDirectory) return; setSyncAction(action); try { const getPullOptions = (pullRemote: GitRemote) => { const trackingPrefix = `${pullRemote.name}/`; const trackedBranch = status?.tracking?.startsWith(trackingPrefix) ? status.tracking.slice(trackingPrefix.length) : undefined; return { remote: pullRemote.name, branch: trackedBranch, rebase: true, }; }; if (action === 'fetch') { if (!remote) { throw new Error('No remote available for fetch'); } await git.gitFetch(currentDirectory, { remote: remote.name }); toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name })); } else if (action === 'pull') { if (!remote) { throw new Error('No remote available for pull'); } const result = await git.gitPull(currentDirectory, getPullOptions(remote)); toast.success( result.files.length === 1 ? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name }) : t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name }) ); } else if (action === 'push') { const result = await git.gitPush(currentDirectory); toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); } else if (action === 'sync') { if (!remote) { throw new Error('No remote available for sync'); } let pulledFileCount = 0; let pushedChanges = false; await git.gitFetch(currentDirectory, { remote: remote.name }); const afterFetch = await git.getGitStatus(currentDirectory); if ((afterFetch.behind ?? 0) > 0) { if ((afterFetch.files?.length ?? 0) > 0) { toast.error(t('gitView.toast.commitOrStashBeforeSync')); return; } const pullResult = await git.gitPull(currentDirectory, getPullOptions(remote)); pulledFileCount = pullResult.files.length; } const afterPull = await git.getGitStatus(currentDirectory); if ((afterPull.ahead ?? 0) > 0) { await git.gitPush(currentDirectory); pushedChanges = true; } if (pulledFileCount > 0 && pushedChanges) { toast.success( pulledFileCount === 1 ? t('gitView.toast.syncedPulledSingleAndPushed', { count: pulledFileCount, name: remote.name }) : t('gitView.toast.syncedPulledPluralAndPushed', { count: pulledFileCount, name: remote.name }) ); } else if (pulledFileCount > 0) { toast.success( pulledFileCount === 1 ? t('gitView.toast.pulledFilesSingle', { count: pulledFileCount, name: remote.name }) : t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: remote.name }) ); } else if (pushedChanges) { toast.success(t('gitView.toast.pushedToUpstream', { name: remote.name })); } else { toast.success(t('gitView.toast.alreadyUpToDate')); } } await refreshStatusAndBranches(false); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.syncActionFailed', { action: action === 'sync' ? t('gitView.sync.syncChanges') : action === 'pull' ? t('gitView.sync.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(t('gitView.toast.remoteNameRequired')); return; } if (remoteName === 'origin') { toast.error(t('gitView.toast.cannotRemoveOriginRemote')); return; } setRemovingRemoteName(remoteName); try { await git.removeRemote(currentDirectory, { remote: remoteName }); toast.success(t('gitView.toast.removedRemote', { name: remoteName })); 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, t]); const handleCommit = async (options: { pushAfter?: boolean } = {}) => { if (!currentDirectory) return; if (!commitMessage.trim()) { toast.error(t('gitView.toast.enterCommitMessage')); return; } const filesToCommit = stagedChangeEntries.map((file) => file.path).sort(); if (filesToCommit.length === 0) { toast.error(t('gitView.toast.stageFileToCommit')); return; } const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit'; setCommitAction(action); try { await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit, stageFiles: [], }); bumpIndexRevision(currentDirectory); toast.success(t('gitView.toast.commitCreated')); setCommitMessage(''); clearGeneratedHighlights(); await refreshStatusAndBranches(); if (options.pushAfter) { const trackingRemoteName = status?.tracking?.split('/')[0]; const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0]; if (!remote) { throw new Error(t('mobile.changes.noRemote')); } setSyncAction('sync'); const trackingPrefix = `${remote.name}/`; const trackedBranch = status?.tracking?.startsWith(trackingPrefix) ? status.tracking.slice(trackingPrefix.length) : undefined; await git.gitFetch(currentDirectory, { remote: remote.name }); const afterFetch = await git.getGitStatus(currentDirectory); if ((afterFetch.behind ?? 0) > 0) { if ((afterFetch.files?.length ?? 0) > 0) { toast.error(t('gitView.toast.commitOrStashBeforeSync')); await refreshStatusAndBranches(false); return; } await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true }); } const afterPull = await git.getGitStatus(currentDirectory); let result: Awaited> | undefined; if ((afterPull.ahead ?? 0) > 0) { result = await git.gitPush(currentDirectory); } toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); triggerFireworks(); await refreshStatusAndBranches(false); } else { await refreshStatusAndBranches(false); } await refreshLog(); setIntegrateRefreshKey((v) => v + 1); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.createCommitFailed'); toast.error(message); } finally { setCommitAction(null); if (options.pushAfter) { setSyncAction(null); } } }; const handleGenerateCommitMessage = React.useCallback(async () => { if (!currentDirectory) return; const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort(); if (selectedFilePaths.length === 0) { toast.error(t('gitView.toast.stageFileToDescribe')); return; } console.error('[git-generation][browser] generate button clicked', { directory: currentDirectory, selectedFiles: selectedFilePaths.length, }); setIsGeneratingMessage(true); try { const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths); 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 : t('gitView.toast.generateCommitMessageFailed'); toast.error(message); } finally { setIsGeneratingMessage(false); } }, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); const formatBlockingReason = (reason: ReturnType[number]): string => { if (reason.reason === 'attention') { return `${reason.attentionReason} in progress`; } if (reason.reason === 'missing') { return 'worktree is missing'; } return 'worktree is invalid'; }; const handleCreateBranch = async (branchName: string, remote?: GitRemote) => { if (!currentDirectory || !status) return; const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { toast.error(t('gitView.toast.cannotCreateBranch', { reason: formatBlockingReason(blockingReasons[0]) })); return; } const checkoutBase = status.current ?? null; const remoteName = remote?.name ?? 'origin'; try { await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD'); toast.success(t('gitView.toast.createdBranch', { name: 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(t('gitView.toast.branchCreatedLocally'), { description: ( Upstream setup failed: {message} ), }); } await refreshStatusAndBranches(); await refreshLog(); if (pushSucceeded) { toast.success(t('gitView.toast.upstreamSet', { branch: branchName, remote: remoteName })); } } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.createBranchFailed'); toast.error(message); throw err; } }; const handleRenameBranch = async (oldName: string, newName: string) => { if (!currentDirectory) return; const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { toast.error(t('gitView.toast.cannotRenameBranch', { reason: formatBlockingReason(blockingReasons[0]) })); return; } try { await git.renameBranch(currentDirectory, oldName, newName); toast.success(t('gitView.toast.renamedBranch', { oldName, newName })); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.renameBranchFailed', { oldName, newName }); toast.error(message); } }; const handleCheckoutBranch = async (branch: string) => { if (!currentDirectory) return; // Block mutation if worktree is in an attention-required state const blockingReasons = getMutationBlockingReasons(worktreeAttachment); if (blockingReasons.length > 0) { toast.error(t('gitView.toast.cannotCheckout', { reason: formatBlockingReason(blockingReasons[0]) })); return; } const normalized = branch.replace(/^remotes\//, ''); if (status?.current === normalized) { return; } try { await git.checkoutBranch(currentDirectory, normalized); toast.success(t('gitView.toast.checkedOut', { name: normalized })); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.checkoutFailed', { name: normalized }); toast.error(message); } }; const handleApplyIdentity = async (profile: GitIdentityProfile) => { if (!currentDirectory) return; beginIdentityApply(); try { await git.setGitIdentity(currentDirectory, profile.id); toast.success(t('gitView.toast.appliedIdentity', { name: profile.name })); await refreshIdentity(); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.applyIdentityFailed'); 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(() => deriveBaseBranch({ remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)), localBranches, worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch, rootBranchHint, }), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]); const updateTargetBranch = React.useMemo(() => { const remoteNames = effectiveRemotes.map((remote) => remote.name); const remoteCandidates = remoteNames.map((remote) => `${remote}/${baseBranch}`); return remoteCandidates.find((candidate) => remoteBranches.includes(candidate)) ?? baseBranch; }, [baseBranch, effectiveRemotes, remoteBranches]); 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 stagedCount = stagedChangeEntries.length; const isBusy = isLoading || syncAction !== null || commitAction !== null; const currentBranch = status?.current ?? null; const canShowIntegrateCommitsSection = Boolean( worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ); const canShowBranchWorkflows = Boolean(currentBranch); const integrateCommitsProps = canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata ? { repoRoot: repoRootForIntegrate, sourceBranch: sourceBranchForIntegrate, worktreeMetadata, } : null; 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]); // Clear graph log when directory changes React.useEffect(() => { setGraphLog(null); }, [currentDirectory]); React.useEffect(() => { if (gitLogDialogMode !== 'graph' || !currentDirectory) { if (gitLogDialogMode !== 'graph') setGraphLog(null); return; } let cancelled = false; setGraphLogLoading(true); git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true }) .then((result) => { if (!cancelled) setGraphLog(result); }) .catch((err) => { console.error('Failed to fetch graph log:', err); }) .finally(() => { if (!cancelled) setGraphLogLoading(false); }); return () => { cancelled = true; }; }, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => { if (!currentDirectory || paths.length === 0) return; const uniquePaths = Array.from(new Set(paths)); setMovingChangePaths((previous) => { const next = new Set(previous); uniquePaths.forEach((path) => next.add(path)); return next; }); const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction); gitIndexMutationQueue.enqueue({ directory: currentDirectory, direction, paths: new Set(uniquePaths), rollback: () => restoreStatus(currentDirectory, previousStatus), }); scheduleGitMutationFlush(); }, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]); 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, { scope: 'working' }); toast.success(t('gitView.toast.revertedFile', { path: filePath })); await refreshStatusAndBranches(false); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.revertFailed'); toast.error(message); } finally { setRevertingPaths((previous) => { const next = new Set(previous); next.delete(filePath); return next; }); } }, [currentDirectory, refreshStatusAndBranches, git, t] ); const handleRevertPaths = React.useCallback( async (paths: string[], setGlobalReverting: boolean, scope: 'all' | 'working' = 'all') => { if (!currentDirectory || paths.length === 0) { return; } const uniquePaths = Array.from(new Set(paths)); if (isRevertingAll || uniquePaths.some((path) => revertingPaths.has(path))) { return; } const stagedPaths = new Set(stagedChangeEntries.map((entry) => entry.path)); const touchesStagedIndex = scope === 'all' && uniquePaths.some((path) => stagedPaths.has(path)); if (setGlobalReverting) { 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, { scope }); } catch (err) { failed.push({ path: filePath, message: err instanceof Error ? err.message : t('gitView.toast.revertFailed'), }); } })); if (touchesStagedIndex && failed.length < uniquePaths.length) { bumpIndexRevision(currentDirectory); } await refreshStatusAndBranches(false); if (failed.length === 0) { toast.success( uniquePaths.length === 1 ? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length }) : t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length }) ); } else if (failed.length === uniquePaths.length) { toast.error(failed[0]?.message || t('gitView.toast.revertFailed')); } else { const successCount = uniquePaths.length - failed.length; toast.warning( successCount === 1 ? t('gitView.toast.revertedSomeSingle', { success: successCount, failed: failed.length }) : t('gitView.toast.revertedSomePlural', { success: successCount, failed: failed.length }) ); } } finally { setRevertingPaths((previous) => { const next = new Set(previous); uniquePaths.forEach((path) => next.delete(path)); return next; }); if (setGlobalReverting) { setIsRevertingAll(false); } } }, [bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t] ); const handleRevertAll = React.useCallback( async (paths: string[]) => { await handleRevertPaths(paths, true); }, [handleRevertPaths] ); const handleRevertDirectory = React.useCallback( async (paths: string[]) => { await handleRevertPaths(paths, false, 'working'); }, [handleRevertPaths] ); const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => { if (currentDirectory && !isMobile) { openContextDiff(currentDirectory, path, staged); return; } navigateToDiff(path, staged); }, [currentDirectory, isMobile, navigateToDiff, openContextDiff]); const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); const changeGroups = React.useMemo(() => { const groups: ChangesGroupConfig[] = []; if (stagedChangeEntries.length > 0) { groups.push({ id: 'staged', title: t('gitView.changes.stagedTitle'), entries: stagedChangeEntries, actionSymbol: '-', actionAllLabel: t('gitView.changes.unstageAllAria'), getActionLabel: (path) => t('gitView.changes.unstageFileAria', { path }), onActionFile: (path) => void moveChangePaths([path], 'unstage'), onActionAll: (paths) => void moveChangePaths(paths, 'unstage'), onViewDiff: (path) => handleViewChangeDiff(path, true), onRevertFile: handleRevertFile, showRevertActions: false, accent: true, }); } if (unstagedChangeEntries.length > 0) { groups.push({ id: 'unstaged', title: t('gitView.changes.title'), entries: unstagedChangeEntries, actionSymbol: '+', actionAllLabel: t('gitView.changes.stageAllAria'), getActionLabel: (path) => t('gitView.changes.stageFileAria', { path }), onActionFile: (path) => void moveChangePaths([path], 'stage'), onActionAll: (paths) => void moveChangePaths(paths, 'stage'), onViewDiff: (path) => handleViewChangeDiff(path, false), onRevertFile: handleRevertFile, }); } return groups; }, [ handleRevertFile, handleViewChangeDiff, moveChangePaths, stagedChangeEntries, t, unstagedChangeEntries, ]); const handleInsertHighlights = React.useCallback((sourceHighlights: string[]) => { if (sourceHighlights.length === 0) return; const normalizedHighlights = sourceHighlights .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(); }); clearGeneratedHighlights(); }, [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 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 resolveIntegrationTarget = React.useCallback((branch: string) => { const trimmed = branch.trim(); const knownRemoteNames = new Set(effectiveRemotes.map((remote) => remote.name)); const slashIndex = trimmed.indexOf('/'); if (slashIndex > 0) { const remote = trimmed.slice(0, slashIndex); const remoteBranch = trimmed.slice(slashIndex + 1); if (knownRemoteNames.has(remote) && remoteBranch) { return { branch: trimmed, remote, remoteBranch }; } } for (const remote of effectiveRemotes) { const remoteCandidate = `${remote.name}/${trimmed}`; if (remoteBranches.includes(remoteCandidate)) { return { branch: remoteCandidate, remote: remote.name, remoteBranch: trimmed }; } } return { branch: trimmed, remote: null, remoteBranch: null }; }, [effectiveRemotes, remoteBranches]); const handleMerge = React.useCallback( async (branch: string) => { if (!currentDirectory) return; setBranchOperation('merge'); resetOperationLogs(); const currentBranch = status?.current; const target = resolveIntegrationTarget(branch); try { if (target.remote && target.remoteBranch) { addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running'); await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch }); updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`); } addOperationLog(`Merging ${target.branch} into ${currentBranch}...`, 'running'); const result = await git.merge(currentDirectory, { branch: target.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 ${target.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(target.branch); setStashDialogOpen(true); } else { const message = err instanceof Error ? err.message : `Failed to merge ${target.branch}`; updateLastLog('error', message); } } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, [currentDirectory, git, status, resolveIntegrationTarget, 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; const target = resolveIntegrationTarget(branch); try { if (target.remote && target.remoteBranch) { addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running'); await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch }); updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`); } addOperationLog(`Rebasing ${currentBranch} onto ${target.branch}...`, 'running'); const result = await git.rebase(currentDirectory, { onto: target.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 ${target.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(target.branch); setStashDialogOpen(true); } else { const message = err instanceof Error ? err.message : `Failed to rebase onto ${target.branch}`; updateLastLog('error', message); } } // Note: branchOperation is cleared when dialog closes via handleOperationComplete }, [currentDirectory, git, status, resolveIntegrationTarget, 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(t('gitView.toast.mergeAborted')); } else { await git.abortRebase(currentDirectory); toast.success(t('gitView.toast.rebaseAborted')); } 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, t]); // 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(t('gitView.toast.mergeConflictsDetected')); } else { clearConflictState(); toast.success(t('gitView.toast.mergeCompleted')); 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(t('gitView.toast.rebaseConflictsDetected')); } else { clearConflictState(); toast.success(t('gitView.toast.rebaseStepCompleted')); await refreshStatusAndBranches(); await refreshLog(); } } } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.continueOperationFailed'); toast.error(message); } }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]); const handleAbortOperation = React.useCallback(async () => { if (!currentDirectory) return; try { const isMerge = !!status?.mergeInProgress?.head; if (isMerge) { await git.abortMerge(currentDirectory); toast.success(t('gitView.toast.mergeAborted')); } else { await git.abortRebase(currentDirectory); toast.success(t('gitView.toast.rebaseAborted')); } clearConflictState(); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { const message = err instanceof Error ? err.message : t('gitView.toast.abortOperationFailed'); toast.error(message); } }, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]); 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; const hadStagedChanges = (status?.files ?? []).some(isStagedStatusFile); // Stash changes try { await git.stash(currentDirectory, { message: `Auto-stash before ${operation} with ${branch}`, includeUntracked: true, }); if (hadStagedChanges) { bumpIndexRevision(currentDirectory); } } 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(t('gitView.toast.mergedIntoBranch', { branch, currentBranch: 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(t('gitView.toast.rebasedOntoBranch', { currentBranch: currentBranch || '', branch })); } } // Restore stashed changes if requested and operation succeeded if (restoreAfter && operationSucceeded) { try { await git.stashPop(currentDirectory); bumpIndexRevision(currentDirectory); toast.success(t('gitView.toast.stashedRestored')); } catch (popErr) { const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed'); toast.error(popMessage); } } else if (restoreAfter && hasConflict) { toast.info(t('gitView.toast.restoreStashManually')); } 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); bumpIndexRevision(currentDirectory); } catch { // Ignore stash pop errors in this case } } throw err; } }, [bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] ); const handleLogMaxCountChange = React.useCallback( (count: number) => { setLogMaxCountLocal(count); if (currentDirectory) { setLogMaxCount(currentDirectory, count); fetchLog(currentDirectory, git, count); } }, [currentDirectory, fetchLog, git, setLogMaxCount] ); const handleGraphLogMaxCountChange = React.useCallback((count: number) => { setGraphLogMaxCount(count); }, []); const handleGraphActionSuccess = React.useCallback(() => { setGitLogDialogMode(null); if (currentDirectory) { fetchStatus(currentDirectory, git); fetchBranches(currentDirectory, git); fetchLog(currentDirectory, git, logMaxCountLocal); } }, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); const handleGraphConflict = React.useCallback((result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase'; }) => { if (!result.conflict) return; if (result.operation === 'cherry-pick' || result.operation === 'revert') { // Cherry-pick and revert conflicts are not supported by the shared ConflictDialog // Show a toast with manual resolution instructions toast.error(t('gitView.history.actions.conflictToastTitle'), { description: t('gitView.history.actions.conflictToastDescription', { files: result.conflictFiles?.join(', ') ?? 'unknown files', }), }); if (currentDirectory) { fetchStatus(currentDirectory, git); fetchBranches(currentDirectory, git); fetchLog(currentDirectory, git, logMaxCountLocal); } return; } setConflictFiles(result.conflictFiles ?? []); setConflictOperation(result.operation); setConflictDialogOpen(true); if (currentDirectory) { persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation); } }, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]); if (!currentDirectory) { return (

{t('gitView.empty.selectSessionOrDirectory')}

); } if (isGitRepo === null || (isGitRepo === true && !status)) { return (
{t('gitView.loading.checkingRepository')}
); } if (isGitRepo === false) { if (shouldHideNotGitState) { return (

{t('gitView.empty.worktreeSetupInProgress')}

{t('gitView.empty.worktreeSetupDescription')}

); } return (

{t('gitView.empty.notGitRepository')}

{t('gitView.empty.notGitRepositoryDescription')}

{repairActions.includes('open-without-worktree-features') ? (

{t('gitView.empty.worktreeFeaturesUnavailable')}

) : null}
); } return (
handleSyncAction('fetch', remote)} onSync={(remote) => handleSyncAction('sync', remote)} onRemoveRemote={handleRemoveRemote} removingRemoteName={removingRemoteName} onCheckoutBranch={handleCheckoutBranch} onCreateBranch={handleCreateBranch} onRenameBranch={handleRenameBranch} activeIdentityProfile={activeIdentityProfile} availableIdentities={availableIdentities} onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} onOpenHistory={() => setGitLogDialogMode('history')} onOpenGraph={() => setGitLogDialogMode('graph')} onOpenStashes={openStashes} onOpenUpdateBranch={canShowBranchWorkflows ? () => setIsUpdateBranchDialogOpen(true) : undefined} onOpenReintegrateCommits={integrateCommitsProps ? () => setIsIntegrateCommitsDialogOpen(true) : undefined} pullRequest={prChipStatus?.pr ?? null} prChecks={prChipStatus?.checks ?? null} onOpenPullRequest={ currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined } /> {/* In-progress operation banner */} {currentDirectory && ( (status?.mergeInProgress?.head) || (status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto) ) && ( )}
{(changeEntries?.length ?? 0) > 0 ? ( <>
handleCommit({ pushAfter: false })} onCommitAndPush={() => handleCommit({ pushAfter: true })} commitAction={commitAction} hasPendingIndexMutation={hasPendingIndexMutation} gitmojiEnabled={settingsGitmojiEnabled} onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} /> ) : ( setIsStashesDialogOpen(true)} /> )}
{ // Keep the dialog up while a merge/rebase is running so the // operation log stays visible until it completes or fails. if (!open && branchOperation !== null) { return; } setIsUpdateBranchDialogOpen(open); }} > {t('gitView.branch.updateTitle')} {t('gitView.branch.updateDescriptionPrefix')}{' '} {status?.current ?? ''}. {canShowBranchWorkflows ? ( ) : (

{t('gitView.branch.actionsUnavailable')}

)}
{t('gitView.integrate.title')} {integrateCommitsProps ? ( {integrateCommitsProps.sourceBranch} → {defaultTargetBranch} ) : null} {integrateCommitsProps ? ( { if (!currentDirectory) return; fetchStatus(currentDirectory, git); fetchBranches(currentDirectory, git); fetchLog(currentDirectory, git, logMaxCountLocal); }} /> ) : null} { if (!open) setGitLogDialogMode(null); }}>
{gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')}
{t('gitView.history.dialogDescription')}
0} hasStagedChanges={stagedChangeEntries.length > 0} uncommittedFileCount={status?.files?.length ?? 0} onChanged={async (change) => { if (currentDirectory && change?.affectsIndex) { bumpIndexRevision(currentDirectory); } await refreshStatusAndBranches(false); await refreshLog(); }} /> {t('gitView.gitmoji.title')} {t('gitView.gitmoji.empty')} {(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 && ( )}
); };