Git Improvements (#382)

This commit is contained in:
Gregory Schier
2026-02-04 11:46:04 -08:00
committed by GitHub
parent 8f1463e5d0
commit 7c31718f5e
16 changed files with 468 additions and 28 deletions

View File

@@ -20,7 +20,7 @@ import { InlineCode } from '../core/InlineCode';
import { gitCallbacks } from './callbacks';
import { GitCommitDialog } from './GitCommitDialog';
import { GitRemotesDialog } from './GitRemotesDialog';
import { handlePullResult } from './git-util';
import { handlePullResult, handlePushResult } from './git-util';
import { HistoryDialog } from './HistoryDialog';
export function GitDropdown() {
@@ -48,6 +48,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
push,
pull,
checkout,
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir));
@@ -72,6 +73,9 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}
const currentBranch = status.data.headRefShorthand;
const hasChanges = status.data.entries.some((e) => e.status !== 'current');
const hasRemotes = (status.data.origins ?? []).length > 0;
const { ahead, behind } = status.data;
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
@@ -168,12 +172,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{ type: 'separator' },
{
label: 'Push',
disabled: !hasRemotes || ahead === 0,
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onSuccess: handlePushResult,
onError(err) {
showErrorToast({
id: 'git-push-error',
@@ -186,7 +191,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Pull',
hidden: (status.data?.origins ?? []).length === 0,
disabled: !hasRemotes || behind === 0,
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
@@ -205,6 +210,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
{
label: 'Commit...',
disabled: !hasChanges,
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
@@ -218,6 +224,41 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
});
},
},
{
label: 'Reset Changes',
hidden: !hasChanges,
leftSlot: <Icon icon="rotate_ccw" />,
color: 'danger',
async onSelect() {
const confirmed = await showConfirm({
id: 'git-reset-changes',
title: 'Reset Changes',
description: 'This will discard all uncommitted changes. This cannot be undone.',
confirmText: 'Reset',
color: 'danger',
});
if (!confirmed) return;
await resetChanges.mutateAsync(undefined, {
disableToastError: true,
onSuccess() {
showToast({
id: 'git-reset-success',
message: 'Changes have been reset',
color: 'success',
});
sync({ force: true });
},
onError(err) {
showErrorToast({
id: 'git-reset-error',
title: 'Error resetting changes',
message: String(err),
});
},
});
},
},
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
@@ -463,8 +504,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return (
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
<GitMenuButton>
<InlineCode>{currentBranch}</InlineCode>
<Icon icon="git_branch" size="sm" />
<InlineCode className="flex items-center gap-1">
<Icon icon="git_branch" size="xs" className="opacity-50" />
{currentBranch}
</InlineCode>
<div className="flex items-center gap-1.5">
{ahead > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-primary"></span>{ahead}</span>}
{behind > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-info"></span>{behind}</span>}
</div>
</GitMenuButton>
</Dropdown>
);

View File

@@ -1,5 +1,8 @@
import type { GitCallbacks } from '@yaakapp-internal/git';
import { sync } from '../../init/sync';
import { promptCredentials } from './credentials';
import { promptDivergedStrategy } from './diverged';
import { promptUncommittedChangesStrategy } from './uncommitted';
import { addGitRemote } from './showAddRemoteDialog';
export function gitCallbacks(dir: string): GitCallbacks {
@@ -12,5 +15,12 @@ export function gitCallbacks(dir: string): GitCallbacks {
if (creds == null) throw new Error('Cancelled credentials prompt');
return creds;
},
promptDiverged: async ({ remote, branch }) => {
return promptDivergedStrategy({ remote, branch });
},
promptUncommittedChanges: async () => {
return promptUncommittedChangesStrategy();
},
forceSync: () => sync({ force: true }),
};
}

View File

@@ -0,0 +1,102 @@
import type { DivergedStrategy } from '@yaakapp-internal/git';
import { useState } from 'react';
import { showDialog } from '../../lib/dialog';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
import { RadioCards } from '../core/RadioCards';
import { HStack } from '../core/Stacks';
type Resolution = 'force_reset' | 'merge';
const resolutionLabel: Record<Resolution, string> = {
force_reset: 'Force Pull',
merge: 'Merge',
};
interface DivergedDialogProps {
remote: string;
branch: string;
onResult: (strategy: DivergedStrategy) => void;
onHide: () => void;
}
function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {
const [selected, setSelected] = useState<Resolution | null>(null);
const handleSubmit = () => {
if (selected == null) return;
onResult(selected);
onHide();
};
const handleCancel = () => {
onResult('cancel');
onHide();
};
return (
<div className="flex flex-col gap-4 mb-4">
<p className="text-text-subtle">
Your local branch has diverged from{' '}
<InlineCode>
{remote}/{branch}
</InlineCode>. How would you like to resolve this?
</p>
<RadioCards
name="diverged-strategy"
value={selected}
onChange={setSelected}
options={[
{
value: 'merge',
label: 'Merge Commit',
description: 'Combining local and remote changes into a single merge commit',
},
{
value: 'force_reset',
label: 'Force Pull',
description: 'Discard local commits and reset to match the remote branch',
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === 'force_reset' ? 'danger' : 'primary'}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : 'Select an option'}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
</div>
);
}
export async function promptDivergedStrategy({
remote,
branch,
}: {
remote: string;
branch: string;
}): Promise<DivergedStrategy> {
return new Promise((resolve) => {
showDialog({
id: 'git-diverged',
title: 'Branches Diverged',
hideX: true,
size: 'sm',
disableBackdropClose: true,
onClose: () => resolve('cancel'),
render: ({ hide }) =>
DivergedDialog({
remote,
branch,
onHide: hide,
onResult: resolve,
}),
});
});
}

View File

@@ -26,5 +26,11 @@ export function handlePullResult(r: PullResult) {
case 'up_to_date':
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
break;
case 'diverged':
// Handled by mutation callback before reaching here
break;
case 'uncommitted_changes':
// Handled by mutation callback before reaching here
break;
}
}

View File

@@ -0,0 +1,13 @@
import type { UncommittedChangesStrategy } from '@yaakapp-internal/git';
import { showConfirm } from '../../lib/confirm';
export async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {
const confirmed = await showConfirm({
id: 'git-uncommitted-changes',
title: 'Uncommitted Changes',
description: 'You have uncommitted changes. Commit or reset your changes before pulling.',
confirmText: 'Reset and Pull',
color: 'danger',
});
return confirmed ? 'reset' : 'cancel';
}