feat: add commit panel and file status indicators to repo workspace

- Add CommitPanel component for viewing changed files and committing
- Show file status indicators (M/A/D/?) in file tree
- Integrate git status with workspace for real-time updates
- Add CSS styles for commit panel and status badges

Part of git-control change implementation.
This commit is contained in:
Fusion
2026-05-19 15:00:25 +02:00
parent 762e0de44c
commit 875594d66d
21 changed files with 2242 additions and 73 deletions
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useState } from "react";
import {
checkoutBranch,
createBranch,
fetchRepository,
getRepositoryStatus,
pullRepository,
pushRepository,
type GitStatus,
} from "../api/git_repositories";
interface GitToolbarProps {
projectId: string;
repoId: string;
currentBranch: string;
branches: string[];
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const GitToolbar = ({
projectId,
repoId,
currentBranch,
branches,
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 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 () => {
setLoading(true);
try {
await fetchRepository(projectId, repoId);
await loadStatus();
} catch {
setError("Fetch failed");
} finally {
setLoading(false);
}
};
const handlePull = async () => {
setLoading(true);
try {
await pullRepository(projectId, repoId, currentBranch);
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 || "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
);
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</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 ? `${b}` : b}
</option>
))}
</select>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(!showNewBranch)}
disabled={loading}
type="button"
>
+ New
</button>
</div>
<div className="toolbar-group">
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading}
type="button"
>
Fetch
</button>
<button
className="toolbar-button"
onClick={handlePull}
disabled={loading}
type="button"
>
Pull
{status?.behind ? <span className="badge">{status.behind}</span> : null}
</button>
<button
className="toolbar-button"
onClick={handlePush}
disabled={loading || !status?.ahead}
type="button"
>
Push
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
</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"
>
Create
</button>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(false)}
type="button"
>
Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className="toolbar-row status-summary">
{status.modified.length > 0 && <span className="status-badge modified"> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted">🗑 {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"> {status.untracked.length} untracked</span>}
</div>
)}
</div>
);
};