1e40540ef4
- Add isMirror prop to GitToolbar\n- Show warning banner when repo is a bare mirror\n- Explain that editing/committing/pulling/merging are unavailable\n- Suggest deleting and recreating to enable full features\n\nQuality gates: vitest (43 passed)
277 lines
7.9 KiB
TypeScript
277 lines
7.9 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import {
|
|
checkoutBranch,
|
|
createBranch,
|
|
fetchRepository,
|
|
getRepositoryStatus,
|
|
pullRepository,
|
|
pushRepository,
|
|
type GitStatus,
|
|
} from "../api/git_repositories";
|
|
import { Icon } from "./icon";
|
|
import { MergeDialog } from "./merge-dialog";
|
|
|
|
interface GitToolbarProps {
|
|
projectId: string;
|
|
repoId: string;
|
|
currentBranch: string;
|
|
branches: string[];
|
|
hasRemote: boolean;
|
|
isMirror: boolean;
|
|
onBranchChange: (branch: string) => void;
|
|
onRefresh: () => void;
|
|
}
|
|
|
|
export const GitToolbar = ({
|
|
projectId,
|
|
repoId,
|
|
currentBranch,
|
|
branches,
|
|
hasRemote,
|
|
isMirror,
|
|
onBranchChange,
|
|
onRefresh,
|
|
}: GitToolbarProps) => {
|
|
const [status, setStatus] = useState<GitStatus | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showNewBranch, setShowNewBranch] = useState(false);
|
|
const [newBranchName, setNewBranchName] = useState("");
|
|
const [newBranchBase, setNewBranchBase] = useState("");
|
|
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
try {
|
|
const data = await getRepositoryStatus(projectId, repoId);
|
|
setStatus(data);
|
|
setError(null);
|
|
} catch {
|
|
setError("Failed to load status");
|
|
}
|
|
}, [projectId, repoId]);
|
|
|
|
useEffect(() => {
|
|
void loadStatus();
|
|
// Poll status every 5 seconds
|
|
const interval = setInterval(() => void loadStatus(), 5000);
|
|
return () => clearInterval(interval);
|
|
}, [loadStatus]);
|
|
|
|
const handleFetch = async () => {
|
|
if (!hasRemote) return;
|
|
setLoading(true);
|
|
try {
|
|
await fetchRepository(projectId, repoId);
|
|
await loadStatus();
|
|
} catch {
|
|
setError("Fetch failed");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handlePull = async () => {
|
|
if (!hasRemote) return;
|
|
setLoading(true);
|
|
try {
|
|
await pullRepository(projectId, repoId, currentBranch || undefined);
|
|
await loadStatus();
|
|
onRefresh();
|
|
} catch {
|
|
setError("Pull failed");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handlePush = async () => {
|
|
setLoading(true);
|
|
try {
|
|
await pushRepository(projectId, repoId, currentBranch);
|
|
await loadStatus();
|
|
} catch {
|
|
setError("Push failed");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCheckout = async (branch: string) => {
|
|
setLoading(true);
|
|
try {
|
|
await checkoutBranch(projectId, repoId, branch);
|
|
onBranchChange(branch);
|
|
onRefresh();
|
|
} catch {
|
|
setError("Checkout failed");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateBranch = async () => {
|
|
if (!newBranchName.trim()) return;
|
|
setLoading(true);
|
|
try {
|
|
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
|
|
setShowNewBranch(false);
|
|
setNewBranchName("");
|
|
setNewBranchBase("");
|
|
onRefresh();
|
|
} catch {
|
|
setError("Failed to create branch");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const hasChanges = status && (
|
|
status.modified.length > 0 ||
|
|
status.added.length > 0 ||
|
|
status.deleted.length > 0 ||
|
|
status.untracked.length > 0
|
|
);
|
|
|
|
const canSync = hasRemote;
|
|
|
|
return (
|
|
<div className="git-toolbar">
|
|
{error && <div className="toolbar-error">{error}</div>}
|
|
{isMirror && (
|
|
<div className="warning-message">
|
|
<Icon name="warning" size="sm" /> This repository is a bare mirror.
|
|
Editing, committing, pulling, and merging are not available.
|
|
Delete and recreate it to enable full workspace features.
|
|
</div>
|
|
)}
|
|
<div className="toolbar-row">
|
|
<div className="toolbar-group">
|
|
<select
|
|
value={currentBranch}
|
|
onChange={(e) => handleCheckout(e.target.value)}
|
|
disabled={loading}
|
|
className="branch-select"
|
|
>
|
|
{branches.map((b) => (
|
|
<option key={b} value={b}>
|
|
{b === currentBranch ? (
|
|
<>
|
|
<Icon name="branch" size="sm" /> {b}
|
|
</>
|
|
) : (
|
|
b
|
|
)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="toolbar-button"
|
|
onClick={() => setShowNewBranch(!showNewBranch)}
|
|
disabled={loading}
|
|
type="button"
|
|
>
|
|
<Icon name="add" size="sm" /> New
|
|
</button>
|
|
</div>
|
|
|
|
<div className="toolbar-group">
|
|
<button
|
|
className="toolbar-button"
|
|
onClick={handleFetch}
|
|
disabled={loading || !canSync}
|
|
type="button"
|
|
>
|
|
<Icon name="fetch" size="sm" /> Fetch
|
|
</button>
|
|
<button
|
|
className="toolbar-button"
|
|
onClick={handlePull}
|
|
disabled={loading || !canSync}
|
|
type="button"
|
|
>
|
|
<Icon name="pull" size="sm" /> Pull
|
|
{status?.behind ? <span className="badge">{status.behind}</span> : null}
|
|
</button>
|
|
<button
|
|
className="toolbar-button"
|
|
onClick={handlePush}
|
|
disabled={loading || !canSync || !status?.ahead}
|
|
type="button"
|
|
>
|
|
<Icon name="push" size="sm" /> Push
|
|
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
|
|
</button>
|
|
<button
|
|
className="toolbar-button"
|
|
onClick={() => setShowMergeDialog(true)}
|
|
disabled={loading}
|
|
type="button"
|
|
>
|
|
<Icon name="merge" size="sm" /> Merge
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showNewBranch && (
|
|
<div className="toolbar-row new-branch-form">
|
|
<input
|
|
type="text"
|
|
placeholder="Branch name"
|
|
value={newBranchName}
|
|
onChange={(e) => setNewBranchName(e.target.value)}
|
|
className="toolbar-input"
|
|
/>
|
|
<select
|
|
value={newBranchBase}
|
|
onChange={(e) => setNewBranchBase(e.target.value)}
|
|
className="toolbar-input"
|
|
>
|
|
<option value="">Base: HEAD</option>
|
|
{branches.map((b) => (
|
|
<option key={b} value={b}>{ b}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="toolbar-button primary"
|
|
onClick={handleCreateBranch}
|
|
disabled={loading || !newBranchName.trim()}
|
|
type="button"
|
|
>
|
|
<Icon name="add" size="sm" /> Create
|
|
</button>
|
|
<button
|
|
className="toolbar-button"
|
|
onClick={() => setShowNewBranch(false)}
|
|
type="button"
|
|
>
|
|
<Icon name="cancel" size="sm" /> Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{hasChanges && status && (
|
|
<div className="toolbar-row status-summary">
|
|
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
|
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
|
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
|
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
|
</div>
|
|
)}
|
|
|
|
<MergeDialog
|
|
projectId={projectId}
|
|
repoId={repoId}
|
|
branches={branches}
|
|
currentBranch={currentBranch}
|
|
isOpen={showMergeDialog}
|
|
onClose={() => setShowMergeDialog(false)}
|
|
onMerge={() => {
|
|
void loadStatus();
|
|
onRefresh();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|