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
+131
View File
@@ -112,3 +112,134 @@ export async function getCommitDetail(
);
return response.data;
}
// Git Control API
export interface GitStatus {
branch: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
renamed: string[];
ahead: number;
behind: number;
}
export async function getRepositoryStatus(
projectId: string,
repoId: string
): Promise<GitStatus> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/status`
);
return response.data;
}
export async function createBranch(
projectId: string,
repoId: string,
name: string,
baseBranch: string = "HEAD"
): Promise<{ message: string; branch: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/branches`,
{ name, base_branch: baseBranch }
);
return response.data;
}
export async function deleteBranch(
projectId: string,
repoId: string,
branchName: string,
force: boolean = false
): Promise<{ message: string }> {
const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`
);
return response.data;
}
export async function checkoutBranch(
projectId: string,
repoId: string,
branch: string
): Promise<{ message: string; branch: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/checkout`,
{ branch }
);
return response.data;
}
export interface CommitResponse {
commit_hash: string;
message: string;
}
export async function commitChanges(
projectId: string,
repoId: string,
message: string,
files?: string[]
): Promise<CommitResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/commit`,
{ message, files }
);
return response.data;
}
export async function fetchRepository(
projectId: string,
repoId: string
): Promise<{ message: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/fetch`
);
return response.data;
}
export async function pullRepository(
projectId: string,
repoId: string,
branch?: string
): Promise<{ message: string }> {
const params = branch ? `?branch=${branch}` : "";
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/pull${params}`
);
return response.data;
}
export async function pushRepository(
projectId: string,
repoId: string,
branch?: string
): Promise<{ message: string }> {
const params = branch ? `?branch=${branch}` : "";
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/push${params}`
);
return response.data;
}
export interface MergeResponse {
commit_hash: string;
message: string;
}
export async function mergeBranches(
projectId: string,
repoId: string,
sourceBranch: string,
targetBranch?: string,
message?: string
): Promise<MergeResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/merge`,
{ source_branch: sourceBranch, target_branch: targetBranch, message }
);
return response.data;
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { commitChanges } from "../api/git_repositories";
interface CommitPanelProps {
projectId: string;
repoId: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
onCommit: () => void;
}
export const CommitPanel = ({
projectId,
repoId,
modified,
added,
deleted,
untracked,
onCommit,
}: CommitPanelProps) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0;
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError(null);
try {
await commitChanges(projectId, repoId, message);
setMessage("");
onCommit();
} catch {
setError("Commit failed. Please try again.");
} finally {
setLoading(false);
}
};
if (!hasChanges) return null;
return (
<div className="commit-panel">
<h4>Changes</h4>
<div className="file-list">
{modified.map((file) => (
<div key={file} className="file-item modified">
<span className="file-status">M</span>
<span className="file-name">{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className="file-item added">
<span className="file-status">A</span>
<span className="file-name">{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className="file-item deleted">
<span className="file-status">D</span>
<span className="file-name">{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className="file-item untracked">
<span className="file-status">?</span>
<span className="file-name">{file}</span>
</div>
))}
</div>
<div className="commit-form">
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className="commit-message-input"
/>
{error && <div className="commit-error">{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className="commit-button"
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
};
+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>
);
};
+120 -15
View File
@@ -3,7 +3,14 @@ import { useCallback, useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import {
getRepositoryStatus,
listRepositories,
type GitRepository,
type GitStatus,
} from "../api/git_repositories";
import { CommitPanel } from "../components/commit-panel";
import { GitToolbar } from "../components/git-toolbar";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
@@ -30,6 +37,9 @@ export const RepoWorkspace = () => {
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo")
);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
@@ -57,10 +67,42 @@ export const RepoWorkspace = () => {
}
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
const loadBranches = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
);
const branchList = response.data.branches.map((b: { name: string }) => b.name);
setBranches(branchList);
const defaultBranch = response.data.default_branch;
if (defaultBranch) {
setCurrentBranch(defaultBranch);
}
} catch {
setBranches([]);
}
}, [projectId, selectedRepoId]);
const loadGitStatus = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const data = await getRepositoryStatus(projectId, selectedRepoId);
setGitStatus(data);
} catch {
setGitStatus(null);
}
}, [projectId, selectedRepoId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
useEffect(() => {
void loadBranches();
void loadGitStatus();
}, [loadBranches, loadGitStatus]);
const handleRepoChange = (repoId: string) => {
setSelectedRepoId(repoId);
const newParams = new URLSearchParams(searchParams);
@@ -138,10 +180,44 @@ export const RepoWorkspace = () => {
</div>
{selectedRepoId && (
<FileBrowser
projectId={projectId!}
repoId={selectedRepoId}
/>
<>
<GitToolbar
projectId={projectId!}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
onRefresh={() => {
void loadBranches();
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
<FileBrowser
projectId={projectId!}
repoId={selectedRepoId}
gitStatus={gitStatus}
/>
{gitStatus && (
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
</>
)}
</aside>
@@ -160,9 +236,11 @@ export const RepoWorkspace = () => {
const FileBrowser = ({
projectId,
repoId,
gitStatus,
}: {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
@@ -197,6 +275,13 @@ const FileBrowser = ({
void loadFiles();
}, [loadFiles]);
// Listen for refresh events
useEffect(() => {
const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams);
@@ -221,6 +306,15 @@ const FileBrowser = ({
setSearchParams(newParams);
};
const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked";
return null;
};
if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>;
@@ -231,16 +325,27 @@ const FileBrowser = ({
📁 ..
</button>
)}
{entries.map((entry) => (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
{entry.type === "directory" ? "📁" : "📄"} {entry.name}
</button>
))}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
{entry.type === "directory" ? "📁" : "📄"} {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
};
+108
View File
@@ -842,3 +842,111 @@ a {
height: 100%;
min-height: 300px;
}
/* File Status Indicators */
.file-status-indicator {
float: right;
font-size: 0.75rem;
font-weight: bold;
padding: 0 0.375rem;
border-radius: 3px;
margin-left: 0.5rem;
}
.file-status-indicator.modified {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.file-status-indicator.added {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
.file-status-indicator.deleted {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.file-status-indicator.untracked {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
/* Commit Panel */
.commit-panel {
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--panel);
}
.commit-panel h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
}
.file-list {
max-height: 150px;
overflow: auto;
margin-bottom: 0.75rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.file-status {
font-weight: bold;
font-size: 0.75rem;
width: 1rem;
text-align: center;
}
.file-item.modified .file-status { color: #f59e0b; }
.file-item.added .file-status { color: #10b981; }
.file-item.deleted .file-status { color: #ef4444; }
.file-item.untracked .file-status { color: #6b7280; }
.commit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-message-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
}
.commit-button {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.commit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.commit-error {
color: #ef4444;
font-size: 0.8125rem;
}