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:
@@ -27,3 +27,4 @@ local-dev*
|
||||
*.sln
|
||||
*.sw?
|
||||
.opencode/plans/*
|
||||
.hive
|
||||
|
||||
@@ -114,6 +114,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
||||
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
||||
const pendingInputText = useSessionStore((state) => state.pendingInputText);
|
||||
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
|
||||
|
||||
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||
const agents = getVisibleAgents();
|
||||
@@ -418,7 +419,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
let primaryText = '';
|
||||
let primaryAttachments: AttachedFile[] = [];
|
||||
let agentMentionName: string | undefined;
|
||||
const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = [];
|
||||
const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = [];
|
||||
|
||||
// Consume any pending synthetic parts (from conflict resolution, etc.)
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
|
||||
// Process queued messages first
|
||||
for (let i = 0; i < queuedMessages.length; i++) {
|
||||
@@ -483,6 +487,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}
|
||||
|
||||
// Add synthetic parts (from conflict resolution, etc.)
|
||||
if (syntheticParts && syntheticParts.length > 0) {
|
||||
for (const part of syntheticParts) {
|
||||
additionalParts.push({
|
||||
text: part.text,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!primaryText && additionalParts.length === 0) return;
|
||||
|
||||
// Clear queue and input
|
||||
|
||||
@@ -65,10 +65,10 @@ function CommandDialog({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
@@ -76,6 +76,7 @@ function CommandInput({
|
||||
>
|
||||
<RiSearchLine className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-8 w-full rounded-lg bg-transparent py-2 typography-meta outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@@ -85,7 +86,8 @@ function CommandInput({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
CommandInput.displayName = "CommandInput"
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiGitMergeLine,
|
||||
RiGitBranchLine,
|
||||
RiLoader4Line,
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type OperationType = 'merge' | 'rebase';
|
||||
|
||||
export interface OperationLogEntry {
|
||||
message: string;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface BranchIntegrationSectionProps {
|
||||
currentBranch: string | null | undefined;
|
||||
localBranches: string[];
|
||||
remoteBranches: string[];
|
||||
onMerge: (branch: string) => void;
|
||||
onRebase: (branch: string) => void;
|
||||
disabled?: boolean;
|
||||
isOperating?: boolean;
|
||||
operationLogs?: OperationLogEntry[];
|
||||
onOperationComplete?: () => void;
|
||||
}
|
||||
|
||||
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
|
||||
currentBranch,
|
||||
localBranches,
|
||||
remoteBranches,
|
||||
onMerge,
|
||||
onRebase,
|
||||
disabled = false,
|
||||
isOperating = false,
|
||||
operationLogs = [],
|
||||
onOperationComplete,
|
||||
}) => {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [operation, setOperation] = React.useState<OperationType>('merge');
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(null);
|
||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||
const [branchSearch, setBranchSearch] = React.useState('');
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const logContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const isDisabled = disabled || isOperating;
|
||||
|
||||
// Check if operation completed (all logs are done or error)
|
||||
const operationCompleted = operationLogs.length > 0 &&
|
||||
operationLogs.every(log => log.status === 'done' || log.status === 'error');
|
||||
const hasError = operationLogs.some(log => log.status === 'error');
|
||||
|
||||
// Auto-scroll log container when new entries are added
|
||||
React.useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [operationLogs]);
|
||||
|
||||
// Filter branches based on search
|
||||
const filteredLocal = React.useMemo(() => {
|
||||
const term = branchSearch.toLowerCase();
|
||||
const filtered = localBranches.filter((b) => b !== currentBranch);
|
||||
if (!term) return filtered;
|
||||
return filtered.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [branchSearch, localBranches, currentBranch]);
|
||||
|
||||
const filteredRemote = React.useMemo(() => {
|
||||
const term = branchSearch.toLowerCase();
|
||||
if (!term) return remoteBranches;
|
||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [branchSearch, remoteBranches]);
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setDialogOpen(true);
|
||||
setSelectedBranch(null);
|
||||
setOperation('merge');
|
||||
setBranchSearch('');
|
||||
};
|
||||
|
||||
const handleSelectBranch = (branch: string) => {
|
||||
setSelectedBranch(branch);
|
||||
setBranchDropdownOpen(false);
|
||||
setBranchSearch('');
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedBranch) return;
|
||||
|
||||
// Don't close dialog - keep it open to show progress
|
||||
if (operation === 'merge') {
|
||||
onMerge(selectedBranch);
|
||||
} else {
|
||||
onRebase(selectedBranch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Don't allow cancel during operation
|
||||
if (isOperating) return;
|
||||
|
||||
setSelectedBranch(null);
|
||||
setOperation('merge');
|
||||
setBranchSearch('');
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Only allow closing when operation is complete or not started
|
||||
if (isOperating && !operationCompleted) return;
|
||||
|
||||
if (operationCompleted) {
|
||||
onOperationComplete?.();
|
||||
}
|
||||
setSelectedBranch(null);
|
||||
setOperation('merge');
|
||||
setBranchSearch('');
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!branchDropdownOpen) {
|
||||
setBranchSearch('');
|
||||
} else {
|
||||
// Focus the search input when dropdown opens
|
||||
const timer = setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [branchDropdownOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1.5"
|
||||
onClick={handleOpenDialog}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isOperating ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiGitMergeLine className="size-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Integrate</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>
|
||||
Merge or rebase another branch
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
} else {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Integrate Branch</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isOperating ? (
|
||||
operationCompleted ? (
|
||||
hasError ? 'Operation failed' : 'Operation completed'
|
||||
) : (
|
||||
`${operation === 'merge' ? 'Merging' : 'Rebasing'} in progress...`
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Choose how to integrate changes from another branch into{' '}
|
||||
<span className="font-mono text-foreground">{currentBranch || 'current branch'}</span>
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Show operation log when operating */}
|
||||
{isOperating ? (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="rounded-lg border border-border bg-muted/30 p-3 max-h-48 overflow-y-auto"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{operationLogs.map((log, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{log.status === 'running' && (
|
||||
<RiLoader4Line className="size-3.5 animate-spin text-primary" />
|
||||
)}
|
||||
{log.status === 'done' && (
|
||||
<RiCheckLine className="size-3.5 text-success" />
|
||||
)}
|
||||
{log.status === 'error' && (
|
||||
<RiCloseLine className="size-3.5 text-destructive" />
|
||||
)}
|
||||
{log.status === 'pending' && (
|
||||
<div className="size-3.5 rounded-full border border-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'typography-micro',
|
||||
log.status === 'error' && 'text-destructive',
|
||||
log.status === 'done' && 'text-muted-foreground',
|
||||
log.status === 'running' && 'text-foreground',
|
||||
log.status === 'pending' && 'text-muted-foreground/60'
|
||||
)}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{operationCompleted && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{hasError ? 'Close' : 'Done'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Operation Selection */}
|
||||
<div className="space-y-3">
|
||||
<p className="typography-meta text-muted-foreground">Operation</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperation('merge')}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
|
||||
operation === 'merge'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-border/80 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiGitMergeLine className={cn(
|
||||
'size-4',
|
||||
operation === 'merge' ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
<span className={cn(
|
||||
'typography-ui-label',
|
||||
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
Merge
|
||||
</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Combines branches with a merge commit. Preserves history.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperation('rebase')}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
|
||||
operation === 'rebase'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-border/80 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiGitBranchLine className={cn(
|
||||
'size-4',
|
||||
operation === 'rebase' ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
<span className={cn(
|
||||
'typography-ui-label',
|
||||
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
Rebase
|
||||
</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Replays commits on top. Creates linear history.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch Selection */}
|
||||
<div className="space-y-3">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{operation === 'merge' ? 'Branch to merge' : 'Branch to rebase onto'}
|
||||
</p>
|
||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between h-10"
|
||||
>
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
!selectedBranch && 'text-muted-foreground'
|
||||
)}>
|
||||
{selectedBranch || 'Select a branch...'}
|
||||
</span>
|
||||
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
|
||||
<Command>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search branches..."
|
||||
value={branchSearch}
|
||||
onValueChange={setBranchSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No branches found.</CommandEmpty>
|
||||
|
||||
{filteredLocal.length > 0 && (
|
||||
<CommandGroup heading="Local branches">
|
||||
{filteredLocal.map((branch) => (
|
||||
<CommandItem
|
||||
key={`local-${branch}`}
|
||||
onSelect={() => handleSelectBranch(branch)}
|
||||
>
|
||||
<span className="typography-ui-label text-foreground truncate">
|
||||
{branch}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredLocal.length > 0 && filteredRemote.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
|
||||
{filteredRemote.length > 0 && (
|
||||
<CommandGroup heading="Remote branches">
|
||||
{filteredRemote.map((branch) => (
|
||||
<CommandItem
|
||||
key={`remote-${branch}`}
|
||||
onSelect={() => handleSelectBranch(branch)}
|
||||
>
|
||||
<span className="typography-ui-label text-foreground truncate">
|
||||
{branch}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{selectedBranch && (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{operation === 'merge' ? (
|
||||
<>
|
||||
This will merge{' '}
|
||||
<span className="font-mono text-foreground">{selectedBranch}</span>
|
||||
{' '}into{' '}
|
||||
<span className="font-mono text-foreground">{currentBranch}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will rebase{' '}
|
||||
<span className="font-mono text-foreground">{currentBranch}</span>
|
||||
{' '}onto{' '}
|
||||
<span className="font-mono text-foreground">{selectedBranch}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedBranch}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{operation === 'merge' ? (
|
||||
<>
|
||||
<RiGitMergeLine className="size-4" />
|
||||
Merge
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiGitBranchLine className="size-4" />
|
||||
Rebase
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
|
||||
|
||||
interface ConflictDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
conflictFiles?: string[];
|
||||
directory: string;
|
||||
operation: 'merge' | 'rebase';
|
||||
onAbort: () => void;
|
||||
onClearState?: () => void;
|
||||
}
|
||||
|
||||
export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
conflictFiles = [],
|
||||
directory,
|
||||
operation,
|
||||
onAbort,
|
||||
onClearState,
|
||||
}) => {
|
||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
||||
const setPendingSyntheticParts = useSessionStore((state) => state.setPendingSyntheticParts);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [conflictDetails, setConflictDetails] = React.useState<MergeConflictDetails | null>(null);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
|
||||
// Fetch conflict details when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (!open || !directory) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
setConflictDetails(null);
|
||||
|
||||
getConflictDetails(directory)
|
||||
.then((details) => {
|
||||
setConflictDetails(details);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load conflict details';
|
||||
setLoadError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [open, directory]);
|
||||
|
||||
const buildConflictContext = React.useCallback((): {
|
||||
visibleText: string;
|
||||
instructionsText: string;
|
||||
payloadText: string;
|
||||
} | null => {
|
||||
if (!conflictDetails) return null;
|
||||
|
||||
const operationLabel = operation === 'merge' ? 'merge' : 'rebase';
|
||||
const headRef = conflictDetails.headInfo || (operation === 'merge' ? 'MERGE_HEAD' : 'REBASE_HEAD');
|
||||
const continueCmd = operation === 'merge' ? 'git commit --no-edit' : 'git rebase --continue';
|
||||
|
||||
const visibleText = `Resolve ${operationLabel} conflicts, stage the resolved files, and complete the ${operationLabel}. Preserve the intent of changes from ${headRef}.`;
|
||||
|
||||
const instructionsText = `Git ${operationLabel} operation is in progress with conflicts.
|
||||
- Directory: ${directory}
|
||||
- Operation: ${operation}
|
||||
- Head Info: ${conflictDetails.headInfo || 'N/A'}
|
||||
|
||||
Required steps:
|
||||
1. Read each conflicted file to understand the conflict markers (<<<<<<< HEAD, =======, >>>>>>> ...)
|
||||
2. Edit each file to resolve conflicts by choosing the correct code or merging both changes appropriately
|
||||
3. Stage all resolved files with: git add <file>
|
||||
4. Complete the ${operationLabel} with: ${continueCmd}
|
||||
|
||||
Important:
|
||||
- Remove ALL conflict markers from files (<<<<<<< HEAD, =======, >>>>>>>)
|
||||
- Make sure the final code is syntactically correct and preserves intent from both sides
|
||||
- Do not leave any files with unresolved conflict markers
|
||||
- After completing all steps, confirm the ${operationLabel} was successful
|
||||
`;
|
||||
|
||||
const payloadText = `${operationLabel} conflict context (JSON)\n${JSON.stringify(
|
||||
{
|
||||
directory,
|
||||
operation: conflictDetails.operation,
|
||||
headInfo: conflictDetails.headInfo,
|
||||
statusPorcelain: conflictDetails.statusPorcelain,
|
||||
unmergedFiles: conflictDetails.unmergedFiles,
|
||||
diff: conflictDetails.diff,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`;
|
||||
|
||||
return { visibleText, instructionsText, payloadText };
|
||||
}, [conflictDetails, directory, operation]);
|
||||
|
||||
const handleAbort = () => {
|
||||
onAbort();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleContinueLater = () => {
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleResolveInCurrentSession = () => {
|
||||
const context = buildConflictContext();
|
||||
if (!context) {
|
||||
toast.error('No conflict details available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
toast.error('No active session', { description: 'Open a chat session first or use "New Session".' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the visible text in the input and the synthetic parts for when user sends
|
||||
setPendingInputText(context.visibleText, 'replace');
|
||||
setPendingSyntheticParts([
|
||||
{ text: context.instructionsText, synthetic: true },
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
]);
|
||||
|
||||
setActiveMainTab('chat');
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleResolveInNewSession = () => {
|
||||
const context = buildConflictContext();
|
||||
if (!context) {
|
||||
toast.error('No conflict details available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Open new session with the conflict context as initial prompt + synthetic parts
|
||||
openNewSessionDraft({
|
||||
directoryOverride: directory,
|
||||
initialPrompt: context.visibleText,
|
||||
syntheticParts: [
|
||||
{ text: context.instructionsText, synthetic: true },
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
],
|
||||
});
|
||||
// Navigate to chat tab so user sees the new session
|
||||
setActiveMainTab('chat');
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
|
||||
const displayFiles = conflictDetails?.unmergedFiles || conflictFiles;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
|
||||
<div className="flex flex-col gap-4 overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiAlertLine className="size-5 shrink-0 text-[var(--status-warning)]" />
|
||||
<DialogTitle>{operationLabel} Conflicts Detected</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
The {operation} operation resulted in conflicts that need to be resolved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground">
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
<span className="typography-meta">Loading conflict details...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg bg-[var(--status-error-bg)] p-3 text-[var(--status-error)] typography-meta break-words">
|
||||
Error loading details: {loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayFiles.length > 0 && (
|
||||
<div className="space-y-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="typography-meta text-muted-foreground">Conflicted files:</p>
|
||||
<span className="typography-micro px-1.5 py-0.5 rounded bg-[var(--surface-elevated)] text-muted-foreground">
|
||||
{displayFiles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-[var(--surface-elevated)] rounded-lg p-3 max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
<ul className="space-y-1">
|
||||
{displayFiles.map((file, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="typography-micro text-foreground font-mono truncate block"
|
||||
title={file}
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflictDetails?.headInfo && (
|
||||
<div className="space-y-1 overflow-hidden">
|
||||
<p className="typography-meta text-muted-foreground">Head information:</p>
|
||||
<div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap">
|
||||
{conflictDetails.headInfo}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleResolveInNewSession}
|
||||
disabled={isLoading || !conflictDetails}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiAddLine className="size-4" />
|
||||
)}
|
||||
Resolve in New Session
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleResolveInCurrentSession()}
|
||||
disabled={isLoading || !conflictDetails || !currentSessionId}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiChat1Line className="size-4" />
|
||||
)}
|
||||
Resolve in Current Session
|
||||
</Button>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={handleContinueLater} className="flex-1">
|
||||
Continue Later
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleAbort} className="flex-1 text-[var(--status-error)]">
|
||||
Abort {operationLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -24,9 +24,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { BranchSelector } from './BranchSelector';
|
||||
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
||||
import { SyncActions } from './SyncActions';
|
||||
import type { GitStatus, GitIdentityProfile } from '@/lib/api/types';
|
||||
import { BranchIntegrationSection, type OperationLogEntry } from './BranchIntegrationSection';
|
||||
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
type BranchOperation = 'merge' | 'rebase' | null;
|
||||
|
||||
interface GitHeaderProps {
|
||||
status: GitStatus | null;
|
||||
@@ -34,9 +36,10 @@ interface GitHeaderProps {
|
||||
remoteBranches: string[];
|
||||
branchInfo: Record<string, { ahead?: number; behind?: number }> | undefined;
|
||||
syncAction: SyncAction;
|
||||
onFetch: () => void;
|
||||
onPull: () => void;
|
||||
onPush: () => void;
|
||||
remotes: GitRemote[];
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: (remote: GitRemote) => void;
|
||||
onCheckoutBranch: (branch: string) => void;
|
||||
onCreateBranch: (name: string) => Promise<void>;
|
||||
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
|
||||
@@ -45,6 +48,13 @@ interface GitHeaderProps {
|
||||
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
||||
isApplyingIdentity: boolean;
|
||||
isWorktreeMode: boolean;
|
||||
// Branch integration (merge/rebase)
|
||||
onMerge: (branch: string) => void;
|
||||
onRebase: (branch: string) => void;
|
||||
branchOperation: BranchOperation;
|
||||
operationLogs: OperationLogEntry[];
|
||||
onOperationComplete: () => void;
|
||||
isBusy: boolean;
|
||||
onOpenBranchPicker?: () => void;
|
||||
}
|
||||
|
||||
@@ -186,6 +196,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
remoteBranches,
|
||||
branchInfo,
|
||||
syncAction,
|
||||
remotes,
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -197,6 +208,12 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
onSelectIdentity,
|
||||
isApplyingIdentity,
|
||||
isWorktreeMode,
|
||||
onMerge,
|
||||
onRebase,
|
||||
branchOperation,
|
||||
operationLogs,
|
||||
onOperationComplete,
|
||||
isBusy,
|
||||
onOpenBranchPicker,
|
||||
}) => {
|
||||
if (!status) {
|
||||
@@ -247,12 +264,27 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
<SyncActions
|
||||
syncAction={syncAction}
|
||||
remotes={remotes}
|
||||
onFetch={onFetch}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
disabled={!status}
|
||||
/>
|
||||
|
||||
<div className="h-4 w-px bg-border/60" />
|
||||
|
||||
<BranchIntegrationSection
|
||||
currentBranch={status?.current}
|
||||
localBranches={localBranches}
|
||||
remoteBranches={remoteBranches}
|
||||
onMerge={onMerge}
|
||||
onRebase={onRebase}
|
||||
disabled={isBusy}
|
||||
isOperating={branchOperation !== null}
|
||||
operationLogs={operationLogs}
|
||||
onOperationComplete={onOperationComplete}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{onOpenBranchPicker ? (
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiGitMergeLine,
|
||||
RiGitBranchLine,
|
||||
RiLoader4Line,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiSparklingLine,
|
||||
} from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { GitMergeInProgress, GitRebaseInProgress } from '@/lib/api/types';
|
||||
|
||||
interface InProgressOperationBannerProps {
|
||||
mergeInProgress: GitMergeInProgress | null | undefined;
|
||||
rebaseInProgress: GitRebaseInProgress | null | undefined;
|
||||
onContinue: () => Promise<void>;
|
||||
onAbort: () => Promise<void>;
|
||||
onResolveWithAI?: () => void;
|
||||
hasUnresolvedConflicts?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps> = ({
|
||||
mergeInProgress,
|
||||
rebaseInProgress,
|
||||
onContinue,
|
||||
onAbort,
|
||||
onResolveWithAI,
|
||||
hasUnresolvedConflicts = false,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [processingAction, setProcessingAction] = React.useState<'continue' | 'abort' | null>(null);
|
||||
|
||||
// Only show banner if we have actual in-progress operation data
|
||||
const hasMergeInProgress = mergeInProgress && mergeInProgress.head;
|
||||
const hasRebaseInProgress = rebaseInProgress && (rebaseInProgress.headName || rebaseInProgress.onto);
|
||||
const operation = hasMergeInProgress ? 'merge' : hasRebaseInProgress ? 'rebase' : null;
|
||||
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleContinue = async () => {
|
||||
setProcessingAction('continue');
|
||||
try {
|
||||
await onContinue();
|
||||
} finally {
|
||||
setProcessingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
setProcessingAction('abort');
|
||||
try {
|
||||
await onAbort();
|
||||
} finally {
|
||||
setProcessingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isProcessing = processingAction !== null;
|
||||
|
||||
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
|
||||
const OperationIcon = operation === 'merge' ? RiGitMergeLine : RiGitBranchLine;
|
||||
|
||||
// Build description
|
||||
let description = '';
|
||||
if (mergeInProgress) {
|
||||
description = mergeInProgress.message
|
||||
? `Merging: ${mergeInProgress.message}`
|
||||
: `Merge in progress (${mergeInProgress.head})`;
|
||||
} else if (rebaseInProgress) {
|
||||
description = rebaseInProgress.headName
|
||||
? `Rebasing ${rebaseInProgress.headName} onto ${rebaseInProgress.onto}`
|
||||
: `Rebase in progress`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--status-warning-bg)] border border-[var(--status-warning)] rounded-lg p-3 mx-3 mt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<OperationIcon className="size-4 text-[var(--status-warning)] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="typography-label text-[var(--status-warning)]">
|
||||
{operationLabel} in Progress
|
||||
</p>
|
||||
{description && (
|
||||
<p className="typography-micro text-muted-foreground truncate">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasUnresolvedConflicts && onResolveWithAI && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onResolveWithAI}
|
||||
disabled={isProcessing || isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RiSparklingLine className="size-4" />
|
||||
Resolve with AI
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{processingAction !== 'continue' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAbort}
|
||||
disabled={isProcessing || isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{processingAction === 'abort' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiCloseLine className="size-4" />
|
||||
)}
|
||||
Abort
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!hasUnresolvedConflicts && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleContinue}
|
||||
disabled={isProcessing || isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{processingAction === 'continue' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiCheckLine className="size-4" />
|
||||
)}
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasUnresolvedConflicts && (
|
||||
<p className="typography-micro text-[var(--status-warning)] mt-2">
|
||||
Conflicts must be resolved before continuing. Use "Resolve with AI" or resolve manually, then stage changes and click Continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { RiArrowDownSLine, RiLoader4Line, RiSplitCellsHorizontal } from '@remixicon/react';
|
||||
import { RiArrowDownSLine, RiLoader4Line, RiSplitCellsHorizontal, RiSparklingLine } from '@remixicon/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -20,8 +21,6 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useMessageStore } from '@/stores/messageStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
@@ -65,12 +64,24 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
|
||||
React.useEffect(() => {
|
||||
setTargetBranch(defaultTargetBranch);
|
||||
}, [defaultTargetBranch]);
|
||||
|
||||
// Focus search input when branch dropdown opens
|
||||
React.useEffect(() => {
|
||||
if (branchDropdownOpen) {
|
||||
const timer = setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [branchDropdownOpen]);
|
||||
|
||||
const isEligible = Boolean(
|
||||
repoRoot && sourceBranch && targetBranch && targetBranch !== 'HEAD' && sourceBranch !== targetBranch
|
||||
);
|
||||
@@ -171,32 +182,30 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
[currentSessionId, worktreeMetadata]
|
||||
);
|
||||
|
||||
const handleResolveWithAi = React.useCallback(async (payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => {
|
||||
setActiveMainTab('chat');
|
||||
if (!currentSessionId) {
|
||||
toast.error('No active session', { description: 'Open a chat session first.' });
|
||||
return;
|
||||
}
|
||||
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
||||
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
||||
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
||||
const modelID = currentModelId || lastUsedProvider?.modelID;
|
||||
if (!providerID || !modelID) {
|
||||
toast.error('No model selected');
|
||||
return;
|
||||
}
|
||||
const openNewSessionDraft = useSessionStore((s) => s.openNewSessionDraft);
|
||||
|
||||
const visibleText = `Resolve cherry-pick conflicts and keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}. After edits, report if I can continue process.`;
|
||||
const instructionsText = `Worktree commit integration is in progress.
|
||||
const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => {
|
||||
const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`;
|
||||
const instructionsText = `Worktree commit integration (cherry-pick) is in progress with conflicts.
|
||||
- Repo root: ${payload.state.repoRoot}
|
||||
- Temp target worktree: ${payload.state.tempWorktreePath}
|
||||
- Source branch: ${payload.state.sourceBranch}
|
||||
- Target branch: ${payload.state.targetBranch}
|
||||
- Current commit: ${payload.state.currentCommit}
|
||||
|
||||
Goal:
|
||||
- Resolve conflicts inside the temp target worktree directory.
|
||||
- Do NOT change intent of the commit being applied.
|
||||
- After edits, say whether I can click "Continue".
|
||||
Required steps:
|
||||
1. Read each conflicted file in the temp worktree to understand the conflict markers (<<<<<<< HEAD, =======, >>>>>>> ...)
|
||||
2. Edit each file to resolve conflicts by choosing the correct code or merging both changes appropriately
|
||||
3. Stage all resolved files with: git add <file>
|
||||
4. Complete the cherry-pick with: git cherry-pick --continue
|
||||
|
||||
Important:
|
||||
- Work inside the temp worktree directory: ${payload.state.tempWorktreePath}
|
||||
- Remove ALL conflict markers from files (<<<<<<< HEAD, =======, >>>>>>>)
|
||||
- Preserve the intent of the commit being applied
|
||||
- Make sure the final code is syntactically correct
|
||||
- Do not leave any files with unresolved conflict markers
|
||||
- After completing all steps, confirm the cherry-pick was successful
|
||||
`;
|
||||
const payloadText = `Cherry-pick conflict context (JSON)\n${JSON.stringify({
|
||||
repoRoot: payload.state.repoRoot,
|
||||
@@ -212,24 +221,46 @@ Goal:
|
||||
diff: payload.details.diff,
|
||||
}, null, 2)}`;
|
||||
|
||||
void useMessageStore.getState().sendMessage(
|
||||
visibleText,
|
||||
providerID,
|
||||
modelID,
|
||||
currentAgentName ?? undefined,
|
||||
currentSessionId,
|
||||
undefined,
|
||||
null,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: payloadText, synthetic: true },
|
||||
],
|
||||
currentVariant
|
||||
).catch((e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Failed to send message', { description: message });
|
||||
});
|
||||
}, [currentSessionId, setActiveMainTab]);
|
||||
return { visibleText, instructionsText, payloadText };
|
||||
}, []);
|
||||
|
||||
const setPendingInputText = useSessionStore((s) => s.setPendingInputText);
|
||||
const setPendingSyntheticParts = useSessionStore((s) => s.setPendingSyntheticParts);
|
||||
|
||||
const handleResolveWithAi = React.useCallback((
|
||||
payload: { state: IntegrateInProgress; details: IntegrateConflictDetails },
|
||||
useNewSession: boolean
|
||||
) => {
|
||||
const context = buildConflictContext(payload);
|
||||
|
||||
if (useNewSession) {
|
||||
// Open new session with the conflict context as initial prompt + synthetic parts
|
||||
openNewSessionDraft({
|
||||
directoryOverride: payload.state.tempWorktreePath,
|
||||
initialPrompt: context.visibleText,
|
||||
syntheticParts: [
|
||||
{ text: context.instructionsText, synthetic: true },
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
],
|
||||
});
|
||||
// Navigate to chat tab so user sees the new session
|
||||
setActiveMainTab('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use current session - set pending input text and synthetic parts
|
||||
if (!currentSessionId) {
|
||||
toast.error('No active session', { description: 'Open a chat session first or use "New Session".' });
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingInputText(context.visibleText, 'replace');
|
||||
setPendingSyntheticParts([
|
||||
{ text: context.instructionsText, synthetic: true },
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
]);
|
||||
setActiveMainTab('chat');
|
||||
}, [currentSessionId, setActiveMainTab, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts]);
|
||||
|
||||
const handleMove = React.useCallback(async () => {
|
||||
if (ui.kind !== 'ready') return;
|
||||
@@ -345,7 +376,7 @@ Goal:
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
Target
|
||||
@@ -358,7 +389,7 @@ Goal:
|
||||
className="w-72 p-0 max-h-(--radix-dropdown-menu-content-available-height) flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
<CommandInput placeholder="Search branches..." />
|
||||
<CommandInput ref={searchInputRef} placeholder="Search branches..." />
|
||||
<CommandList
|
||||
className="h-full min-h-0"
|
||||
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
|
||||
@@ -373,6 +404,7 @@ Goal:
|
||||
onSelect={() => {
|
||||
setTargetBranch(branch);
|
||||
persistTarget(branch);
|
||||
setBranchDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{branch}
|
||||
@@ -465,10 +497,21 @@ Goal:
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 px-2 py-0 typography-meta"
|
||||
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details })}
|
||||
className="h-7 px-2 py-0 typography-meta gap-1"
|
||||
disabled={!currentSessionId}
|
||||
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details }, false)}
|
||||
>
|
||||
Resolve with AI
|
||||
<RiSparklingLine className="size-3.5" />
|
||||
Current Session
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 px-2 py-0 typography-meta gap-1"
|
||||
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details }, true)}
|
||||
>
|
||||
<RiSparklingLine className="size-3.5" />
|
||||
New Session
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 px-2 py-0 typography-meta" onClick={() => void handleContinue()}>
|
||||
Continue
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from '@/components/ui';
|
||||
import { RiAlertLine, RiLoader4Line } from '@remixicon/react';
|
||||
|
||||
interface StashDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
operation: 'merge' | 'rebase';
|
||||
targetBranch: string;
|
||||
onConfirm: (restoreAfter: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const StashDialog: React.FC<StashDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
operation,
|
||||
targetBranch,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [restoreAfter, setRestoreAfter] = React.useState(true);
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
|
||||
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await onConfirm(restoreAfter);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
// Show error to user - parent may also handle it but user should see feedback
|
||||
const message = err instanceof Error ? err.message : `Failed to ${operation}`;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isProcessing) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiAlertLine className="size-5 text-[var(--status-warning)]" />
|
||||
<DialogTitle>Uncommitted Changes</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
You have uncommitted changes that would be overwritten by {operation}.
|
||||
Would you like to stash them temporarily?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<p className="typography-meta text-muted-foreground mb-3">
|
||||
This will:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
|
||||
<li>Stash your uncommitted changes</li>
|
||||
<li>{operationLabel} <span className="font-mono text-primary">{targetBranch}</span></li>
|
||||
{restoreAfter && <li>Restore your stashed changes</li>}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Checkbox
|
||||
checked={restoreAfter}
|
||||
onChange={setRestoreAfter}
|
||||
disabled={isProcessing}
|
||||
ariaLabel="Restore changes after operation"
|
||||
/>
|
||||
<span
|
||||
className="typography-ui-label text-foreground cursor-pointer select-none"
|
||||
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
|
||||
>
|
||||
Restore changes after {operation}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={isProcessing}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
`Stash & ${operationLabel}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -7,87 +7,186 @@ import {
|
||||
} from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { GitRemote } from '@/lib/gitApi';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
|
||||
interface SyncActionsProps {
|
||||
syncAction: SyncAction;
|
||||
onFetch: () => void;
|
||||
onPull: () => void;
|
||||
onPush: () => void;
|
||||
remotes: GitRemote[];
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: (remote: GitRemote) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||
syncAction,
|
||||
remotes = [],
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
disabled,
|
||||
}) => {
|
||||
const isDisabled = disabled || syncAction !== null;
|
||||
const hasNoRemotes = remotes.length === 0;
|
||||
const isDisabled = disabled || syncAction !== null || hasNoRemotes;
|
||||
const hasMultipleRemotes = remotes.length > 1;
|
||||
|
||||
const handleFetch = () => {
|
||||
const remote = remotes[0];
|
||||
if (remotes.length === 1 && remote) {
|
||||
onFetch(remote);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = () => {
|
||||
const remote = remotes[0];
|
||||
if (remotes.length === 1 && remote) {
|
||||
onPull(remote);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = () => {
|
||||
const remote = remotes[0];
|
||||
if (remotes.length === 1 && remote) {
|
||||
onPush(remote);
|
||||
}
|
||||
};
|
||||
|
||||
const renderButton = (
|
||||
action: SyncAction,
|
||||
icon: React.ReactNode,
|
||||
loadingIcon: React.ReactNode,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
tooltipText: string
|
||||
) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === action ? loadingIcon : icon}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDropdownButton = (
|
||||
action: SyncAction,
|
||||
icon: React.ReactNode,
|
||||
loadingIcon: React.ReactNode,
|
||||
label: string,
|
||||
onSelect: (remote: GitRemote) => void,
|
||||
tooltipText: string
|
||||
) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === action ? loadingIcon : icon}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="min-w-[200px]">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem key={remote.name} onSelect={() => onSelect(remote)}>
|
||||
<div className="flex flex-col">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{remote.name}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{remote.fetchUrl}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={onFetch}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === 'fetch' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiRefreshLine className="size-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Fetch</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>Fetch from remote</TooltipContent>
|
||||
</Tooltip>
|
||||
{hasMultipleRemotes
|
||||
? renderDropdownButton(
|
||||
'fetch',
|
||||
<RiRefreshLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Fetch',
|
||||
onFetch,
|
||||
'Fetch from remote'
|
||||
)
|
||||
: renderButton(
|
||||
'fetch',
|
||||
<RiRefreshLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Fetch',
|
||||
handleFetch,
|
||||
'Fetch from remote'
|
||||
)}
|
||||
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={onPull}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === 'pull' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiArrowDownLine className="size-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Pull</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>Pull changes</TooltipContent>
|
||||
</Tooltip>
|
||||
{hasMultipleRemotes
|
||||
? renderDropdownButton(
|
||||
'pull',
|
||||
<RiArrowDownLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Pull',
|
||||
onPull,
|
||||
'Pull changes'
|
||||
)
|
||||
: renderButton(
|
||||
'pull',
|
||||
<RiArrowDownLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Pull',
|
||||
handlePull,
|
||||
'Pull changes'
|
||||
)}
|
||||
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={onPush}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === 'push' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiArrowUpLine className="size-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Push</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>Push changes</TooltipContent>
|
||||
</Tooltip>
|
||||
{hasMultipleRemotes
|
||||
? renderDropdownButton(
|
||||
'push',
|
||||
<RiArrowUpLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Push',
|
||||
onPush,
|
||||
'Push changes'
|
||||
)
|
||||
: renderButton(
|
||||
'push',
|
||||
<RiArrowUpLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Push',
|
||||
handlePush,
|
||||
'Push changes'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,3 +9,6 @@ export { HistorySection } from './HistorySection';
|
||||
export { HistoryCommitRow } from './HistoryCommitRow';
|
||||
export { SyncActions } from './SyncActions';
|
||||
export { BranchSelector } from './BranchSelector';
|
||||
export { BranchIntegrationSection } from './BranchIntegrationSection';
|
||||
export { ConflictDialog } from './ConflictDialog';
|
||||
export { StashDialog } from './StashDialog';
|
||||
|
||||
@@ -90,6 +90,20 @@ export interface GitStatusFile {
|
||||
working_dir: string;
|
||||
}
|
||||
|
||||
export interface GitMergeInProgress {
|
||||
/** Short SHA of MERGE_HEAD */
|
||||
head: string;
|
||||
/** First line of MERGE_MSG */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitRebaseInProgress {
|
||||
/** Branch name being rebased */
|
||||
headName: string;
|
||||
/** Short SHA of the onto commit */
|
||||
onto: string;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
current: string;
|
||||
tracking: string | null;
|
||||
@@ -98,6 +112,10 @@ export interface GitStatus {
|
||||
files: GitStatusFile[];
|
||||
isClean: boolean;
|
||||
diffStats?: Record<string, { insertions: number; deletions: number }>;
|
||||
/** Present when a merge is in progress with conflicts */
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
/** Present when a rebase is in progress */
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
}
|
||||
|
||||
export interface GitDiffResponse {
|
||||
@@ -168,6 +186,37 @@ export interface GitPullResult {
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface GitRemote {
|
||||
name: string;
|
||||
fetchUrl: string;
|
||||
pushUrl: string;
|
||||
}
|
||||
|
||||
export interface GitMergeResult {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface GitRebaseResult {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface MergeConflictDetails {
|
||||
/** Git status --porcelain output showing current state */
|
||||
statusPorcelain: string;
|
||||
/** List of unmerged file paths */
|
||||
unmergedFiles: string[];
|
||||
/** Git diff output showing current conflict state */
|
||||
diff: string;
|
||||
/** Information about MERGE_HEAD or REBASE_HEAD */
|
||||
headInfo: string;
|
||||
/** The operation type: 'merge' or 'rebase' */
|
||||
operation: 'merge' | 'rebase';
|
||||
}
|
||||
|
||||
export type GitIdentityAuthType = 'ssh' | 'token';
|
||||
|
||||
export interface GitIdentityProfile {
|
||||
@@ -297,6 +346,16 @@ export interface GitAPI {
|
||||
discoverGitCredentials?(): Promise<DiscoveredGitCredential[]>;
|
||||
getGlobalGitIdentity?(): Promise<GitIdentitySummary | null>;
|
||||
getRemoteUrl?(directory: string, remote?: string): Promise<string | null>;
|
||||
getRemotes(directory: string): Promise<GitRemote[]>;
|
||||
rebase(directory: string, options: { onto: string }): Promise<GitRebaseResult>;
|
||||
abortRebase(directory: string): Promise<{ success: boolean }>;
|
||||
continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>;
|
||||
merge(directory: string, options: { branch: string }): Promise<GitMergeResult>;
|
||||
abortMerge(directory: string): Promise<{ success: boolean }>;
|
||||
continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>;
|
||||
stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>;
|
||||
stashPop(directory: string): Promise<{ success: boolean }>;
|
||||
getConflictDetails(directory: string): Promise<MergeConflictDetails>;
|
||||
}
|
||||
|
||||
export interface FileListEntry {
|
||||
|
||||
@@ -21,6 +21,10 @@ export type {
|
||||
GitDeleteBranchPayload,
|
||||
GitDeleteRemoteBranchPayload,
|
||||
DiscoveredGitCredential,
|
||||
GitRemote,
|
||||
GitMergeResult,
|
||||
GitRebaseResult,
|
||||
MergeConflictDetails,
|
||||
} from './api/types';
|
||||
|
||||
declare global {
|
||||
@@ -262,3 +266,72 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise<
|
||||
if (runtime?.getRemoteUrl) return runtime.getRemoteUrl(directory, remote);
|
||||
return gitHttp.getRemoteUrl(directory, remote);
|
||||
}
|
||||
|
||||
export async function getRemotes(directory: string): Promise<import('./api/types').GitRemote[]> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getRemotes(directory);
|
||||
return gitHttp.getRemotes(directory);
|
||||
}
|
||||
|
||||
export async function rebase(
|
||||
directory: string,
|
||||
options: { onto: string }
|
||||
): Promise<import('./api/types').GitRebaseResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.rebase(directory, options);
|
||||
return gitHttp.rebase(directory, options);
|
||||
}
|
||||
|
||||
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.abortRebase(directory);
|
||||
return gitHttp.abortRebase(directory);
|
||||
}
|
||||
|
||||
export async function merge(
|
||||
directory: string,
|
||||
options: { branch: string }
|
||||
): Promise<import('./api/types').GitMergeResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.merge(directory, options);
|
||||
return gitHttp.merge(directory, options);
|
||||
}
|
||||
|
||||
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.abortMerge(directory);
|
||||
return gitHttp.abortMerge(directory);
|
||||
}
|
||||
|
||||
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.continueRebase(directory);
|
||||
return gitHttp.continueRebase(directory);
|
||||
}
|
||||
|
||||
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.continueMerge(directory);
|
||||
return gitHttp.continueMerge(directory);
|
||||
}
|
||||
|
||||
export async function stash(
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.stash(directory, options);
|
||||
return gitHttp.stash(directory, options);
|
||||
}
|
||||
|
||||
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.stashPop(directory);
|
||||
return gitHttp.stashPop(directory);
|
||||
}
|
||||
|
||||
export async function getConflictDetails(directory: string): Promise<import('./api/types').MergeConflictDetails> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.getConflictDetails) return runtime.getConflictDetails(directory);
|
||||
return gitHttp.getConflictDetails(directory);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
GitIdentityProfile,
|
||||
GitIdentitySummary,
|
||||
DiscoveredGitCredential,
|
||||
MergeConflictDetails,
|
||||
} from './api/types';
|
||||
|
||||
declare global {
|
||||
@@ -562,3 +563,122 @@ export async function getRemoteUrl(directory: string, remote?: string): Promise<
|
||||
const data = await response.json();
|
||||
return data.url ?? null;
|
||||
}
|
||||
|
||||
export async function getRemotes(directory: string): Promise<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/remotes`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get remotes: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function rebase(
|
||||
directory: string,
|
||||
options: { onto: string }
|
||||
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/rebase`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to rebase');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/rebase/abort`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to abort rebase');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function merge(
|
||||
directory: string,
|
||||
options: { branch: string }
|
||||
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/merge`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to merge');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to abort merge');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/rebase/continue`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to continue rebase');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/merge/continue`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to continue merge');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function stash(
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/stash`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to stash');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/stash/pop`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to pop stash');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getConflictDetails(directory: string): Promise<MergeConflictDetails> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/conflict-details`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get conflict details: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -89,11 +89,20 @@ export const getActiveSessionWindow = () => {
|
||||
export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS;
|
||||
export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW;
|
||||
|
||||
/** Synthetic context parts to attach when sending initial message */
|
||||
export interface SyntheticContextPart {
|
||||
text: string;
|
||||
synthetic: true;
|
||||
}
|
||||
|
||||
export type NewSessionDraftState = {
|
||||
open: boolean;
|
||||
directoryOverride: string | null;
|
||||
parentID: string | null;
|
||||
title?: string;
|
||||
initialPrompt?: string;
|
||||
/** Synthetic context parts to include with the initial message */
|
||||
syntheticParts?: SyntheticContextPart[];
|
||||
};
|
||||
|
||||
export interface SessionStore {
|
||||
@@ -157,6 +166,8 @@ export interface SessionStore {
|
||||
|
||||
pendingInputText: string | null;
|
||||
pendingInputMode: 'replace' | 'append';
|
||||
/** Synthetic context parts to include with the next message sent */
|
||||
pendingSyntheticParts: SyntheticContextPart[] | null;
|
||||
|
||||
newSessionDraft: NewSessionDraftState;
|
||||
|
||||
@@ -165,7 +176,7 @@ export interface SessionStore {
|
||||
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
|
||||
loadSessions: () => Promise<void>;
|
||||
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string }) => void;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[] }) => void;
|
||||
closeNewSessionDraft: () => void;
|
||||
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
|
||||
@@ -178,7 +189,7 @@ export interface SessionStore {
|
||||
unshareSession: (id: string) => Promise<Session | null>;
|
||||
setCurrentSession: (id: string | null) => void;
|
||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
|
||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise<void>;
|
||||
abortCurrentOperation: () => Promise<void>;
|
||||
acknowledgeSessionAbort: (sessionId: string) => void;
|
||||
armAbortPrompt: (durationMs?: number) => number | null;
|
||||
@@ -256,4 +267,6 @@ export interface SessionStore {
|
||||
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
|
||||
setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void;
|
||||
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null;
|
||||
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void;
|
||||
consumePendingSyntheticParts: () => SyntheticContextPart[] | null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { devtools } from "zustand/middleware";
|
||||
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
|
||||
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
|
||||
import type { QuestionRequest } from "@/types/question";
|
||||
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
|
||||
import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes";
|
||||
import { getActiveSessionWindow, getMemoryLimits } from "./types/sessionTypes";
|
||||
|
||||
import { useSessionStore as useSessionManagementStore } from "./sessionStore";
|
||||
@@ -103,6 +103,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
userSummaryTitles: new Map(),
|
||||
pendingInputText: null,
|
||||
pendingInputMode: 'replace',
|
||||
pendingSyntheticParts: null,
|
||||
newSessionDraft: { open: true, directoryOverride: null, parentID: null },
|
||||
|
||||
getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => {
|
||||
@@ -135,9 +136,13 @@ export const useSessionStore = create<SessionStore>()(
|
||||
directoryOverride: directory,
|
||||
parentID: options?.parentID ?? null,
|
||||
title: options?.title,
|
||||
initialPrompt: options?.initialPrompt,
|
||||
syntheticParts: options?.syntheticParts,
|
||||
},
|
||||
currentSessionId: null,
|
||||
error: null,
|
||||
// Set pending input text if initialPrompt is provided
|
||||
...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -169,7 +174,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
closeNewSessionDraft: () => {
|
||||
const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId;
|
||||
set({
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined },
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined },
|
||||
currentSessionId: realCurrentSessionId,
|
||||
});
|
||||
},
|
||||
@@ -313,7 +318,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
get().evictLeastRecentlyUsed();
|
||||
},
|
||||
loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
|
||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => {
|
||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => {
|
||||
const draft = get().newSessionDraft;
|
||||
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
||||
|
||||
@@ -384,13 +389,21 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// ignored
|
||||
}
|
||||
|
||||
// Capture synthetic parts before clearing draft
|
||||
const draftSyntheticParts = draft.syntheticParts;
|
||||
|
||||
get().closeNewSessionDraft();
|
||||
setStatus(created.id, 'busy');
|
||||
|
||||
// Merge draft synthetic parts with any additional parts passed to sendMessage
|
||||
const mergedAdditionalParts = draftSyntheticParts?.length
|
||||
? [...(additionalParts || []), ...draftSyntheticParts]
|
||||
: additionalParts;
|
||||
|
||||
try {
|
||||
return await useMessageStore
|
||||
.getState()
|
||||
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts, variant);
|
||||
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant);
|
||||
} catch (error) {
|
||||
setStatus(created.id, 'idle');
|
||||
throw error;
|
||||
@@ -781,6 +794,18 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
return { text, mode };
|
||||
},
|
||||
|
||||
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => {
|
||||
set({ pendingSyntheticParts: parts });
|
||||
},
|
||||
|
||||
consumePendingSyntheticParts: () => {
|
||||
const parts = get().pendingSyntheticParts;
|
||||
if (parts !== null) {
|
||||
set({ pendingSyntheticParts: null });
|
||||
}
|
||||
return parts;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "composed-session-store",
|
||||
|
||||
@@ -2269,6 +2269,97 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/remotes': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.getRemotes(directory);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/rebase': {
|
||||
const { directory, onto } = (payload || {}) as { directory?: string; onto?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
if (!onto) {
|
||||
return { id, type, success: false, error: 'onto is required' };
|
||||
}
|
||||
const result = await gitService.rebase(directory, { onto });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/rebase/abort': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.abortRebase(directory);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/merge': {
|
||||
const { directory, branch } = (payload || {}) as { directory?: string; branch?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
if (!branch) {
|
||||
return { id, type, success: false, error: 'branch is required' };
|
||||
}
|
||||
const result = await gitService.merge(directory, { branch });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/merge/abort': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.abortMerge(directory);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/rebase/continue': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.continueRebase(directory);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/merge/continue': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.continueMerge(directory);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/stash': {
|
||||
const { directory, message, includeUntracked } = (payload || {}) as {
|
||||
directory?: string;
|
||||
message?: string;
|
||||
includeUntracked?: boolean;
|
||||
};
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.stash(directory, { message, includeUntracked });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/stash/pop': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
const result = await gitService.stashPop(directory);
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:git/log': {
|
||||
const { directory, maxCount, from, to, file } = (payload || {}) as {
|
||||
directory?: string;
|
||||
@@ -2408,6 +2499,84 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:git/ignore-openchamber': {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
// This is now a no-op since the function was removed with legacy worktree support.
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
case 'api:git/conflict-details': {
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get git status --porcelain
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const statusPorcelain = statusResult.stdout;
|
||||
|
||||
// Get unmerged files (files with conflicts)
|
||||
const unmergedResult = await execGit(['diff', '--name-only', '--diff-filter=U'], directory);
|
||||
const unmergedFiles = unmergedResult.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Get current diff
|
||||
const diffResult = await execGit(['diff'], directory);
|
||||
const diff = diffResult.stdout;
|
||||
|
||||
// Detect operation type and get head info
|
||||
let operation: 'merge' | 'rebase' = 'merge';
|
||||
let headInfo = '';
|
||||
|
||||
// Check for MERGE_HEAD (merge in progress)
|
||||
const mergeHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory);
|
||||
const mergeHeadExists = mergeHeadResult.exitCode === 0;
|
||||
|
||||
if (mergeHeadExists) {
|
||||
operation = 'merge';
|
||||
const mergeHead = mergeHeadResult.stdout.trim();
|
||||
// Try to read MERGE_MSG file
|
||||
let mergeMsg = '';
|
||||
try {
|
||||
const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG');
|
||||
mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8');
|
||||
} catch {
|
||||
// MERGE_MSG may not exist
|
||||
}
|
||||
headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`;
|
||||
} else {
|
||||
// Check for REBASE_HEAD (rebase in progress)
|
||||
const rebaseHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory);
|
||||
const rebaseHeadExists = rebaseHeadResult.exitCode === 0;
|
||||
|
||||
if (rebaseHeadExists) {
|
||||
operation = 'rebase';
|
||||
const rebaseHead = rebaseHeadResult.stdout.trim();
|
||||
headInfo = `REBASE_HEAD: ${rebaseHead}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
statusPorcelain: statusPorcelain.trim(),
|
||||
unmergedFiles,
|
||||
diff: diff.trim(),
|
||||
headInfo: headInfo.trim(),
|
||||
operation,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { id, type, success: false, error: `Unknown message type: ${type}` };
|
||||
}
|
||||
|
||||
@@ -243,6 +243,20 @@ export interface GitStatusFile {
|
||||
working_dir: string;
|
||||
}
|
||||
|
||||
export interface GitMergeInProgress {
|
||||
/** Short SHA of MERGE_HEAD */
|
||||
head: string;
|
||||
/** First line of MERGE_MSG */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitRebaseInProgress {
|
||||
/** Branch name being rebased */
|
||||
headName: string;
|
||||
/** Short SHA of the onto commit */
|
||||
onto: string;
|
||||
}
|
||||
|
||||
export interface GitStatusResult {
|
||||
current: string;
|
||||
tracking: string | null;
|
||||
@@ -251,6 +265,10 @@ export interface GitStatusResult {
|
||||
files: GitStatusFile[];
|
||||
isClean: boolean;
|
||||
diffStats?: Record<string, { insertions: number; deletions: number }>;
|
||||
/** Present when a merge is in progress with conflicts */
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
/** Present when a rebase is in progress */
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,6 +341,9 @@ export async function getGitStatus(directory: string): Promise<GitStatusResult>
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-progress operations
|
||||
const inProgressState = await checkInProgressOperations(directory);
|
||||
|
||||
return {
|
||||
current: head?.name || '',
|
||||
tracking: head?.upstream ? `${head.upstream.remote}/${head.upstream.name}` : null,
|
||||
@@ -330,9 +351,73 @@ export async function getGitStatus(directory: string): Promise<GitStatusResult>
|
||||
behind: head?.behind || 0,
|
||||
files,
|
||||
isClean: files.length === 0,
|
||||
...inProgressState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for in-progress merge/rebase operations
|
||||
*/
|
||||
async function checkInProgressOperations(directory: string): Promise<{
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
}> {
|
||||
const result: {
|
||||
mergeInProgress?: GitMergeInProgress | null;
|
||||
rebaseInProgress?: GitRebaseInProgress | null;
|
||||
} = {};
|
||||
|
||||
const gitDir = path.join(directory, '.git');
|
||||
|
||||
try {
|
||||
// Check MERGE_HEAD for merge in progress
|
||||
const mergeHeadPath = path.join(gitDir, 'MERGE_HEAD');
|
||||
const mergeHeadExists = await fs.promises.stat(mergeHeadPath).then(() => true).catch(() => false);
|
||||
|
||||
if (mergeHeadExists) {
|
||||
const mergeHead = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => '');
|
||||
const headSha = mergeHead.trim().slice(0, 7);
|
||||
// Only set mergeInProgress if we actually have a valid head SHA
|
||||
if (headSha) {
|
||||
const mergeMsg = await fs.promises.readFile(path.join(gitDir, 'MERGE_MSG'), 'utf8').catch(() => '');
|
||||
result.mergeInProgress = {
|
||||
head: headSha,
|
||||
message: mergeMsg.split('\n')[0] || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
|
||||
const rebaseMergeExists = await fs.promises.stat(path.join(gitDir, 'rebase-merge')).then(() => true).catch(() => false);
|
||||
const rebaseApplyExists = await fs.promises.stat(path.join(gitDir, 'rebase-apply')).then(() => true).catch(() => false);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) {
|
||||
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
|
||||
const headName = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'head-name'), 'utf8').catch(() => '');
|
||||
const onto = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'onto'), 'utf8').catch(() => '');
|
||||
|
||||
const headNameTrimmed = headName.trim().replace('refs/heads/', '');
|
||||
const ontoTrimmed = onto.trim().slice(0, 7);
|
||||
|
||||
// Only set rebaseInProgress if we have valid data
|
||||
if (headNameTrimmed || ontoTrimmed) {
|
||||
result.rebaseInProgress = {
|
||||
headName: headNameTrimmed,
|
||||
onto: ontoTrimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Get git status using raw git commands
|
||||
*/
|
||||
@@ -383,6 +468,9 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-progress operations
|
||||
const inProgressState = await checkInProgressOperations(directory);
|
||||
|
||||
return {
|
||||
current,
|
||||
tracking,
|
||||
@@ -390,6 +478,7 @@ async function getGitStatusRaw(directory: string): Promise<GitStatusResult> {
|
||||
behind,
|
||||
files,
|
||||
isClean: files.length === 0,
|
||||
...inProgressState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1319,3 +1408,227 @@ export async function setGitIdentity(
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============== Remote Operations ==============
|
||||
|
||||
export interface GitRemote {
|
||||
name: string;
|
||||
fetchUrl: string;
|
||||
pushUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of remotes
|
||||
*/
|
||||
export async function getRemotes(directory: string): Promise<GitRemote[]> {
|
||||
const result = await execGit(['remote', '-v'], directory);
|
||||
if (result.exitCode !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const remoteMap = new Map<string, GitRemote>();
|
||||
const lines = result.stdout.split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
||||
if (match) {
|
||||
const [, name, url, type] = match;
|
||||
if (!remoteMap.has(name)) {
|
||||
remoteMap.set(name, { name, fetchUrl: '', pushUrl: '' });
|
||||
}
|
||||
const remote = remoteMap.get(name)!;
|
||||
if (type === 'fetch') {
|
||||
remote.fetchUrl = url;
|
||||
} else {
|
||||
remote.pushUrl = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(remoteMap.values());
|
||||
}
|
||||
|
||||
// ============== Merge & Rebase Operations ==============
|
||||
|
||||
export interface GitMergeResult {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface GitRebaseResult {
|
||||
success: boolean;
|
||||
conflict?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebase current branch onto target
|
||||
*/
|
||||
export async function rebase(
|
||||
directory: string,
|
||||
options: { onto: string }
|
||||
): Promise<GitRebaseResult> {
|
||||
const result = await execGit(['rebase', options.onto], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('could not apply') ||
|
||||
output.includes('merge conflict');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Rebase failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress rebase
|
||||
*/
|
||||
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['rebase', '--abort'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge branch into current
|
||||
*/
|
||||
export async function merge(
|
||||
directory: string,
|
||||
options: { branch: string }
|
||||
): Promise<GitMergeResult> {
|
||||
const result = await execGit(['merge', options.branch], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('merge conflict') ||
|
||||
output.includes('automatic merge failed');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Merge failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress merge
|
||||
*/
|
||||
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['merge', '--abort'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an in-progress rebase after conflicts are resolved
|
||||
*/
|
||||
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
const result = await execGit(['rebase', '--continue'], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('needs merge') ||
|
||||
output.includes('unmerged');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Continue rebase failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an in-progress merge after conflicts are resolved
|
||||
*/
|
||||
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
|
||||
// For merge, we commit after resolving conflicts
|
||||
const result = await execGit(['commit', '--no-edit'], directory);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
const output = (result.stdout + result.stderr).toLowerCase();
|
||||
const isConflict =
|
||||
output.includes('conflict') ||
|
||||
output.includes('needs merge') ||
|
||||
output.includes('unmerged');
|
||||
|
||||
if (isConflict) {
|
||||
const statusResult = await execGit(['status', '--porcelain'], directory);
|
||||
const conflictFiles = statusResult.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD'))
|
||||
.map((line) => line.slice(3).trim());
|
||||
|
||||
return { success: false, conflict: true, conflictFiles };
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || 'Continue merge failed');
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
/**
|
||||
* Stash changes
|
||||
*/
|
||||
export async function stash(
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> {
|
||||
const args = ['stash', 'push'];
|
||||
|
||||
// Include untracked files by default
|
||||
if (options?.includeUntracked !== false) {
|
||||
args.push('--include-untracked');
|
||||
}
|
||||
|
||||
if (options?.message) {
|
||||
args.push('-m', options.message);
|
||||
}
|
||||
|
||||
const result = await execGit(args, directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash
|
||||
*/
|
||||
export async function stashPop(directory: string): Promise<{ success: boolean }> {
|
||||
const result = await execGit(['stash', 'pop'], directory);
|
||||
return { success: result.exitCode === 0 };
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ import type {
|
||||
GitCommitFilesResponse,
|
||||
GitIdentitySummary,
|
||||
GitIdentityProfile,
|
||||
GitRemote,
|
||||
GitRebaseResult,
|
||||
GitMergeResult,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
@@ -220,4 +223,62 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
deleteGitIdentity: async (id: string): Promise<void> => {
|
||||
void id; // Unused for now
|
||||
},
|
||||
|
||||
getRemotes: async (directory: string): Promise<GitRemote[]> => {
|
||||
return sendBridgeMessage<GitRemote[]>('api:git/remotes', { directory });
|
||||
},
|
||||
|
||||
rebase: async (directory: string, options: { onto: string }): Promise<GitRebaseResult> => {
|
||||
return sendBridgeMessage<GitRebaseResult>('api:git/rebase', {
|
||||
directory,
|
||||
onto: options.onto,
|
||||
});
|
||||
},
|
||||
|
||||
abortRebase: async (directory: string): Promise<{ success: boolean }> => {
|
||||
return sendBridgeMessage<{ success: boolean }>('api:git/rebase/abort', { directory });
|
||||
},
|
||||
|
||||
merge: async (directory: string, options: { branch: string }): Promise<GitMergeResult> => {
|
||||
return sendBridgeMessage<GitMergeResult>('api:git/merge', {
|
||||
directory,
|
||||
branch: options.branch,
|
||||
});
|
||||
},
|
||||
|
||||
abortMerge: async (directory: string): Promise<{ success: boolean }> => {
|
||||
return sendBridgeMessage<{ success: boolean }>('api:git/merge/abort', { directory });
|
||||
},
|
||||
|
||||
continueRebase: async (directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> => {
|
||||
return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/rebase/continue', { directory });
|
||||
},
|
||||
|
||||
continueMerge: async (directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> => {
|
||||
return sendBridgeMessage<{ success: boolean; conflict: boolean; conflictFiles?: string[] }>('api:git/merge/continue', { directory });
|
||||
},
|
||||
|
||||
stash: async (
|
||||
directory: string,
|
||||
options?: { message?: string; includeUntracked?: boolean }
|
||||
): Promise<{ success: boolean }> => {
|
||||
return sendBridgeMessage<{ success: boolean }>('api:git/stash', {
|
||||
directory,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
stashPop: async (directory: string): Promise<{ success: boolean }> => {
|
||||
return sendBridgeMessage<{ success: boolean }>('api:git/stash/pop', { directory });
|
||||
},
|
||||
|
||||
getConflictDetails: async (directory: string) => {
|
||||
return sendBridgeMessage<{
|
||||
statusPorcelain: string;
|
||||
unmergedFiles: string[];
|
||||
diff: string;
|
||||
headInfo: string;
|
||||
operation: 'merge' | 'rebase';
|
||||
}>('api:git/conflict-details', { directory });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,4 +97,4 @@
|
||||
"package.json",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7323,6 +7323,166 @@ Context:
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/remotes', async (req, res) => {
|
||||
const { getRemotes } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const remotes = await getRemotes(directory);
|
||||
res.json(remotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to get remotes:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get remotes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/rebase', async (req, res) => {
|
||||
const { rebase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await rebase(directory, req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to rebase:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to rebase' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/rebase/abort', async (req, res) => {
|
||||
const { abortRebase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await abortRebase(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to abort rebase:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to abort rebase' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/merge', async (req, res) => {
|
||||
const { merge } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await merge(directory, req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to merge:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to merge' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/merge/abort', async (req, res) => {
|
||||
const { abortMerge } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await abortMerge(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to abort merge:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to abort merge' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/rebase/continue', async (req, res) => {
|
||||
const { continueRebase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await continueRebase(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue rebase:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to continue rebase' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/merge/continue', async (req, res) => {
|
||||
const { continueMerge } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await continueMerge(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue merge:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to continue merge' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/conflict-details', async (req, res) => {
|
||||
const { getConflictDetails } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await getConflictDetails(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to get conflict details:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get conflict details' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/stash', async (req, res) => {
|
||||
const { stash } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await stash(directory, req.body);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to stash:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to stash' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/stash/pop', async (req, res) => {
|
||||
const { stashPop } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const result = await stashPop(directory);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to pop stash:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to pop stash' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/commit', async (req, res) => {
|
||||
const { commit } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,54 @@ const fsp = fs.promises;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||
|
||||
/**
|
||||
* Escape an SSH key path for use in core.sshCommand.
|
||||
* Handles Windows/Unix differences and prevents command injection.
|
||||
*/
|
||||
function escapeSshKeyPath(sshKeyPath) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Normalize path first on Windows (convert backslashes to forward slashes)
|
||||
let normalizedPath = sshKeyPath;
|
||||
if (isWindows) {
|
||||
normalizedPath = sshKeyPath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Validate: reject paths with characters that could enable injection
|
||||
// Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
|
||||
// Note: backslash is not in this list since we've already normalized Windows paths
|
||||
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
|
||||
if (dangerousChars.test(normalizedPath)) {
|
||||
throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, Git (via MSYS/MinGW) expects Unix-style paths
|
||||
// Convert "C:/path" to "/c/path" for MSYS compatibility
|
||||
let unixPath = normalizedPath;
|
||||
const driveMatch = unixPath.match(/^([A-Za-z]):\//);
|
||||
if (driveMatch) {
|
||||
unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`;
|
||||
}
|
||||
|
||||
// Use single quotes for the path (prevents shell interpretation)
|
||||
return `'${unixPath}'`;
|
||||
} else {
|
||||
// On Unix, use single quotes and escape any single quotes in the path
|
||||
// Single quotes prevent all shell interpretation except for single quotes themselves
|
||||
const escaped = normalizedPath.replace(/'/g, "'\\''");
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSH command string for git config
|
||||
*/
|
||||
function buildSshCommand(sshKeyPath) {
|
||||
const escapedPath = escapeSshKeyPath(sshKeyPath);
|
||||
return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
|
||||
}
|
||||
|
||||
const isSocketPath = async (candidate) => {
|
||||
if (!candidate || typeof candidate !== 'string') {
|
||||
return false;
|
||||
@@ -221,7 +269,7 @@ export async function setLocalIdentity(directory, profile) {
|
||||
if (authType === 'ssh' && profile.sshKey) {
|
||||
await git.addConfig(
|
||||
'core.sshCommand',
|
||||
`ssh -i ${profile.sshKey}`,
|
||||
buildSshCommand(profile.sshKey),
|
||||
false,
|
||||
'local'
|
||||
);
|
||||
@@ -404,6 +452,58 @@ export async function getStatus(directory) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-progress operations
|
||||
let mergeInProgress = null;
|
||||
let rebaseInProgress = null;
|
||||
|
||||
try {
|
||||
// Check MERGE_HEAD for merge in progress
|
||||
const mergeHeadExists = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (mergeHeadExists) {
|
||||
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
||||
const headSha = mergeHead.trim().slice(0, 7);
|
||||
// Only set mergeInProgress if we actually have a valid head SHA
|
||||
if (headSha) {
|
||||
const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
|
||||
mergeInProgress = {
|
||||
head: headSha,
|
||||
message: mergeMsg.split('\n')[0] || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
|
||||
const rebaseMergeExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
|
||||
const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) {
|
||||
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
|
||||
const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => '');
|
||||
const onto = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'onto'), 'utf8').catch(() => '');
|
||||
|
||||
const headNameTrimmed = headName.trim().replace('refs/heads/', '');
|
||||
const ontoTrimmed = onto.trim().slice(0, 7);
|
||||
|
||||
// Only set rebaseInProgress if we have valid data
|
||||
if (headNameTrimmed || ontoTrimmed) {
|
||||
rebaseInProgress = {
|
||||
headName: headNameTrimmed,
|
||||
onto: ontoTrimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
current: status.current,
|
||||
tracking,
|
||||
@@ -416,6 +516,8 @@ export async function getStatus(directory) {
|
||||
})),
|
||||
isClean: status.isClean(),
|
||||
diffStats,
|
||||
mergeInProgress,
|
||||
rebaseInProgress,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
@@ -1201,3 +1303,302 @@ export async function renameBranch(directory, oldName, newName) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemotes(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const remotes = await git.getRemotes(true);
|
||||
|
||||
return remotes.map((remote) => ({
|
||||
name: remote.name,
|
||||
fetchUrl: remote.refs.fetch,
|
||||
pushUrl: remote.refs.push
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get remotes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebase(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const { onto } = options;
|
||||
if (!onto) {
|
||||
throw new Error('onto parameter is required for rebase');
|
||||
}
|
||||
|
||||
await git.rebase([onto]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
conflict: false
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('could not apply') ||
|
||||
errorMessage.includes('merge conflict');
|
||||
|
||||
if (isConflict) {
|
||||
// Get list of conflicted files
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Failed to rebase:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortRebase(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
await git.rebase(['--abort']);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to abort rebase:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function merge(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const { branch } = options;
|
||||
if (!branch) {
|
||||
throw new Error('branch parameter is required for merge');
|
||||
}
|
||||
|
||||
await git.merge([branch]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
conflict: false
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('merge conflict') ||
|
||||
errorMessage.includes('automatic merge failed');
|
||||
|
||||
if (isConflict) {
|
||||
// Get list of conflicted files
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Failed to merge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortMerge(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
await git.merge(['--abort']);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to abort merge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueRebase(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
const git = await createGit(directoryPath);
|
||||
|
||||
try {
|
||||
// Set GIT_EDITOR to prevent editor prompts
|
||||
await git.env('GIT_EDITOR', 'true').rebase(['--continue']);
|
||||
return { success: true, conflict: false };
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('needs merge') ||
|
||||
errorMessage.includes('unmerged') ||
|
||||
errorMessage.includes('fix conflicts');
|
||||
|
||||
if (isConflict) {
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
// Check for "nothing to commit" which means rebase step is complete
|
||||
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) {
|
||||
// Skip this commit and continue
|
||||
try {
|
||||
await git.env('GIT_EDITOR', 'true').rebase(['--skip']);
|
||||
return { success: true, conflict: false };
|
||||
} catch {
|
||||
// If skip also fails, the rebase may be complete
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Failed to continue rebase:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueMerge(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
const git = await createGit(directoryPath);
|
||||
|
||||
try {
|
||||
// Check if there are still unmerged files
|
||||
const status = await git.status();
|
||||
if (status.conflicted && status.conflicted.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted
|
||||
};
|
||||
}
|
||||
|
||||
// For merge, we commit after resolving conflicts
|
||||
// Use --no-edit to use the default merge commit message
|
||||
await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null });
|
||||
return { success: true, conflict: false };
|
||||
} catch (error) {
|
||||
const errorMessage = String(error?.message || error || '').toLowerCase();
|
||||
const isConflict = errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('needs merge') ||
|
||||
errorMessage.includes('unmerged') ||
|
||||
errorMessage.includes('fix conflicts');
|
||||
|
||||
if (isConflict) {
|
||||
const status = await git.status().catch(() => ({ conflicted: [] }));
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictFiles: status.conflicted || []
|
||||
};
|
||||
}
|
||||
|
||||
// "nothing to commit" can happen if all conflicts resolved to one side
|
||||
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) {
|
||||
// The merge is effectively complete (all changes already committed or no changes needed)
|
||||
return { success: true, conflict: false };
|
||||
}
|
||||
|
||||
console.error('Failed to continue merge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConflictDetails(directory) {
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
const git = await createGit(directoryPath);
|
||||
|
||||
try {
|
||||
// Get git status --porcelain
|
||||
const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => '');
|
||||
|
||||
// Get unmerged files
|
||||
const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
|
||||
const unmergedFiles = unmergedFilesRaw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Get current diff
|
||||
const diff = await git.raw(['diff']).catch(() => '');
|
||||
|
||||
// Detect operation type and get head info
|
||||
let operation = 'merge';
|
||||
let headInfo = '';
|
||||
|
||||
// Check for MERGE_HEAD (merge in progress)
|
||||
const mergeHeadExists = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (mergeHeadExists) {
|
||||
operation = 'merge';
|
||||
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
||||
const mergeMsg = await fsp
|
||||
.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8')
|
||||
.catch(() => '');
|
||||
headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
|
||||
} else {
|
||||
// Check for REBASE_HEAD (rebase in progress)
|
||||
const rebaseHeadExists = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'])
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (rebaseHeadExists) {
|
||||
operation = 'rebase';
|
||||
const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => '');
|
||||
headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusPorcelain: statusPorcelain.trim(),
|
||||
unmergedFiles,
|
||||
diff: diff.trim(),
|
||||
headInfo: headInfo.trim(),
|
||||
operation,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get conflict details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Stash Operations ==============
|
||||
|
||||
export async function stash(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
const args = ['stash', 'push'];
|
||||
|
||||
// Include untracked files by default
|
||||
if (options.includeUntracked !== false) {
|
||||
args.push('--include-untracked');
|
||||
}
|
||||
|
||||
if (options.message) {
|
||||
args.push('-m', options.message);
|
||||
}
|
||||
|
||||
await git.raw(args);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to stash:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stashPop(directory) {
|
||||
const git = await createGit(directory);
|
||||
|
||||
try {
|
||||
await git.raw(['stash', 'pop']);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to pop stash:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,14 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
createGitIdentity: gitApiHttp.createGitIdentity,
|
||||
updateGitIdentity: gitApiHttp.updateGitIdentity,
|
||||
deleteGitIdentity: gitApiHttp.deleteGitIdentity,
|
||||
getRemotes: gitApiHttp.getRemotes,
|
||||
rebase: gitApiHttp.rebase,
|
||||
abortRebase: gitApiHttp.abortRebase,
|
||||
continueRebase: gitApiHttp.continueRebase,
|
||||
merge: gitApiHttp.merge,
|
||||
abortMerge: gitApiHttp.abortMerge,
|
||||
continueMerge: gitApiHttp.continueMerge,
|
||||
stash: gitApiHttp.stash,
|
||||
stashPop: gitApiHttp.stashPop,
|
||||
getConflictDetails: gitApiHttp.getConflictDetails,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user