feat(worktrees): ship upstream-first worktree flow across web + vscode (#418)
* feat: add worktree validation and deleteLocalBranch option Add API to validate and create worktrees with new payload types Allow deleting local branches when removing worktrees via UI and API Introduce OpenCode style random names for worktrees when not provided * feat: enable SSH/HTTPS transport detection for PR picker Load remotes for the current project directory to inform PR picker options. Determine preferred push transport from remotes and apply it. Expose sshUrl in API for frontend to build SSH clone URLs * feat: extend head repo with sshUrl and improve push error messages Add sshUrl field to head repo mapping Enhance push failure handling to display stderr or stdout details Return push details on success * fix: worktree path * feat: worktree set upstream on creation Enable pushing to upstream by default when no remote is specified Remove per-remote dropdown for push actions and auto-use first/upstream remote Update server and VSCode git services to support push without explicit remote and set upstream * fix: worktree-name sanitization * feat: rename worktree path field and branch prefix * feat(worktrees): add git.worktree facade, validation endpoint, upstream/remote-aware creation, and non-blocking setup execution * refactor(git): use git.worktree namespace in branch picker * feat(worktrees): sync OpenCode sandbox metadata on create/remove * fix(worktrees): accept new path key in workspace guard and validate remote startRef * chore(docs): remove temporary worktree testing plan * feat: add git worktree management API (list/create/delete/validate) for vscode * feat: wire root tracking remote and defaults for new worktrees Add resolveRootTrackingRemote to detect upstream remote for root branch Apply upstream defaults when creating new worktrees to auto-set upstream Replace validation and creation flow to use new worktreeCreate APIs * feat(worktrees): enable root tracking remote handling
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
||||
import { resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
|
||||
|
||||
export type WorktreeBaseOption = {
|
||||
value: string;
|
||||
@@ -38,6 +39,18 @@ export interface BranchSelectorState {
|
||||
isGitRepository: boolean | null;
|
||||
}
|
||||
|
||||
const parseTrackingRemote = (tracking: string | null | undefined): string | null => {
|
||||
const value = String(tracking || '').trim().replace(/^remotes\//, '');
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const slashIndex = value.indexOf('/');
|
||||
if (slashIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value.slice(0, slashIndex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to load available git branches for a directory.
|
||||
*/
|
||||
@@ -77,6 +90,9 @@ export function useBranchOptions(directory: string | null): BranchSelectorState
|
||||
const branchData = await getGitBranches(directory).catch(() => null);
|
||||
if (cancelled) return;
|
||||
|
||||
const rootTrackingRemote = await resolveRootTrackingRemote(directory).catch(() => null);
|
||||
if (cancelled) return;
|
||||
|
||||
const worktreeBaseOptions: WorktreeBaseOption[] = [];
|
||||
const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)';
|
||||
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
||||
@@ -84,6 +100,17 @@ export function useBranchOptions(directory: string | null): BranchSelectorState
|
||||
if (branchData) {
|
||||
const localBranches = branchData.all
|
||||
.filter((branchName) => !branchName.startsWith('remotes/'))
|
||||
.filter((branchName) => {
|
||||
if (!rootTrackingRemote) {
|
||||
return true;
|
||||
}
|
||||
const tracking = branchData.branches?.[branchName]?.tracking;
|
||||
const trackingRemote = parseTrackingRemote(tracking);
|
||||
if (!trackingRemote) {
|
||||
return true;
|
||||
}
|
||||
return trackingRemote === rootTrackingRemote;
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
localBranches.forEach((branchName) => {
|
||||
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' });
|
||||
@@ -92,6 +119,16 @@ export function useBranchOptions(directory: string | null): BranchSelectorState
|
||||
const remoteBranches = branchData.all
|
||||
.filter((branchName) => branchName.startsWith('remotes/'))
|
||||
.map((branchName) => branchName.replace(/^remotes\//, ''))
|
||||
.filter((branchName) => {
|
||||
if (!rootTrackingRemote) {
|
||||
return true;
|
||||
}
|
||||
const slashIndex = branchName.indexOf('/');
|
||||
if (slashIndex <= 0) {
|
||||
return false;
|
||||
}
|
||||
return branchName.slice(0, slashIndex) === rootTrackingRemote;
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
remoteBranches.forEach((branchName) => {
|
||||
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' });
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
|
||||
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
@@ -59,7 +59,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
try {
|
||||
const [b, w] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
listGitWorktrees(project.path),
|
||||
git.worktree.list(project.path),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
|
||||
@@ -28,9 +28,15 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
|
||||
import { gitFetch } from '@/lib/gitApi';
|
||||
import { execCommand, execCommands } from '@/lib/execCommands';
|
||||
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
|
||||
import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
|
||||
import { getRemotes } from '@/lib/gitApi';
|
||||
import type {
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestHeadRepo,
|
||||
GitHubPullRequestSummary,
|
||||
GitHubPullRequestsListResult,
|
||||
GitRemote,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
const parsePullRequestNumber = (value: string): number | null => {
|
||||
const trimmed = value.trim();
|
||||
@@ -64,6 +70,35 @@ const sanitizeGitRemoteName = (value: string): string => {
|
||||
.slice(0, 64);
|
||||
};
|
||||
|
||||
const looksLikeSshUrl = (value: string): boolean => {
|
||||
const trimmed = value.trim();
|
||||
return /^git@/i.test(trimmed) || /^ssh:\/\//i.test(trimmed);
|
||||
};
|
||||
|
||||
const resolvePreferredPushTransport = (remotes: GitRemote[]): 'ssh' | 'https' => {
|
||||
const candidates = remotes.length > 0
|
||||
? remotes
|
||||
: [];
|
||||
const preferredByName = candidates.find((remote) => remote.name === 'origin')
|
||||
|| candidates.find((remote) => remote.name === 'upstream')
|
||||
|| candidates[0];
|
||||
|
||||
const sample = preferredByName?.pushUrl || preferredByName?.fetchUrl || '';
|
||||
return looksLikeSshUrl(sample) ? 'ssh' : 'https';
|
||||
};
|
||||
|
||||
const resolveForkRemoteUrl = (headRepo: GitHubPullRequestHeadRepo | null | undefined, preferredTransport: 'ssh' | 'https'): string => {
|
||||
if (!headRepo) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preferredTransport === 'ssh') {
|
||||
return headRepo.sshUrl || headRepo.cloneUrl || headRepo.url || '';
|
||||
}
|
||||
|
||||
return headRepo.cloneUrl || headRepo.sshUrl || headRepo.url || '';
|
||||
};
|
||||
|
||||
export function GitHubPullRequestPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -79,6 +114,15 @@ export function GitHubPullRequestPickerDialog({
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
const projectRef = React.useMemo(() => {
|
||||
if (!projectDirectory) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: activeProject?.id ?? `path:${projectDirectory}`,
|
||||
path: projectDirectory,
|
||||
};
|
||||
}, [activeProject?.id, projectDirectory]);
|
||||
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||
@@ -91,8 +135,14 @@ export function GitHubPullRequestPickerDialog({
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [existingBranchHeads, setExistingBranchHeads] = React.useState<Map<string, boolean>>(new Map());
|
||||
const [projectRemotes, setProjectRemotes] = React.useState<GitRemote[]>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const preferredPushTransport = React.useMemo(
|
||||
() => resolvePreferredPushTransport(projectRemotes),
|
||||
[projectRemotes]
|
||||
);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!projectDirectory) {
|
||||
setResult(null);
|
||||
@@ -166,13 +216,37 @@ export function GitHubPullRequestPickerDialog({
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setExistingBranchHeads(new Map());
|
||||
setProjectRemotes([]);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void getRemotes(projectDirectory)
|
||||
.then((remotes) => {
|
||||
if (!cancelled) {
|
||||
setProjectRemotes(Array.isArray(remotes) ? remotes : []);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setProjectRemotes([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, projectDirectory]);
|
||||
|
||||
const checkLocalBranchExists = React.useCallback(async (heads: string[]) => {
|
||||
if (!projectDirectory) return;
|
||||
if (!projectRef) return;
|
||||
const unique = Array.from(new Set(heads.map((h) => (h || '').trim()).filter(Boolean)));
|
||||
if (unique.length === 0) return;
|
||||
|
||||
@@ -180,28 +254,36 @@ export function GitHubPullRequestPickerDialog({
|
||||
const unknown = unique.filter((h) => !existingBranchHeads.has(h));
|
||||
if (unknown.length === 0) return;
|
||||
|
||||
// optimistic UI: no spinner; disable once results arrive
|
||||
{
|
||||
// Avoid shell wrappers; rely on exit code only.
|
||||
const commands = unknown.map((h) => `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${h}`)}`);
|
||||
const res = await execCommands(commands, projectDirectory);
|
||||
setExistingBranchHeads((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (let i = 0; i < unknown.length; i += 1) {
|
||||
const head = unknown[i];
|
||||
next.set(head, Boolean(res.results[i]?.success));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [projectDirectory, existingBranchHeads]);
|
||||
const results = await Promise.all(
|
||||
unknown.map(async (head) => {
|
||||
const validation = await validateWorktreeCreate(projectRef, {
|
||||
mode: 'new',
|
||||
branchName: head,
|
||||
worktreeName: head,
|
||||
}).catch(() => ({ ok: false, errors: [{ code: 'validation_failed', message: 'Validation failed' }] }));
|
||||
|
||||
const blockedByBranch = validation.errors.some((entry) =>
|
||||
entry.code === 'branch_in_use' || entry.code === 'branch_exists'
|
||||
);
|
||||
return { head, blocked: blockedByBranch };
|
||||
})
|
||||
);
|
||||
|
||||
setExistingBranchHeads((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const item of results) {
|
||||
next.set(item.head, item.blocked);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [projectRef, existingBranchHeads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!projectDirectory) return;
|
||||
if (!projectRef) return;
|
||||
if (!createInWorktree) return;
|
||||
void checkLocalBranchExists(prs.map((pr) => pr.head));
|
||||
}, [open, projectDirectory, createInWorktree, prs, checkLocalBranchExists]);
|
||||
}, [open, projectRef, createInWorktree, prs, checkLocalBranchExists]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -290,7 +372,7 @@ export function GitHubPullRequestPickerDialog({
|
||||
baseRepo: GitHubPullRequestsListResult['repo'] | undefined,
|
||||
pr: GitHubPullRequestSummary,
|
||||
): Promise<{ id: string } | null> => {
|
||||
if (!projectDirectory) return null;
|
||||
if (!projectDirectory || !projectRef) return null;
|
||||
const headRef = pr.head;
|
||||
const headRepo = pr.headRepo;
|
||||
if (!headRef) {
|
||||
@@ -303,33 +385,53 @@ export function GitHubPullRequestPickerDialog({
|
||||
(headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo)
|
||||
);
|
||||
|
||||
const fetchRemote = isFork
|
||||
? (headRepo?.cloneUrl || headRepo?.url || '')
|
||||
: 'origin';
|
||||
if (!fetchRemote) {
|
||||
throw new Error('PR head remote URL missing');
|
||||
}
|
||||
|
||||
const fetchRef = `refs/heads/${headRef}`;
|
||||
const fetchResult = await gitFetch(projectDirectory, { remote: fetchRemote, branch: fetchRef });
|
||||
if (!fetchResult?.success) {
|
||||
throw new Error('Failed to fetch PR head');
|
||||
}
|
||||
|
||||
const headCommitish = pr.headSha?.trim() || (await execCommand('git rev-parse FETCH_HEAD', projectDirectory)).stdout?.trim() || '';
|
||||
if (!headCommitish) {
|
||||
throw new Error('PR head commit not resolvable');
|
||||
}
|
||||
|
||||
const preferredBranch = pr.head;
|
||||
const remoteName = isFork
|
||||
? (sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`) || `pr-${pr.number}`)
|
||||
: 'origin';
|
||||
const remoteUrl = isFork ? resolveForkRemoteUrl(headRepo, preferredPushTransport) : '';
|
||||
|
||||
if (isFork && !remoteUrl) {
|
||||
throw new Error('PR fork remote URL missing');
|
||||
}
|
||||
|
||||
const startRef = `${remoteName}/${preferredBranch}`;
|
||||
const validation = await validateWorktreeCreate(projectRef, {
|
||||
mode: 'new',
|
||||
branchName: preferredBranch,
|
||||
worktreeName: preferredBranch,
|
||||
startRef,
|
||||
setUpstream: true,
|
||||
upstreamRemote: remoteName,
|
||||
upstreamBranch: preferredBranch,
|
||||
ensureRemoteName: isFork ? remoteName : undefined,
|
||||
ensureRemoteUrl: isFork ? remoteUrl : undefined,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
const branchError = validation.errors.find((entry) =>
|
||||
entry.code === 'branch_in_use' || entry.code === 'branch_exists'
|
||||
);
|
||||
if (branchError) {
|
||||
throw new Error(branchError.message);
|
||||
}
|
||||
throw new Error(validation.errors[0]?.message || 'PR worktree validation failed');
|
||||
}
|
||||
|
||||
// Prevent clobbering/removing an existing local branch when using PR worktree mode.
|
||||
if (existingBranchHeads.get(preferredBranch) === true) {
|
||||
throw new Error(`Local branch already exists: ${preferredBranch}`);
|
||||
}
|
||||
|
||||
const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, headCommitish, {
|
||||
const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, startRef, {
|
||||
kind: 'pr',
|
||||
worktreeName: preferredBranch,
|
||||
setUpstream: true,
|
||||
upstreamRemote: remoteName,
|
||||
upstreamBranch: preferredBranch,
|
||||
ensureRemoteName: isFork ? remoteName : undefined,
|
||||
ensureRemoteUrl: isFork ? remoteUrl : undefined,
|
||||
createdFromBranch: pr.base,
|
||||
});
|
||||
if (!session?.id) {
|
||||
throw new Error('Failed to create PR worktree session');
|
||||
@@ -341,53 +443,6 @@ export function GitHubPullRequestPickerDialog({
|
||||
throw new Error('Worktree directory not found');
|
||||
}
|
||||
|
||||
// Switch the new worktree to the PR branch and delete the SDK-created opencode/* branch immediately.
|
||||
// This makes the worktree directly operate on the PR branch.
|
||||
const commands: string[] = [
|
||||
// Create local branch from the fetched PR head commit.
|
||||
`git -C ${JSON.stringify(worktreeDir)} switch -c ${JSON.stringify(preferredBranch)} ${JSON.stringify(headCommitish)}`,
|
||||
];
|
||||
const originalBranch = (meta?.branch || session.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||
if (meta?.kind === 'pr' && originalBranch && originalBranch.startsWith('opencode/')) {
|
||||
commands.push(`git -C ${JSON.stringify(projectDirectory)} branch -D ${JSON.stringify(originalBranch)}`);
|
||||
}
|
||||
|
||||
const result = await execCommands(commands, projectDirectory);
|
||||
if (!result.success) {
|
||||
const failed = result.results.find((r) => !r.success);
|
||||
throw new Error(failed?.stderr || failed?.stdout || 'Failed to switch worktree to PR branch');
|
||||
}
|
||||
|
||||
// Best-effort: set upstream for PR branch (without pushing).
|
||||
try {
|
||||
const remoteName = isFork
|
||||
? sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`)
|
||||
: 'origin';
|
||||
const remoteUrl = isFork ? (headRepo?.cloneUrl || headRepo?.url || '') : '';
|
||||
const fetchRefspec = `+refs/heads/${preferredBranch}:refs/remotes/${remoteName}/${preferredBranch}`;
|
||||
|
||||
const upstreamCommands: string[] = [];
|
||||
if (isFork && remoteUrl) {
|
||||
upstreamCommands.push(
|
||||
`git -C ${JSON.stringify(projectDirectory)} remote add ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)} 2>/dev/null || git -C ${JSON.stringify(projectDirectory)} remote set-url ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)}`
|
||||
);
|
||||
}
|
||||
upstreamCommands.push(
|
||||
`git -C ${JSON.stringify(projectDirectory)} fetch ${JSON.stringify(remoteName)} ${JSON.stringify(fetchRefspec)}`
|
||||
);
|
||||
upstreamCommands.push(
|
||||
`git -C ${JSON.stringify(worktreeDir)} branch --set-upstream-to=${JSON.stringify(`${remoteName}/${preferredBranch}`)} ${JSON.stringify(preferredBranch)}`
|
||||
);
|
||||
|
||||
const upstreamResult = await execCommands(upstreamCommands, projectDirectory);
|
||||
if (!upstreamResult.success) {
|
||||
const failed = upstreamResult.results.find((r) => !r.success);
|
||||
toast.message('PR upstream not set', { description: failed?.stderr || failed?.stdout || 'Configure remote manually if needed.' });
|
||||
}
|
||||
} catch {
|
||||
toast.message('PR upstream not set', { description: 'Configure remote manually if needed.' });
|
||||
}
|
||||
|
||||
// Update stored metadata for better UX + reintegration target.
|
||||
useSessionStore.getState().setWorktreeMetadata(session.id, {
|
||||
...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }),
|
||||
@@ -400,7 +455,7 @@ export function GitHubPullRequestPickerDialog({
|
||||
});
|
||||
|
||||
return { id: session.id };
|
||||
}, [projectDirectory, existingBranchHeads]);
|
||||
}, [projectDirectory, projectRef, existingBranchHeads, preferredPushTransport]);
|
||||
|
||||
const startSession = React.useCallback(async (number: number) => {
|
||||
if (!projectDirectory) {
|
||||
@@ -433,14 +488,8 @@ export function GitHubPullRequestPickerDialog({
|
||||
|
||||
const sessionId = await (async () => {
|
||||
if (createInWorktree) {
|
||||
try {
|
||||
const worktreeSession = await createPrWorktreeSession(prContext.repo, pr);
|
||||
return worktreeSession?.id || null;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
toast.error('PR worktree failed', { description: msg });
|
||||
// fall back to normal session
|
||||
}
|
||||
const worktreeSession = await createPrWorktreeSession(prContext.repo, pr);
|
||||
return worktreeSession?.id || null;
|
||||
}
|
||||
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
|
||||
return session?.id || null;
|
||||
@@ -572,7 +621,7 @@ Nice-to-have:
|
||||
toast.success('Session created from PR');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error('Failed to start session', { description: message });
|
||||
toast.error(createInWorktree ? 'PR worktree failed' : 'Failed to start session', { description: message });
|
||||
} finally {
|
||||
setStartingNumber(null);
|
||||
}
|
||||
@@ -690,7 +739,7 @@ Nice-to-have:
|
||||
<p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p>
|
||||
{createInWorktree && disabledByWorktree ? (
|
||||
<p className="typography-micro text-muted-foreground mt-0.5 ml-0.5">
|
||||
PR worktree disabled: local branch exists ({pr.head})
|
||||
PR worktree disabled: branch already exists or is in use ({pr.head})
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
const [deleteDialogShouldDeleteLocalBranch, setDeleteDialogShouldDeleteLocalBranch] = React.useState(false);
|
||||
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
|
||||
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
|
||||
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
|
||||
@@ -102,6 +103,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const shouldArchiveWorktree = isWorktreeDelete;
|
||||
const removeRemoteOptionDisabled =
|
||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||
const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete;
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSessions();
|
||||
@@ -186,6 +188,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
setDeleteDialog(null);
|
||||
setDeleteDialogSummaries([]);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
setDeleteDialogShouldDeleteLocalBranch(false);
|
||||
setIsProcessingDelete(false);
|
||||
setHasCompletedDirtyCheck(false);
|
||||
setDirtyWorktreePaths(new Set());
|
||||
@@ -207,6 +210,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
if (!deleteDialog) {
|
||||
setDeleteDialogSummaries([]);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
setDeleteDialogShouldDeleteLocalBranch(false);
|
||||
setHasCompletedDirtyCheck(false);
|
||||
setDirtyWorktreePaths(new Set());
|
||||
return;
|
||||
@@ -326,6 +330,26 @@ export const SessionDialogs: React.FC = () => {
|
||||
}
|
||||
}, [canRemoveRemoteBranches]);
|
||||
|
||||
const removeSelectedWorktree = React.useCallback(async (
|
||||
worktree: WorktreeMetadata,
|
||||
deleteLocalBranch: boolean
|
||||
): Promise<boolean> => {
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
try {
|
||||
await removeProjectWorktree(
|
||||
getProjectRefForWorktree(worktree),
|
||||
worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch }
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove worktree', {
|
||||
description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, getProjectRefForWorktree]);
|
||||
|
||||
const handleConfirmDelete = React.useCallback(async () => {
|
||||
if (!deleteDialog) {
|
||||
return;
|
||||
@@ -335,22 +359,15 @@ export const SessionDialogs: React.FC = () => {
|
||||
try {
|
||||
const shouldArchive = shouldArchiveWorktree;
|
||||
const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote;
|
||||
const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch;
|
||||
|
||||
if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) {
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
try {
|
||||
await removeProjectWorktree(
|
||||
getProjectRefForWorktree(deleteDialog.worktree),
|
||||
deleteDialog.worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove worktree', {
|
||||
description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'),
|
||||
});
|
||||
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
||||
if (!removed) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.';
|
||||
toast.success('Worktree removed', {
|
||||
description: renderToastDescription(archiveNote),
|
||||
@@ -367,6 +384,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
// Don't try to derive worktree removal from per-session metadata (may be missing).
|
||||
archiveWorktree: isWorktreeDelete ? false : shouldArchive,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
if (!success) {
|
||||
toast.error('Failed to delete session');
|
||||
@@ -390,23 +408,15 @@ export const SessionDialogs: React.FC = () => {
|
||||
const { deletedIds, failedIds } = await deleteSessions(ids, {
|
||||
archiveWorktree: isWorktreeDelete ? false : shouldArchive,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
|
||||
if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) {
|
||||
// Remove selected worktree even if per-session metadata is missing.
|
||||
// Use same projectRef logic as the no-sessions path.
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
try {
|
||||
await removeProjectWorktree(
|
||||
getProjectRefForWorktree(deleteDialog.worktree),
|
||||
deleteDialog.worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
|
||||
);
|
||||
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
||||
if (removed) {
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove worktree', {
|
||||
description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,18 +454,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
}
|
||||
|
||||
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
try {
|
||||
await removeProjectWorktree(
|
||||
getProjectRefForWorktree(deleteDialog.worktree),
|
||||
deleteDialog.worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
|
||||
);
|
||||
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
||||
if (removed) {
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove worktree', {
|
||||
description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,13 +467,14 @@ export const SessionDialogs: React.FC = () => {
|
||||
}, [
|
||||
deleteDialog,
|
||||
deleteDialogShouldRemoveRemote,
|
||||
deleteDialogShouldDeleteLocalBranch,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
closeDeleteDialog,
|
||||
shouldArchiveWorktree,
|
||||
isWorktreeDelete,
|
||||
canRemoveRemoteBranches,
|
||||
getProjectRefForWorktree,
|
||||
removeSelectedWorktree,
|
||||
loadSessions,
|
||||
]);
|
||||
|
||||
@@ -590,9 +592,36 @@ export const SessionDialogs: React.FC = () => {
|
||||
)
|
||||
) : null;
|
||||
|
||||
const deleteLocalBranchAction = isWorktreeDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (deleteLocalOptionDisabled) {
|
||||
return;
|
||||
}
|
||||
setDeleteDialogShouldDeleteLocalBranch((prev) => !prev);
|
||||
}}
|
||||
disabled={deleteLocalOptionDisabled}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors',
|
||||
deleteLocalOptionDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
>
|
||||
{deleteDialogShouldDeleteLocalBranch ? (
|
||||
<RiCheckboxLine className="size-4 text-primary" />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="size-4" />
|
||||
)}
|
||||
Delete local branch
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const deleteDialogActions = isWorktreeDelete ? (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{deleteLocalBranchAction}
|
||||
{deleteRemoteBranchAction}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -43,6 +43,7 @@ const toViewKeyBindings = (bindings: readonly unknown[]): readonly KeyBinding[]
|
||||
return bindings as readonly KeyBinding[];
|
||||
};
|
||||
|
||||
const forceParsingCompat = forceParsing as unknown as (view: EditorView, upto?: number, timeout?: number) => boolean;
|
||||
const openSearchPanelCompat = openSearchPanel as unknown as (view: EditorView) => void;
|
||||
const closeSearchPanelCompat = closeSearchPanel as unknown as (view: EditorView) => void;
|
||||
|
||||
@@ -223,7 +224,7 @@ export function CodeMirrorEditor({
|
||||
parent: hostRef.current,
|
||||
});
|
||||
|
||||
forceParsing(viewRef.current, viewRef.current.state.doc.length, 200);
|
||||
forceParsingCompat(viewRef.current, viewRef.current.state.doc.length, 200);
|
||||
viewRef.current.requestMeasure();
|
||||
|
||||
if (viewRef.current) {
|
||||
@@ -255,7 +256,7 @@ export function CodeMirrorEditor({
|
||||
],
|
||||
});
|
||||
|
||||
forceParsing(view, view.state.doc.length, 200);
|
||||
forceParsingCompat(view, view.state.doc.length, 200);
|
||||
view.requestMeasure();
|
||||
|
||||
// Force a re-render to ensure Portals can find the new widget containers in the DOM
|
||||
|
||||
@@ -409,8 +409,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
|
||||
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
|
||||
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge');
|
||||
const [pushRemoteDialogOpen, setPushRemoteDialogOpen] = React.useState(false);
|
||||
const [pendingPushAction, setPendingPushAction] = React.useState<'commitAndPush' | null>(null);
|
||||
|
||||
// Conflict state persistence key
|
||||
const conflictStorageKey = React.useMemo(() => {
|
||||
@@ -727,22 +725,28 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
});
|
||||
}, [status, changeEntries, hasUserAdjustedSelection]);
|
||||
|
||||
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote: GitRemote) => {
|
||||
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
||||
if (!currentDirectory) return;
|
||||
setSyncAction(action);
|
||||
|
||||
try {
|
||||
if (action === 'fetch') {
|
||||
if (!remote) {
|
||||
throw new Error('No remote available for fetch');
|
||||
}
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
toast.success(`Fetched from ${remote.name}`);
|
||||
} else if (action === 'pull') {
|
||||
if (!remote) {
|
||||
throw new Error('No remote available for pull');
|
||||
}
|
||||
const result = await git.gitPull(currentDirectory, { remote: remote.name });
|
||||
toast.success(
|
||||
`Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}`
|
||||
);
|
||||
} else if (action === 'push') {
|
||||
await git.gitPush(currentDirectory, { remote: remote.name });
|
||||
toast.success(`Pushed to ${remote.name}`);
|
||||
await git.gitPush(currentDirectory);
|
||||
toast.success('Pushed to upstream');
|
||||
}
|
||||
|
||||
await refreshStatusAndBranches(false);
|
||||
@@ -758,7 +762,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async (options: { pushAfter?: boolean; remote?: GitRemote } = {}) => {
|
||||
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
||||
if (!currentDirectory) return;
|
||||
if (!commitMessage.trim()) {
|
||||
toast.error('Please enter a commit message');
|
||||
@@ -771,17 +775,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// If pushing with multiple remotes and no remote specified, this shouldn't happen anymore
|
||||
// since CommitSection now uses a dropdown. But keep as fallback for safety.
|
||||
if (options.pushAfter && remotes.length > 1 && !options.remote) {
|
||||
setPendingPushAction('commitAndPush');
|
||||
setPushRemoteDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's only one remote, use it automatically when no remote is specified
|
||||
const targetRemote = options.remote ?? (remotes.length === 1 ? remotes[0] : undefined);
|
||||
|
||||
const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit';
|
||||
setCommitAction(action);
|
||||
|
||||
@@ -798,9 +791,8 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
await refreshStatusAndBranches();
|
||||
|
||||
if (options.pushAfter) {
|
||||
const remoteName = targetRemote?.name;
|
||||
await git.gitPush(currentDirectory, remoteName ? { remote: remoteName } : undefined);
|
||||
toast.success(remoteName ? `Pushed to ${remoteName}` : 'Pushed to remote');
|
||||
await git.gitPush(currentDirectory);
|
||||
toast.success('Pushed to upstream');
|
||||
triggerFireworks();
|
||||
await refreshStatusAndBranches(false);
|
||||
} else {
|
||||
@@ -817,19 +809,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushRemoteSelect = (remote: GitRemote) => {
|
||||
setPushRemoteDialogOpen(false);
|
||||
if (pendingPushAction === 'commitAndPush') {
|
||||
handleCommit({ pushAfter: true, remote });
|
||||
}
|
||||
setPendingPushAction(null);
|
||||
};
|
||||
|
||||
const handlePushRemoteDialogClose = () => {
|
||||
setPushRemoteDialogOpen(false);
|
||||
setPendingPushAction(null);
|
||||
};
|
||||
|
||||
const handleGenerateCommitMessage = React.useCallback(async () => {
|
||||
if (!currentDirectory) return;
|
||||
if (selectedPaths.size === 0) {
|
||||
@@ -1611,7 +1590,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
remotes={effectiveRemotes}
|
||||
onFetch={(remote) => handleSyncAction('fetch', remote)}
|
||||
onPull={(remote) => handleSyncAction('pull', remote)}
|
||||
onPush={(remote) => handleSyncAction('push', remote)}
|
||||
onPush={() => handleSyncAction('push')}
|
||||
onCheckoutBranch={handleCheckoutBranch}
|
||||
onCreateBranch={handleCreateBranch}
|
||||
onRenameBranch={handleRenameBranch}
|
||||
@@ -1697,12 +1676,11 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
onGenerateMessage={handleGenerateCommitMessage}
|
||||
isGeneratingMessage={isGeneratingMessage}
|
||||
onCommit={() => handleCommit({ pushAfter: false })}
|
||||
onCommitAndPush={(remote) => handleCommit({ pushAfter: true, remote })}
|
||||
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
||||
commitAction={commitAction}
|
||||
isBusy={isBusy}
|
||||
gitmojiEnabled={settingsGitmojiEnabled}
|
||||
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
||||
remotes={remotes}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1885,34 +1863,6 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
project={branchPickerProject}
|
||||
/>
|
||||
|
||||
<Dialog open={pushRemoteDialogOpen} onOpenChange={handlePushRemoteDialogClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select remote</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which remote to push to
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{remotes.map((remote) => (
|
||||
<button
|
||||
key={remote.name}
|
||||
type="button"
|
||||
onClick={() => handlePushRemoteSelect(remote)}
|
||||
className="flex flex-col items-start gap-0.5 px-3 py-2 rounded-lg text-left border border-border/60 hover:bg-accent hover:border-border transition-colors"
|
||||
>
|
||||
<span className="typography-ui-label text-foreground font-medium">
|
||||
{remote.name}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate max-w-full">
|
||||
{remote.pushUrl}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
RiAiGenerate2,
|
||||
RiLoader4Line,
|
||||
RiEmotionHappyLine,
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -16,13 +15,6 @@ import { CommitInput } from './CommitInput';
|
||||
import { AIHighlightsBox } from './AIHighlightsBox';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { GitRemote } from '@/lib/api/types';
|
||||
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
|
||||
@@ -36,12 +28,11 @@ interface CommitSectionProps {
|
||||
onGenerateMessage: () => void;
|
||||
isGeneratingMessage: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitAndPush: (remote?: GitRemote) => void;
|
||||
onCommitAndPush: () => void;
|
||||
commitAction: CommitAction;
|
||||
isBusy: boolean;
|
||||
gitmojiEnabled: boolean;
|
||||
onOpenGitmojiPicker: () => void;
|
||||
remotes?: GitRemote[];
|
||||
variant?: 'framed' | 'plain';
|
||||
}
|
||||
|
||||
@@ -60,13 +51,11 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
isBusy,
|
||||
gitmojiEnabled,
|
||||
onOpenGitmojiPicker,
|
||||
remotes = [],
|
||||
variant = 'framed',
|
||||
}) => {
|
||||
const hasSelectedFiles = selectedCount > 0;
|
||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
const hasMultipleRemotes = remotes.length > 1;
|
||||
|
||||
const containerClassName =
|
||||
variant === 'framed'
|
||||
@@ -177,109 +166,27 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
</ButtonLarge>
|
||||
|
||||
{isMobile ? (
|
||||
hasMultipleRemotes ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 gap-0.5 px-1.5"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
<RiArrowDownSLine className="size-3 opacity-60" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Commit & Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(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.pushUrl}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiArrowUpLine className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Commit & Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
) : hasMultipleRemotes ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonLarge
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
|
||||
</>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
<span className="commit-actions__label commit-actions__label--long">Commit & Push</span>
|
||||
<RiArrowDownSLine className="size-3.5 opacity-60 -mr-0.5" />
|
||||
</>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
)}
|
||||
</ButtonLarge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(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.pushUrl}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Commit & Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ButtonLarge
|
||||
variant="default"
|
||||
|
||||
@@ -38,7 +38,7 @@ interface GitHeaderProps {
|
||||
remotes: GitRemote[];
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: (remote: GitRemote) => void;
|
||||
onPush: () => void;
|
||||
onCheckoutBranch: (branch: string) => void;
|
||||
onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>;
|
||||
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
|
||||
|
||||
@@ -22,7 +22,7 @@ interface SyncActionsProps {
|
||||
remotes: GitRemote[];
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: (remote: GitRemote) => void;
|
||||
onPush: () => void;
|
||||
disabled: boolean;
|
||||
iconOnly?: boolean;
|
||||
tooltipDelayMs?: number;
|
||||
@@ -61,9 +61,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||
};
|
||||
|
||||
const handlePush = () => {
|
||||
const remote = remotes[0];
|
||||
if (remotes.length === 1 && remote) {
|
||||
onPush(remote);
|
||||
if (remotes.length >= 1) {
|
||||
onPush();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,25 +201,15 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||
behindCount
|
||||
)}
|
||||
|
||||
{hasMultipleRemotes
|
||||
? renderDropdownButton(
|
||||
'push',
|
||||
<RiArrowUpLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Push',
|
||||
onPush,
|
||||
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
|
||||
aheadCount
|
||||
)
|
||||
: renderButton(
|
||||
'push',
|
||||
<RiArrowUpLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Push',
|
||||
handlePush,
|
||||
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
|
||||
aheadCount
|
||||
)}
|
||||
{renderButton(
|
||||
'push',
|
||||
<RiArrowUpLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
'Push',
|
||||
handlePush,
|
||||
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
|
||||
aheadCount
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user