mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 00:13:07 +00:00
Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch * feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count * feat: Add validation for remote names and improve error handling * Address PR comments and mobile layout fixes * ``` refactor: Extract PR target resolution logic into dedicated service ``` * feat: Add app shell UI and improve service imports. Address PR comments * fix: Improve security validation and cache handling in git operations * feat: Add GET /list endpoint and improve parameter handling * chore: Improve validation, accessibility, and error handling across apps * chore: Format vite server port configuration * fix: Add error handling for gh pr list command and improve offline fallbacks * fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
@@ -321,11 +321,11 @@ export function AgentOutputModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
|
||||
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
|
||||
data-testid="agent-output-modal"
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Spinner size="md" />
|
||||
|
||||
@@ -493,7 +493,7 @@ export function CherryPickDialog({
|
||||
if (step === 'select-commits') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cherry className="w-5 h-5 text-foreground" />
|
||||
|
||||
@@ -11,6 +11,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
GitCommit,
|
||||
Sparkles,
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -31,6 +39,11 @@ import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -178,6 +191,17 @@ export function CommitWorktreeDialog({
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||
|
||||
// Push after commit state
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [remotesFetched, setRemotesFetched] = useState(false);
|
||||
const [remotesFetchError, setRemotesFetchError] = useState<string | null>(null);
|
||||
// Track whether the commit already succeeded so retries can skip straight to push
|
||||
const [commitSucceeded, setCommitSucceeded] = useState(false);
|
||||
|
||||
// Parse diffs
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
@@ -190,6 +214,58 @@ export function CommitWorktreeDialog({
|
||||
return map;
|
||||
}, [parsedDiffs]);
|
||||
|
||||
// Fetch remotes when push option is enabled
|
||||
const fetchRemotesForWorktree = useCallback(
|
||||
async (worktreePath: string, signal?: { cancelled: boolean }) => {
|
||||
setIsLoadingRemotes(true);
|
||||
setRemotesFetchError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.listRemotes) {
|
||||
const result = await api.worktree.listRemotes(worktreePath);
|
||||
if (signal?.cancelled) return;
|
||||
setRemotesFetched(true);
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
// Auto-select 'origin' if available, otherwise first remote
|
||||
if (remoteInfos.length > 0) {
|
||||
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// API not available — mark fetch as complete with an error so the UI
|
||||
// shows feedback instead of remaining in an empty/loading state.
|
||||
setRemotesFetchError('Remote listing not available');
|
||||
setRemotesFetched(true);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.cancelled) return;
|
||||
// Don't mark as successfully fetched — show an error with retry instead
|
||||
setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes');
|
||||
console.warn('Failed to fetch remotes:', err);
|
||||
} finally {
|
||||
if (!signal?.cancelled) setIsLoadingRemotes(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) {
|
||||
const signal = { cancelled: false };
|
||||
fetchRemotesForWorktree(worktree.path, signal);
|
||||
return () => {
|
||||
signal.cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]);
|
||||
|
||||
// Load diffs when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
@@ -198,6 +274,14 @@ export function CommitWorktreeDialog({
|
||||
setDiffContent('');
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
// Reset push state
|
||||
setPushAfterCommit(false);
|
||||
setRemotes([]);
|
||||
setSelectedRemote('');
|
||||
setIsPushing(false);
|
||||
setRemotesFetched(false);
|
||||
setRemotesFetchError(null);
|
||||
setCommitSucceeded(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -278,14 +362,64 @@ export function CommitWorktreeDialog({
|
||||
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||
}, []);
|
||||
|
||||
/** Shared push helper — returns true if the push succeeded */
|
||||
const performPush = async (
|
||||
api: ReturnType<typeof getElectronAPI>,
|
||||
worktreePath: string,
|
||||
remoteName: string
|
||||
): Promise<boolean> => {
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error('Push API not available');
|
||||
return false;
|
||||
}
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const pushResult = await api.worktree.push(worktreePath, false, remoteName);
|
||||
if (pushResult.success && pushResult.result) {
|
||||
toast.success('Pushed to remote', {
|
||||
description: pushResult.result.message,
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
toast.error(pushResult.error || 'Failed to push to remote');
|
||||
return false;
|
||||
}
|
||||
} catch (pushErr) {
|
||||
toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote');
|
||||
return false;
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
|
||||
if (!worktree) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
|
||||
// If commit already succeeded on a previous attempt, skip straight to push (or close if no push needed)
|
||||
if (commitSucceeded) {
|
||||
if (pushAfterCommit && selectedRemote) {
|
||||
const ok = await performPush(api, worktree.path, selectedRemote);
|
||||
if (ok) {
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
}
|
||||
} else {
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.commit) {
|
||||
setError('Worktree API not available');
|
||||
return;
|
||||
@@ -299,12 +433,27 @@ export function CommitWorktreeDialog({
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.committed) {
|
||||
setCommitSucceeded(true);
|
||||
toast.success('Changes committed', {
|
||||
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
|
||||
});
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
|
||||
// Push after commit if enabled
|
||||
let pushSucceeded = false;
|
||||
if (pushAfterCommit && selectedRemote) {
|
||||
pushSucceeded = await performPush(api, worktree.path, selectedRemote);
|
||||
}
|
||||
|
||||
// Only close the dialog when no push was requested or the push completed successfully.
|
||||
// If push failed, keep the dialog open so the user can retry.
|
||||
if (!pushAfterCommit || pushSucceeded) {
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage('');
|
||||
} else {
|
||||
// Commit succeeded but push failed — notify parent of commit but keep dialog open for retry
|
||||
onCommitted();
|
||||
}
|
||||
} else {
|
||||
toast.info('No changes to commit', {
|
||||
description: result.result.message,
|
||||
@@ -320,16 +469,30 @@ export function CommitWorktreeDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// When the commit succeeded but push failed, allow retrying the push without
|
||||
// requiring a commit message or file selection.
|
||||
const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!isLoading &&
|
||||
!isGenerating &&
|
||||
message.trim() &&
|
||||
selectedFiles.size > 0
|
||||
!isPushing &&
|
||||
!isGenerating
|
||||
) {
|
||||
handleCommit();
|
||||
if (isPushRetry) {
|
||||
// Push retry only needs a selected remote
|
||||
if (selectedRemote) {
|
||||
handleCommit();
|
||||
}
|
||||
} else if (
|
||||
message.trim() &&
|
||||
selectedFiles.size > 0 &&
|
||||
!(pushAfterCommit && !selectedRemote)
|
||||
) {
|
||||
handleCommit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,8 +553,19 @@ export function CommitWorktreeDialog({
|
||||
|
||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||
|
||||
// Prevent the dialog from being dismissed while a push is in progress.
|
||||
// Overlay clicks and Escape key both route through onOpenChange(false); we
|
||||
// intercept those here so the UI stays open until the push completes.
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen && isPushing) {
|
||||
// Ignore close requests during an active push.
|
||||
return;
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -580,9 +754,80 @@ export function CommitWorktreeDialog({
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Push after commit option */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="push-after-commit"
|
||||
checked={pushAfterCommit}
|
||||
onCheckedChange={(checked) => setPushAfterCommit(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="push-after-commit"
|
||||
className="text-sm font-medium cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
Push to remote after commit
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{pushAfterCommit && (
|
||||
<div className="ml-6 flex flex-col gap-1.5">
|
||||
{isLoadingRemotes || (!remotesFetched && !remotesFetchError) ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading remotes...</span>
|
||||
</div>
|
||||
) : remotesFetchError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<span>Failed to load remotes.</span>
|
||||
<button
|
||||
className="text-xs underline hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
if (worktree) {
|
||||
setRemotesFetchError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : remotes.length === 0 && remotesFetched ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No remotes configured for this repository.
|
||||
</p>
|
||||
) : remotes.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="remote-select"
|
||||
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||
>
|
||||
Remote:
|
||||
</Label>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select" className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="Select remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
|
||||
{remote.url}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
|
||||
commit
|
||||
commit{pushAfterCommit ? ' & push' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -590,23 +835,41 @@ export function CommitWorktreeDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading || isGenerating}
|
||||
disabled={isLoading || isPushing || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isPushing ||
|
||||
isGenerating ||
|
||||
(isPushRetry
|
||||
? !selectedRemote
|
||||
: !message.trim() ||
|
||||
selectedFiles.size === 0 ||
|
||||
(pushAfterCommit && !selectedRemote))
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading || isPushing ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Committing...
|
||||
{isPushing ? 'Pushing...' : 'Committing...'}
|
||||
</>
|
||||
) : isPushRetry ? (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Retry Push
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
Commit
|
||||
{pushAfterCommit ? (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pushAfterCommit ? 'Commit & Push' : 'Commit'}
|
||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useWorktreeBranches } from '@/hooks/queries';
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
branches?: string[];
|
||||
}
|
||||
|
||||
interface WorktreeInfo {
|
||||
@@ -74,13 +75,19 @@ export function CreatePRDialog({
|
||||
// Remote selection state
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
// Target remote: which remote to create the PR against (may differ from push remote)
|
||||
const [selectedTargetRemote, setSelectedTargetRemote] = useState<string>('');
|
||||
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
|
||||
// without needing it in its dependency array (which would cause re-fetch loops)
|
||||
const selectedRemoteRef = useRef<string>(selectedRemote);
|
||||
const selectedTargetRemoteRef = useRef<string>(selectedTargetRemote);
|
||||
useEffect(() => {
|
||||
selectedRemoteRef.current = selectedRemote;
|
||||
}, [selectedRemote]);
|
||||
useEffect(() => {
|
||||
selectedTargetRemoteRef.current = selectedTargetRemote;
|
||||
}, [selectedTargetRemote]);
|
||||
|
||||
// Generate description state
|
||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||
@@ -91,11 +98,52 @@ export function CreatePRDialog({
|
||||
true // Include remote branches for PR base branch selection
|
||||
);
|
||||
|
||||
// Determine if push remote selection is needed:
|
||||
// Show when there are unpushed commits, no remote tracking branch, or uncommitted changes
|
||||
// (uncommitted changes will be committed first, then pushed)
|
||||
const branchHasRemote = branchesData?.hasRemoteBranch ?? false;
|
||||
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
||||
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
||||
|
||||
// Filter out current worktree branch from the list
|
||||
// When a target remote is selected, only show branches from that remote
|
||||
const branches = useMemo(() => {
|
||||
if (!branchesData?.branches) return [];
|
||||
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
||||
}, [branchesData?.branches, worktree?.branch]);
|
||||
const allBranches = branchesData.branches
|
||||
.map((b) => b.name)
|
||||
.filter((name) => name !== worktree?.branch);
|
||||
|
||||
// If a target remote is selected and we have remote info with branches,
|
||||
// only show that remote's branches (not branches from other remotes)
|
||||
if (selectedTargetRemote) {
|
||||
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
|
||||
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
|
||||
const targetBranchNames = new Set(targetRemoteInfo.branches);
|
||||
// Filter to only include branches that exist on the target remote
|
||||
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
|
||||
return allBranches.filter((name) => {
|
||||
// Check if the branch name matches a target remote branch directly
|
||||
if (targetBranchNames.has(name)) return true;
|
||||
// Check if it's a prefixed remote branch (e.g. "upstream/main")
|
||||
const prefix = `${selectedTargetRemote}/`;
|
||||
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allBranches;
|
||||
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
|
||||
|
||||
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
||||
useEffect(() => {
|
||||
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
||||
// Current base branch is not in the filtered list — pick the best match
|
||||
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
||||
setBaseBranch(mainBranch || branches[0]);
|
||||
}
|
||||
}, [branches, baseBranch]);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
const fetchRemotes = useCallback(async () => {
|
||||
@@ -109,14 +157,15 @@ export function CreatePRDialog({
|
||||
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
|
||||
(r: { name: string; url: string }) => ({
|
||||
(r: { name: string; url: string; branches?: { name: string }[] }) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
branches: r.branches?.map((b: { name: string }) => b.name) || [],
|
||||
})
|
||||
);
|
||||
setRemotes(remoteInfos);
|
||||
|
||||
// Preserve existing selection if it's still valid; otherwise fall back to 'origin' or first remote
|
||||
// Preserve existing push remote selection if it's still valid; otherwise fall back to 'origin' or first remote
|
||||
if (remoteInfos.length > 0) {
|
||||
const remoteNames = remoteInfos.map((r) => r.name);
|
||||
const currentSelection = selectedRemoteRef.current;
|
||||
@@ -126,6 +175,19 @@ export function CreatePRDialog({
|
||||
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
}
|
||||
|
||||
// Preserve existing target remote selection if it's still valid
|
||||
const currentTargetSelection = selectedTargetRemoteRef.current;
|
||||
const currentTargetStillExists =
|
||||
currentTargetSelection !== '' && remoteNames.includes(currentTargetSelection);
|
||||
if (!currentTargetStillExists) {
|
||||
// Default target remote: 'upstream' if it exists (fork workflow), otherwise same as push remote
|
||||
const defaultTarget =
|
||||
remoteInfos.find((r) => r.name === 'upstream') ||
|
||||
remoteInfos.find((r) => r.name === 'origin') ||
|
||||
remoteInfos[0];
|
||||
setSelectedTargetRemote(defaultTarget.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -154,6 +216,7 @@ export function CreatePRDialog({
|
||||
setShowBrowserFallback(false);
|
||||
setRemotes([]);
|
||||
setSelectedRemote('');
|
||||
setSelectedTargetRemote('');
|
||||
setIsGeneratingDescription(false);
|
||||
operationCompletedRef.current = false;
|
||||
}, [defaultBaseBranch]);
|
||||
@@ -215,6 +278,7 @@ export function CreatePRDialog({
|
||||
baseBranch,
|
||||
draft: isDraft,
|
||||
remote: selectedRemote || undefined,
|
||||
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.result) {
|
||||
@@ -348,7 +412,7 @@ export function CreatePRDialog({
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Push changes and create a pull request from{' '}
|
||||
{worktree.hasChanges ? 'Push changes and create' : 'Create'} a pull request from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -482,8 +546,8 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Remote selector - only show if multiple remotes are available */}
|
||||
{remotes.length > 1 && (
|
||||
{/* Push remote selector - only show when multiple remotes and there are commits to push */}
|
||||
{remotes.length > 1 && needsPush && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Push to Remote</Label>
|
||||
@@ -525,14 +589,46 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target remote selector - which remote to create PR against */}
|
||||
{remotes.length > 1 && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="target-remote-select">Create PR Against</Label>
|
||||
<Select value={selectedTargetRemote} onValueChange={setSelectedTargetRemote}>
|
||||
<SelectTrigger id="target-remote-select">
|
||||
<SelectValue placeholder="Select target remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The remote repository where the pull request will be created
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base-branch">Base Branch</Label>
|
||||
<Label htmlFor="base-branch">Base Remote Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={baseBranch}
|
||||
onChange={setBaseBranch}
|
||||
branches={branches}
|
||||
placeholder="Select base branch..."
|
||||
disabled={isLoadingBranches}
|
||||
allowCreate={false}
|
||||
emptyMessage="No matching branches found."
|
||||
data-testid="base-branch-autocomplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, AlertCircle } from 'lucide-react';
|
||||
import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
@@ -100,6 +102,145 @@ export function CreateWorktreeDialog({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
||||
|
||||
// Base branch selection state
|
||||
const [showBaseBranch, setShowBaseBranch] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState('');
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [availableBranches, setAvailableBranches] = useState<
|
||||
Array<{ name: string; isRemote: boolean }>
|
||||
>([]);
|
||||
// When the branch list fetch fails, store a message to show the user and
|
||||
// allow free-form branch entry via allowCreate as a fallback.
|
||||
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
|
||||
|
||||
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
|
||||
const branchFetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch available branches (local + remote) when the base branch section is expanded
|
||||
const fetchBranches = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Fetch branches using the project path (use listBranches on the project root).
|
||||
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
|
||||
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
|
||||
|
||||
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
|
||||
if (signal?.aborted) return;
|
||||
|
||||
if (branchResult.success && branchResult.result) {
|
||||
setBranchFetchError(null);
|
||||
setAvailableBranches(
|
||||
branchResult.result.branches.map((b: { name: string; isRemote: boolean }) => ({
|
||||
name: b.name,
|
||||
isRemote: b.isRemote,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// API returned success: false — treat as an error
|
||||
const message =
|
||||
branchResult.error || 'Failed to load branches. You can type a branch name manually.';
|
||||
setBranchFetchError(message);
|
||||
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||
}
|
||||
} catch (err) {
|
||||
// If aborted, don't update state
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to load branches. You can type a branch name manually.';
|
||||
setBranchFetchError(message);
|
||||
// Provide 'main' as a safe fallback so the autocomplete is not empty,
|
||||
// and enable free-form entry (allowCreate) so the user can still type
|
||||
// any branch name when the remote list is unavailable.
|
||||
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Fetch branches when the base branch section is expanded
|
||||
useEffect(() => {
|
||||
if (open && showBaseBranch) {
|
||||
// Abort any previous in-flight fetch
|
||||
branchFetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
branchFetchAbortRef.current = controller;
|
||||
fetchBranches(controller.signal);
|
||||
}
|
||||
return () => {
|
||||
branchFetchAbortRef.current?.abort();
|
||||
branchFetchAbortRef.current = null;
|
||||
};
|
||||
}, [open, showBaseBranch, fetchBranches]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Abort any in-flight branch fetch to prevent stale writes
|
||||
branchFetchAbortRef.current?.abort();
|
||||
branchFetchAbortRef.current = null;
|
||||
|
||||
setBranchName('');
|
||||
setBaseBranch('');
|
||||
setShowBaseBranch(false);
|
||||
setError(null);
|
||||
setAvailableBranches([]);
|
||||
setBranchFetchError(null);
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Build branch name list for the autocomplete, with local branches first then remote
|
||||
const branchNames = useMemo(() => {
|
||||
const local: string[] = [];
|
||||
const remote: string[] = [];
|
||||
|
||||
for (const b of availableBranches) {
|
||||
if (b.isRemote) {
|
||||
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
|
||||
if (!b.name.includes('/')) continue;
|
||||
remote.push(b.name);
|
||||
} else {
|
||||
local.push(b.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Local branches first, then remote branches
|
||||
return [...local, ...remote];
|
||||
}, [availableBranches]);
|
||||
|
||||
// Determine if the selected base branch is a remote branch.
|
||||
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
||||
// the branch isn't in the fetched availableBranches list.
|
||||
const isRemoteBaseBranch = useMemo(() => {
|
||||
if (!baseBranch) return false;
|
||||
// If the branch list couldn't be fetched, availableBranches is a fallback
|
||||
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
|
||||
if (branchFetchError) return false;
|
||||
// Check fetched branch list first
|
||||
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
||||
if (knownRemote) return true;
|
||||
// Heuristic: if the branch contains '/' and isn't a known local branch,
|
||||
// treat it as a remote ref (e.g. "origin/main")
|
||||
if (baseBranch.includes('/')) {
|
||||
const isKnownLocal = availableBranches.some((b) => b.name === baseBranch && !b.isRemote);
|
||||
return !isKnownLocal;
|
||||
}
|
||||
return false;
|
||||
}, [baseBranch, availableBranches, branchFetchError]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
setError({ title: 'Branch name is required' });
|
||||
@@ -116,6 +257,17 @@ export function CreateWorktreeDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate baseBranch using the same allowed-character check as branchName to prevent
|
||||
// shell-special characters or invalid git ref names from reaching the API.
|
||||
const trimmedBaseBranch = baseBranch.trim();
|
||||
if (trimmedBaseBranch && !validBranchRegex.test(trimmedBaseBranch)) {
|
||||
setError({
|
||||
title: 'Invalid base branch name',
|
||||
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -125,15 +277,22 @@ export function CreateWorktreeDialog({
|
||||
setError({ title: 'Worktree API not available' });
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
|
||||
const effectiveBaseBranch = trimmedBaseBranch || undefined;
|
||||
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
|
||||
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch',
|
||||
description: result.worktree.isNew
|
||||
? `New branch created${baseDesc}`
|
||||
: 'Using existing branch',
|
||||
});
|
||||
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||
onOpenChange(false);
|
||||
setBranchName('');
|
||||
setBaseBranch('');
|
||||
} else {
|
||||
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||
}
|
||||
@@ -154,7 +313,7 @@ export function CreateWorktreeDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
@@ -181,19 +340,96 @@ export function CreateWorktreeDialog({
|
||||
className="font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
||||
{error.description && (
|
||||
<p className="text-xs text-destructive/80">{error.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base Branch Section - collapsible */}
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBaseBranch(!showBaseBranch)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
{showBaseBranch ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Base Branch</span>
|
||||
{baseBranch && !showBaseBranch && (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
||||
{baseBranch}
|
||||
</code>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showBaseBranch && (
|
||||
<div className="grid gap-2 pl-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a local or remote branch as the starting point
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
branchFetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
branchFetchAbortRef.current = controller;
|
||||
void fetchBranches(controller.signal);
|
||||
}}
|
||||
disabled={isLoadingBranches}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isLoadingBranches ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{branchFetchError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertCircle className="w-3 h-3 flex-shrink-0" />
|
||||
<span>Could not load branches: {branchFetchError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BranchAutocomplete
|
||||
value={baseBranch}
|
||||
onChange={(value) => {
|
||||
setBaseBranch(value);
|
||||
setError(null);
|
||||
}}
|
||||
branches={branchNames}
|
||||
placeholder="Select base branch (default: HEAD)..."
|
||||
disabled={isLoadingBranches}
|
||||
allowCreate={!!branchFetchError}
|
||||
/>
|
||||
|
||||
{isRemoteBaseBranch && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Globe className="w-3 h-3" />
|
||||
<span>Remote branch — will fetch latest before creating worktree</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
||||
{error.description && (
|
||||
<p className="text-xs text-destructive/80">{error.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Examples:</p>
|
||||
<ul className="list-disc list-inside pl-2 space-y-0.5">
|
||||
@@ -218,7 +454,7 @@ export function CreateWorktreeDialog({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
{isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -24,8 +24,8 @@ interface MergeWorktreeDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
|
||||
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
/** Called when integration is successful. integratedWorktree indicates the integrated worktree and deletedBranch indicates if the branch was also deleted. */
|
||||
onIntegrated: (integratedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function MergeWorktreeDialog({
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onMerged,
|
||||
onIntegrated,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: MergeWorktreeDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -105,10 +105,10 @@ export function MergeWorktreeDialog({
|
||||
|
||||
if (result.success) {
|
||||
const description = deleteWorktreeAndBranch
|
||||
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
|
||||
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
|
||||
toast.success(`Branch merged to ${targetBranch}`, { description });
|
||||
onMerged(worktree, deleteWorktreeAndBranch);
|
||||
? `Branch "${worktree.branch}" has been integrated into "${targetBranch}" and the worktree and branch were deleted`
|
||||
: `Branch "${worktree.branch}" has been integrated into "${targetBranch}"`;
|
||||
toast.success(`Branch integrated into ${targetBranch}`, { description });
|
||||
onIntegrated(worktree, deleteWorktreeAndBranch);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Check if the error indicates merge conflicts
|
||||
@@ -128,11 +128,11 @@ export function MergeWorktreeDialog({
|
||||
conflictFiles: result.conflictFiles || [],
|
||||
operationType: 'merge',
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
toast.error('Integrate conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
toast.error('Failed to integrate branch', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
@@ -153,11 +153,11 @@ export function MergeWorktreeDialog({
|
||||
conflictFiles: [],
|
||||
operationType: 'merge',
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
toast.error('Integrate conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
toast.error('Failed to integrate branch', {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
@@ -191,12 +191,12 @@ export function MergeWorktreeDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Merge Conflicts Detected
|
||||
Integrate Conflicts Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
There are conflicts when merging{' '}
|
||||
There are conflicts when integrating{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{mergeConflict.sourceBranch}
|
||||
</code>{' '}
|
||||
@@ -274,12 +274,12 @@ export function MergeWorktreeDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge Branch
|
||||
Integrate Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||
Integrate <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||
into:
|
||||
</span>
|
||||
|
||||
@@ -308,7 +308,7 @@ export function MergeWorktreeDialog({
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
commit or discard them before integrating.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -327,7 +327,7 @@ export function MergeWorktreeDialog({
|
||||
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
Delete worktree and branch after merging
|
||||
Delete worktree and branch after integrating
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -353,12 +353,12 @@ export function MergeWorktreeDialog({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Merging...
|
||||
Integrating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Merge
|
||||
Integrate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
|
||||
@@ -367,7 +367,7 @@ export function ViewStashesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Archive className="w-5 h-5" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
|
||||
Reference in New Issue
Block a user