feat: add remote management functionality

- Introduced a new route for adding remotes to git worktrees.
- Enhanced the PushToRemoteDialog component to support adding new remotes, including form handling and error management.
- Updated the API client to include an endpoint for adding remotes.
- Modified the worktree state management to track the presence of remotes.
- Improved the list branches handler to check for configured remotes.

This update allows users to easily add remotes through the UI, enhancing the overall git workflow experience.
This commit is contained in:
Shirone
2026-01-21 22:11:16 +01:00
parent c3cecc18f2
commit a9616ff309
12 changed files with 1142 additions and 95 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -19,7 +20,7 @@ import {
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
import { Upload, RefreshCw, AlertTriangle, Sparkles, Plus, Link } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
@@ -30,6 +31,16 @@ interface RemoteInfo {
const logger = createLogger('PushToRemoteDialog');
/**
* Extracts error message from unknown error type
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
interface PushToRemoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -49,6 +60,13 @@ export function PushToRemoteDialog({
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add remote form state
const [showAddRemoteForm, setShowAddRemoteForm] = useState(false);
const [newRemoteName, setNewRemoteName] = useState('origin');
const [newRemoteUrl, setNewRemoteUrl] = useState('');
const [isAddingRemote, setIsAddingRemote] = useState(false);
const [addRemoteError, setAddRemoteError] = useState<string | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
@@ -61,6 +79,10 @@ export function PushToRemoteDialog({
if (!open) {
setSelectedRemote('');
setError(null);
setShowAddRemoteForm(false);
setNewRemoteName('origin');
setNewRemoteUrl('');
setAddRemoteError(null);
}
}, [open]);
@@ -73,6 +95,36 @@ export function PushToRemoteDialog({
}
}, [remotes, selectedRemote]);
// Show add remote form when no remotes
useEffect(() => {
if (!isLoading && remotes.length === 0) {
setShowAddRemoteForm(true);
}
}, [isLoading, remotes.length]);
/**
* Transforms API remote data to RemoteInfo format
*/
const transformRemoteData = useCallback(
(remotes: Array<{ name: string; url: string }>): RemoteInfo[] => {
return remotes.map((r) => ({
name: r.name,
url: r.url,
}));
},
[]
);
/**
* Updates remotes state and hides add form if remotes exist
*/
const updateRemotesState = useCallback((remoteInfos: RemoteInfo[]) => {
setRemotes(remoteInfos);
if (remoteInfos.length > 0) {
setShowAddRemoteForm(false);
}
}, []);
const fetchRemotes = async () => {
if (!worktree) return;
@@ -84,21 +136,14 @@ export function PushToRemoteDialog({
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
// Extract just the remote info (name and URL), not the branches
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
if (remoteInfos.length === 0) {
setError('No remotes found in this repository. Please add a remote first.');
}
const remoteInfos = transformRemoteData(result.result.remotes);
updateRemotesState(remoteInfos);
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
@@ -115,47 +160,270 @@ export function PushToRemoteDialog({
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
const remoteInfos = transformRemoteData(result.result.remotes);
updateRemotesState(remoteInfos);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
toast.error(getErrorMessage(err));
} finally {
setIsRefreshing(false);
}
};
const handleAddRemote = async () => {
if (!worktree || !newRemoteName.trim() || !newRemoteUrl.trim()) return;
setIsAddingRemote(true);
setAddRemoteError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.addRemote(
worktree.path,
newRemoteName.trim(),
newRemoteUrl.trim()
);
if (result.success && result.result) {
toast.success(result.result.message);
// Add the new remote to the list and select it
const newRemote: RemoteInfo = {
name: result.result.remoteName,
url: result.result.remoteUrl,
};
setRemotes((prev) => [...prev, newRemote]);
setSelectedRemote(newRemote.name);
setShowAddRemoteForm(false);
setNewRemoteName('origin');
setNewRemoteUrl('');
} else {
setAddRemoteError(result.error || 'Failed to add remote');
}
} catch (err) {
logger.error('Failed to add remote:', err);
setAddRemoteError(getErrorMessage(err));
} finally {
setIsAddingRemote(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
const renderAddRemoteForm = () => (
<div className="grid gap-4 py-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Link className="w-4 h-4" />
<span className="text-sm">
{remotes.length === 0
? 'No remotes found. Add a remote to push your branch.'
: 'Add a new remote'}
</span>
</div>
<div className="grid gap-2">
<Label htmlFor="remote-name">Remote Name</Label>
<Input
id="remote-name"
placeholder="origin"
value={newRemoteName}
onChange={(e) => {
setNewRemoteName(e.target.value);
setAddRemoteError(null);
}}
disabled={isAddingRemote}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="remote-url">Remote URL</Label>
<Input
id="remote-url"
placeholder="https://github.com/user/repo.git"
value={newRemoteUrl}
onChange={(e) => {
setNewRemoteUrl(e.target.value);
setAddRemoteError(null);
}}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
newRemoteName.trim() &&
newRemoteUrl.trim() &&
!isAddingRemote
) {
handleAddRemote();
}
}}
disabled={isAddingRemote}
/>
<p className="text-xs text-muted-foreground">
Supports HTTPS, SSH (git@github.com:user/repo.git), or git:// URLs
</p>
</div>
{addRemoteError && (
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{addRemoteError}</span>
</div>
)}
</div>
);
const renderRemoteSelector = () => (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddRemoteForm(true)}
className="h-6 px-2 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
);
const renderFooter = () => {
if (showAddRemoteForm) {
return (
<DialogFooter>
{remotes.length > 0 && (
<Button
variant="outline"
onClick={() => setShowAddRemoteForm(false)}
disabled={isAddingRemote}
>
Back
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAddingRemote}>
Cancel
</Button>
<Button
onClick={handleAddRemote}
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || isAddingRemote}
>
{isAddingRemote ? (
<>
<Spinner size="sm" className="mr-2" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Remote
</>
)}
</Button>
</DialogFooter>
);
}
return (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
{showAddRemoteForm ? (
<>
<Plus className="w-5 h-5 text-primary" />
Add Remote
</>
) : (
<>
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
</>
)}
</DialogTitle>
<DialogDescription>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
{showAddRemoteForm ? (
<>Add a remote repository to push your changes to.</>
) : (
<>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
</>
)}
</DialogDescription>
</DialogHeader>
@@ -163,7 +431,7 @@ export function PushToRemoteDialog({
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
) : error && !showAddRemoteForm ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
@@ -174,68 +442,13 @@ export function PushToRemoteDialog({
Retry
</Button>
</div>
) : showAddRemoteForm ? (
renderAddRemoteForm()
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
renderRemoteSelector()
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
{renderFooter()}
</DialogContent>
</Dialog>
);

View File

@@ -27,7 +27,7 @@ import {
Copy,
Eye,
ScrollText,
Sparkles,
CloudOff,
Terminal,
SquarePlus,
SplitSquareHorizontal,
@@ -365,9 +365,9 @@ export function WorktreeActionsDropdown({
{isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
<Sparkles className="w-2.5 h-2.5" />
new
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (