Feat: add push to and pull from git with remote selection, along with rebase and merge options (#345)

* Add getRemotes API endpoint

- Add getRemotes() function to git-service.js using simple-git's getRemotes(true)
- Returns array of {name, fetchUrl, pushUrl} for each remote
- Add GET /api/git/remotes endpoint to server/index.js
- Follows existing patterns for git endpoints (directory query param, error handling)

* Add merge and rebase API endpoints

- Add rebase(), abortRebase(), merge(), abortMerge() to git-service.js
- Add POST /api/git/rebase, /api/git/rebase/abort endpoints
- Add POST /api/git/merge, /api/git/merge/abort endpoints
- All functions return { success, conflict?, conflictFiles? }
- Conflict detection via error message parsing and git status

* Add client API functions for git remotes, merge, and rebase

- Added GitRemote, GitMergeResult, GitRebaseResult interfaces to types.ts
- Added getRemotes(), rebase(), abortRebase(), merge(), abortMerge() to gitApiHttp.ts
- Added corresponding exports and runtime wrappers to gitApi.ts
- All functions follow existing patterns with proper error handling
- Lint and type-check pass

* feat(git): add remote selection dropdown to SyncActions

- Add remotes prop to SyncActions component
- Change callbacks to accept GitRemote parameter
- Show dropdown menu when multiple remotes exist
- Execute immediately for single remote repos
- Display remote name and fetch URL in dropdown items

* feat: add BranchIntegrationSection component

- Branch selector dropdown (local + remote branches)
- Merge and Rebase buttons with loading states
- Props: currentBranch, localBranches, remoteBranches, onMerge, onRebase, disabled, isOperating
- Follows existing UI patterns (Command + DropdownMenu)
- Tooltips for all interactive elements

* Add ConflictDialog component for merge/rebase conflicts

- Shows when merge/rebase returns conflict
- Three action options: Resolve in New Session, Abort, Continue Later
- Resolve in New Session opens OpenChamber session in conflict directory
- Displays list of conflicted files
- Uses theme tokens for colors
- Follows existing dialog patterns from AboutDialog.tsx

* Integrate git remote selection and branch operations into GitView

- Fetch remotes on mount and store in state
- Pass remotes to SyncActions and update handleSyncAction to accept GitRemote parameter
- Add BranchIntegrationSection component below sync actions for merge/rebase operations
- Add ConflictDialog to handle merge/rebase conflicts with option to resolve in new session
- Export BranchIntegrationSection and ConflictDialog from git/index.ts
- Update GitHeader to accept remotes prop and pass to SyncActions
- Handle single vs multiple remote scenarios (immediate action vs dropdown)
- Fix React hooks exhaustive-deps warnings by capturing status in local variable

* fix: add missing git API methods to web and vscode packages

* feat: extend VSCode bridge with git remote/rebase/merge endpoints

* feat: add stash support for git operations across UI and API

* hive(01-add-types-for-conflict-details): Added MergeConflictDetails interface to packages/u

* hive(02-add-server-side-conflict-details-function): Added `getConflictDetails(directory)` function to

* hive(03-add-server-endpoint-for-conflict-details): Added GET /api/git/conflict-details endpoint to pa

* hive(04-add-client-side-api-for-conflict-details): Added client-side API for conflict details:

1. **

* hive(05-enhance-conflictdialog-with-rich-context): Enhanced ConflictDialog to fetch and use rich conf

* hive(06-add-state-persistence-for-conflicts): Added state persistence for merge/rebase conflicts

* feat: add conflict details API and AI resolve flow

* fix: improve focus handling in git UI and adjust web dev server port

* feat: add continue merge/rebase support and logs

* fix: address bugs in git merge/rebase feature

- Add explicit parentheses to hasUnresolvedConflicts logic for clarity
- Add error handling for stash operation in handleStashAndRetry
- Fix SSH key path escaping on Windows by normalizing before validation

* fix: add default value for remotes prop to prevent crash

When remotes is undefined, accessing .length throws TypeError.
Add default empty array to handle undefined case gracefully.

* fix: replace DialogFooter with plain div for proper button layout

DialogFooter's default flex-col-reverse and sm:flex-row styles
were conflicting with the intended vertical button stack layout,
causing buttons to not display properly.

* Fix lint erorr

* fix: remove duplicate BranchIntegrationSection and fix broken vscode bridge

- Remove duplicate BranchIntegrationSection from GitView.tsx (already in GitHeader)
- Fix vscode bridge calling non-existent ensureOpenChamberIgnored function
  (legacy worktree function was removed, make api:git/ignore-openchamber a no-op)

* fix: handleResolveWithAIFromBanner now properly detects conflicts from status

The function was checking conflictFiles state which may be empty when
the banner is shown. Now it extracts conflict files directly from the
git status (files with 'U' status) and properly sets up the conflict
dialog state before opening it.
This commit is contained in:
gsxdsm
2026-02-07 11:33:17 +02:00
committed by GitHub
parent 57cc3325d8
commit 9534e3d016
24 changed files with 3324 additions and 265 deletions
+596 -135
View File
@@ -43,11 +43,17 @@ import { ChangesSection } from './git/ChangesSection';
import { CommitSection } from './git/CommitSection';
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 type { OperationLogEntry } from './git/BranchIntegrationSection';
import type { GitRemote } from '@/lib/gitApi';
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
type BranchOperation = 'merge' | 'rebase' | null;
type GitViewSnapshot = {
@@ -357,6 +363,67 @@ export const GitView: React.FC = () => {
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
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]);
// 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) => {
navigator.clipboard
@@ -443,6 +510,14 @@ export const GitView: React.FC = () => {
git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null));
}, [currentDirectory, git]);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemotes) {
setRemotes([]);
return;
}
git.getRemotes(currentDirectory).then(setRemotes).catch(() => setRemotes([]));
}, [currentDirectory, git]);
React.useEffect(() => {
if (!settingsGitmojiEnabled) {
setGitmojiEmojis([]);
@@ -567,7 +642,7 @@ export const GitView: React.FC = () => {
};
}, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
const changeEntries = React.useMemo(() => {
const changeEntries = React.useMemo(() => {
if (!status) return [];
const files = status.files ?? [];
const unique = new Map<string, (typeof files)[number]>();
@@ -603,22 +678,22 @@ export const GitView: React.FC = () => {
});
}, [status, changeEntries, hasUserAdjustedSelection]);
const handleSyncAction = async (action: Exclude<SyncAction, null>) => {
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote: GitRemote) => {
if (!currentDirectory) return;
setSyncAction(action);
try {
if (action === 'fetch') {
await git.gitFetch(currentDirectory);
toast.success('Fetched latest updates');
await git.gitFetch(currentDirectory, { remote: remote.name });
toast.success(`Fetched from ${remote.name}`);
} else if (action === 'pull') {
const result = await git.gitPull(currentDirectory);
const result = await git.gitPull(currentDirectory, { remote: remote.name });
toast.success(
`Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'}`
`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 remote');
await git.gitPush(currentDirectory, { remote: remote.name });
toast.success(`Pushed to ${remote.name}`);
}
await refreshStatusAndBranches(false);
@@ -1017,6 +1092,343 @@ export const GitView: React.FC = () => {
[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 (
<div className="flex h-full items-center justify-center px-4 text-center">
@@ -1060,9 +1472,10 @@ export const GitView: React.FC = () => {
remoteBranches={remoteBranches}
branchInfo={branches?.branches}
syncAction={syncAction}
onFetch={() => handleSyncAction('fetch')}
onPull={() => handleSyncAction('pull')}
onPush={() => handleSyncAction('push')}
remotes={remotes}
onFetch={(remote) => handleSyncAction('fetch', remote)}
onPull={(remote) => handleSyncAction('pull', remote)}
onPush={(remote) => handleSyncAction('push', remote)}
onCheckoutBranch={handleCheckoutBranch}
onCreateBranch={handleCreateBranch}
onRenameBranch={handleRenameBranch}
@@ -1071,141 +1484,189 @@ export const GitView: React.FC = () => {
onSelectIdentity={handleApplyIdentity}
isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata}
onMerge={handleMerge}
onRebase={handleRebase}
branchOperation={branchOperation}
operationLogs={operationLogs}
onOperationComplete={handleOperationComplete}
isBusy={isBusy}
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
/>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
<div className="flex flex-col gap-3">
{/* Two-column layout on large screens: Changes + Commit */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{hasChanges ? (
<ChangesSection
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
) : (
<div className="lg:col-span-2 flex justify-center">
<GitEmptyState
behind={status?.behind ?? 0}
onPull={() => handleSyncAction('pull')}
isPulling={syncAction === 'pull'}
/>
</div>
)}
{/* In-progress operation banner */}
{currentDirectory && (
(status?.mergeInProgress?.head) ||
(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto)
) && (
<InProgressOperationBanner
mergeInProgress={status?.mergeInProgress}
rebaseInProgress={status?.rebaseInProgress}
onContinue={handleContinueOperation}
onAbort={handleAbortOperation}
onResolveWithAI={handleResolveWithAIFromBanner}
hasUnresolvedConflicts={hasUnresolvedConflicts}
isLoading={isLoading}
/>
)}
{changeEntries.length > 0 && (
<CommitSection
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
)}
</div>
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
<IntegrateCommitsSection
repoRoot={repoRootForIntegrate}
sourceBranch={sourceBranchForIntegrate}
worktreeMetadata={worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
<div className="flex flex-col gap-3">
{/* Two-column layout on large screens: Changes + Commit */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{hasChanges ? (
<ChangesSection
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onRevertFile={handleRevertFile}
/>
) : null}
) : (
<div className="lg:col-span-2 flex justify-center">
<GitEmptyState
behind={status?.behind ?? 0}
onPull={() => {
if (remotes.length > 0) {
handleSyncAction('pull', remotes[0]);
} else {
toast.error('No remotes configured');
}
}}
isPulling={syncAction === 'pull'}
/>
</div>
)}
{currentDirectory && status?.current && status?.tracking ? (
<PullRequestSection
directory={currentDirectory}
branch={status.current}
baseBranch={baseBranch}
{changeEntries.length > 0 && (
<CommitSection
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
) : null}
{/* History below, constrained width */}
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
/>
)}
</div>
</ScrollableOverlay>
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
<DialogContent className="max-w-md p-0 overflow-hidden">
<DialogHeader className="px-4 pt-4">
<DialogTitle>Pick a gitmoji</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
<CommandInput
placeholder="Search gitmojis..."
value={gitmojiSearch}
onValueChange={setGitmojiSearch}
/>
<CommandList>
<CommandEmpty>No gitmojis found.</CommandEmpty>
<CommandGroup>
{(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) => (
<CommandItem
key={entry.code}
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
>
<span className="text-lg">{entry.emoji}</span>
<span className="typography-ui-label text-foreground">{entry.code}</span>
<span className="typography-meta text-muted-foreground">{entry.description}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
<IntegrateCommitsSection
repoRoot={repoRootForIntegrate}
sourceBranch={sourceBranchForIntegrate}
worktreeMetadata={worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : null}
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
{currentDirectory && status?.current && status?.tracking ? (
<PullRequestSection
directory={currentDirectory}
branch={status.current}
baseBranch={baseBranch}
/>
) : null}
{/* History below, constrained width */}
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
/>
</div>
</ScrollableOverlay>
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
<DialogContent className="max-w-md p-0 overflow-hidden">
<DialogHeader className="px-4 pt-4">
<DialogTitle>Pick a gitmoji</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
<CommandInput
placeholder="Search gitmojis..."
value={gitmojiSearch}
onValueChange={setGitmojiSearch}
/>
<CommandList>
<CommandEmpty>No gitmojis found.</CommandEmpty>
<CommandGroup>
{(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) => (
<CommandItem
key={entry.code}
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
>
<span className="text-lg">{entry.emoji}</span>
<span className="typography-ui-label text-foreground">{entry.code}</span>
<span className="typography-meta text-muted-foreground">{entry.description}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
{currentDirectory && (
<ConflictDialog
open={conflictDialogOpen}
onOpenChange={setConflictDialogOpen}
conflictFiles={conflictFiles}
directory={currentDirectory}
operation={conflictOperation}
onAbort={handleAbortConflict}
onClearState={clearConflictState}
/>
)}
<StashDialog
open={stashDialogOpen}
onOpenChange={setStashDialogOpen}
operation={stashDialogOperation}
targetBranch={stashDialogBranch}
onConfirm={handleStashAndRetry}
/>
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
/>
</div>
);