refactor: organize frontend components into features/ directories

Moved 43 component files into 9 feature domains:
- features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor,
  git-toolbar, merge-dialog
- features/project/ — repositories-settings-tab, repository-create-dialog
- features/terminal/ — special-keys-panel, special-keys-strip,
  terminal-session-tabs, terminal
- features/workspace/ — workspace-card, workspace-create-form,
  workspace-header, workspace-instance-chips
- features/session/ — create-session-form, session-card, session-list
- features/tool/ — instance-list, manifest-editor, start-tool-fab,
  start-tool-modal, tool-starter, tools-bottom-sheet
- features/notification/ — event-toast-bridge, notification-center,
  notification-item
- features/settings/ — settings-tab-layout
- features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view,
  mobile-fab, mobile-list-view, mobile-nav, mobile-page-header,
  mobile-terminal-header, mobile-terminal-wrapper

Updated all imports across pages and components.
Root components/ now only contains generic UI pieces:
app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter.

Quality gates: verified no remaining old imports.
This commit is contained in:
2026-06-04 12:37:24 +02:00
parent 7224afafd1
commit 1021d61be3
54 changed files with 31 additions and 31 deletions
@@ -0,0 +1,159 @@
import React, { useState } from "react";
import { Icon } from "./icon";
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
originalContent: string;
newContent: string;
onCommit: (message: string) => Promise<void>;
onCancel: () => void;
}
export const CommitDialog: React.FC<CommitDialogProps> = ({
isOpen,
filePath,
originalContent,
newContent,
onCommit,
onCancel,
}) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(">");
if (!isOpen) return null;
const generateDiff = () => {
const originalLines = originalContent.split("\n");
const newLines = newContent.split("\n");
const maxLines = Math.max(originalLines.length, newLines.length);
const diff: { type: "same" | "added" | "removed"; line: string; lineNum: number }[] = [];
for (let i = 0; i < maxLines; i++) {
const original = originalLines[i] || "";
const updated = newLines[i] || "";
if (original === updated) {
diff.push({ type: "same", line: updated, lineNum: i + 1 });
} else {
if (original) {
diff.push({ type: "removed", line: original, lineNum: i + 1 });
}
if (updated) {
diff.push({ type: "added", line: updated, lineNum: i + 1 });
}
}
}
return diff;
};
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError("");
try {
await onCommit(message);
} catch {
setError("Failed to commit changes");
} finally {
setLoading(false);
}
};
const diff = generateDiff();
const hasChanges = diff.some((d) => d.type !== "same");
return (
<div className="dialog-overlay">
<div className="commit-dialog">
<div className="dialog-header">
<h3>Commit Changes</h3>
<button className="dialog-close" onClick={onCancel} type="button">
×
</button>
</div>
<div className="dialog-body">
<p className="file-info">
Editing: <strong>{filePath}</strong>
</p>
{!hasChanges && (
<div className="warning-message">No changes to commit</div>
)}
{hasChanges && (
<div className="diff-preview">
<h4>Changes</h4>
<div className="diff-content">
{diff.map((line, i) => (
<div
key={i}
className={`diff-line diff-${line.type}`}
>
<span className="diff-line-number">{line.lineNum}</span>
<span className="diff-marker">
{line.type === "added" && "+"}
{line.type === "removed" && "-"}
{line.type === "same" && " "}
</span>
<span className="diff-line-content">{line.line}</span>
</div>
))}
</div>
</div>
)}
<div className="form-group">
<label>Commit Message *</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your changes..."
rows={3}
className="form-textarea"
/>
</div>
{error && <div className="error-message">{error}</div>}
</div>
<div className="dialog-footer">
<button
className="btn-secondary"
onClick={onCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
className="btn-primary"
onClick={handleCommit}
disabled={loading || !hasChanges || !message.trim()}
type="button"
>
{loading ? (
<>
<Icon name="loading" size="sm" />
Committing...
</>
) : (
<>
<Icon name="commit" size="sm" />
Commit Changes
</>
)}
</button>
</div>
</div>
</div>
);
};
@@ -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>
);
};
@@ -0,0 +1,241 @@
import React, { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { useAuth } from "../state/auth";
import { CodeEditor } from "../components/code-editor";
import { CommitDialog } from "../components/features/git/commit-dialog";
import { Icon } from "../components/icon";
import { SyntaxHighlighter } from "../components/syntax-highlighter";
import { detectLanguage } from "../utils/language";
interface FileEditorProps {
projectId: string;
repoId: string;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const [mode, setMode] = useState<"view" | "edit">("view");
const [content, setContent] = useState(">");
const [originalContent, setOriginalContent] = useState(">");
const [language, setLanguage] = useState("plaintext");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
setOriginalContent("");
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
setLanguage(detectLanguage(filePath));
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
const handleEdit = () => {
if (isBinary) return;
setMode("edit");
};
const handleCancel = () => {
setContent(originalContent);
setMode("view");
setShowCommitDialog(false);
};
const handleSave = () => {
if (content === originalContent) {
setMode("view");
return;
}
setShowCommitDialog(true);
};
const handleCommit = async (message: string) => {
if (!filePath || !user) return;
setSaving(true);
try {
await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
path: filePath,
branch,
content,
commit_message: message,
author_name: user.name || "User",
author_email: user.email || "user@example.com",
}
);
setOriginalContent(content);
setMode("view");
setShowCommitDialog(false);
} catch {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
if (mode === "view" && !isBinary) {
handleEdit();
} else if (mode === "edit") {
handleCancel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (mode === "edit") {
handleSave();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, isBinary, content, originalContent]);
if (!filePath) {
return (
<div className="file-viewer-empty">
<p className="muted">Select a file to view its contents</p>
</div>
);
}
if (loading) return <p className="muted">Loading file...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-editor">
<div className="file-editor-toolbar">
<div className="file-breadcrumbs">
{filePath.split("/").map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
<span className="breadcrumb-sep">/</span>
)}
</span>
))}
</div>
<div className="file-actions">
{mode === "view" && !isBinary && (
<button
className="btn-primary"
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
)}
</div>
</div>
<div className="file-editor-content">
{mode === "view" && (
<SyntaxHighlighter
code={content}
language={language}
showLineNumbers={!isBinary}
/>
)}
{mode === "edit" && (
<CodeEditor
value={content}
onChange={setContent}
language={language}
/>
)}
</div>
<CommitDialog
isOpen={showCommitDialog}
filePath={filePath}
originalContent={originalContent}
newContent={content}
onCommit={handleCommit}
onCancel={() => setShowCommitDialog(false)}
/>
</div>
);
};
@@ -0,0 +1,574 @@
import { useState, useEffect } from "react";
import { Icon } from "./icon";
import { validateGitUrl } from "../api/config_profiles";
import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
}
function normalizeMount(mount: GitMount): GitMount {
// Auto-convert legacy source_path + target_path to mappings
if (
(!mount.mappings || mount.mappings.length === 0) &&
mount.source_path !== undefined &&
mount.target_path !== undefined
) {
return {
remote_url: mount.remote_url,
branch: mount.branch,
mappings: [
{
source_path: mount.source_path || ".",
target_path: mount.target_path,
},
],
};
}
return mount;
}
function normalizeMounts(mounts: GitMount[]): GitMount[] {
return mounts.map(normalizeMount);
}
export const GitMountEditor = ({
mounts,
onChange,
}: GitMountEditorProps) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
normalizeMounts(mounts),
);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [isAdding, setIsAdding] = useState(false);
useEffect(() => {
setNormalizedMounts(normalizeMounts(mounts));
}, [mounts]);
const handleAdd = (mount: GitMount) => {
const updated = [...normalizedMounts, normalizeMount(mount)];
setNormalizedMounts(updated);
onChange(updated);
setIsAdding(false);
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...normalizedMounts];
updatedMounts[index] = normalizeMount(updated);
setNormalizedMounts(updatedMounts);
onChange(updatedMounts);
setEditingIndex(null);
};
const handleRemove = (index: number) => {
const updated = normalizedMounts.filter((_, i) => i !== index);
setNormalizedMounts(updated);
onChange(updated);
};
return (
<div className="git-mount-editor">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
<p
className="muted"
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
>
Clone a repository once and mount multiple directories from it.
</p>
{normalizedMounts.length > 0 && (
<div
className="git-mount-list"
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginBottom: "1rem",
}}
>
{normalizedMounts.map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem" }}>
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
/>
) : (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: "0.5rem",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
marginBottom: "0.25rem",
}}
>
{mount.remote_url}
{mount.branch && (
<span
style={{
color: "var(--muted)",
fontWeight: 400,
marginLeft: "0.5rem",
}}
>
@{mount.branch}
</span>
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
{mount.mappings?.map((m, mi) => (
<div
key={mi}
style={{
fontSize: "0.875rem",
color: "var(--muted)",
fontFamily: "monospace",
}}
>
{m.source_path || "."} {m.target_path}
</div>
))}
</div>
</div>
<div
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
>
<button
type="button"
className="ghost-button small"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="ghost-button small"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{isAdding ? (
<div className="card" style={{ padding: "1rem" }}>
<GitMountForm
mount={{
remote_url: "",
branch: "",
mappings: [{ source_path: ".", target_path: "" }],
}}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
</div>
) : (
<button
type="button"
className="secondary-button"
onClick={() => setIsAdding(true)}
>
<Icon name="add" size="sm" />
Add Git Mount
</button>
)}
</div>
);
};
interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
}
type ValidationState =
| { status: "idle" }
| { status: "loading" }
| { status: "valid"; branches: string[]; defaultBranch: string }
| { status: "suggestion"; suggestedUrl: string; message: string }
| { status: "invalid"; message: string };
const GitMountForm = ({
mount,
onSave,
onCancel,
}: GitMountFormProps) => {
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
const [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>(
mount.mappings?.length
? mount.mappings
: [{ source_path: ".", target_path: "" }],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [validation, setValidation] = useState<ValidationState>({
status: "idle",
});
const isUrlValidated =
validation.status === "valid" ||
(validation.status === "idle" && mount.remote_url.length > 0);
const handleCheckUrl = async () => {
if (!remoteUrl.trim()) {
setErrors((prev) => ({ ...prev, remote_url: "Git URL is required" }));
return;
}
setValidation({ status: "loading" });
setErrors((prev) => {
const next = { ...prev };
delete next.remote_url;
return next;
});
try {
const result = await validateGitUrl(remoteUrl.trim());
if (result.valid && result.branches) {
setValidation({
status: "valid",
branches: result.branches,
defaultBranch: result.default_branch || "main",
});
if (!branch) {
setBranch(result.default_branch || "main");
}
if (result.suggested_url && result.suggested_url !== remoteUrl.trim()) {
setRemoteUrl(result.suggested_url);
}
} else if (result.suggested_url) {
setValidation({
status: "suggestion",
suggestedUrl: result.suggested_url,
message: result.error || "URL needs correction",
});
} else {
setValidation({
status: "invalid",
message: result.error || "Invalid repository URL",
});
}
} catch {
setValidation({
status: "invalid",
message: "Failed to validate URL. Please try again.",
});
}
};
const applySuggestion = () => {
if (validation.status === "suggestion") {
setRemoteUrl(validation.suggestedUrl);
setValidation({ status: "idle" });
}
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!remoteUrl.trim()) {
newErrors.remote_url = "Git URL is required";
} else if (
!remoteUrl.startsWith("http://") &&
!remoteUrl.startsWith("https://") &&
!remoteUrl.startsWith("git@") &&
!remoteUrl.startsWith("ssh://")
) {
newErrors.remote_url =
"Must be a valid git URL (https://, git@, or ssh://)";
}
mappings.forEach((m, i) => {
if (!m.target_path.trim()) {
newErrors[`mapping_${i}_target`] = "Target path is required";
}
if (m.source_path.includes("..")) {
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
}
if (m.target_path.includes("..")) {
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
onSave({
remote_url: remoteUrl.trim(),
branch: branch.trim() || undefined,
mappings: mappings.map((m) => ({
source_path: m.source_path.trim() || ".",
target_path: m.target_path.trim(),
})),
});
};
const addMapping = () => {
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
};
const updateMapping = (
index: number,
field: keyof GitMountMapping,
value: string,
) => {
setMappings((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
if (errors[`mapping_${index}_${field}`]) {
setErrors((prev) => {
const next = { ...prev };
delete next[`mapping_${index}_${field}`];
return next;
});
}
};
const removeMapping = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index));
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div
className="form-row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
>
<div style={{ flex: 2 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Repository URL
</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
type="text"
value={remoteUrl}
onChange={(e) => {
setRemoteUrl(e.target.value);
setValidation({ status: "idle" });
if (errors.remote_url) {
setErrors((prev) => {
const next = { ...prev };
delete next.remote_url;
return next;
});
}
}}
placeholder="https://github.com/user/repo.git"
className={`form-input ${errors.remote_url ? "error" : ""}`}
style={{ flex: 1 }}
/>
<button
type="button"
className="secondary-button small"
onClick={handleCheckUrl}
disabled={validation.status === "loading"}
>
{validation.status === "loading" ? (
<Icon name="loading" size="sm" />
) : (
"Check"
)}
</button>
</div>
{errors.remote_url && (
<span className="error-text">{errors.remote_url}</span>
)}
{validation.status === "valid" && (
<span className="validation-status valid">
Repository is accessible (
{
(validation as Extract<ValidationState, { status: "valid" }>)
.branches.length
}{" "}
branches)
</span>
)}
{validation.status === "suggestion" && (
<div className="url-suggestion">
<span>{validation.message}</span>
<div className="suggestion-actions">
<code className="suggested-url">{validation.suggestedUrl}</code>
<button
type="button"
className="secondary-button small"
onClick={applySuggestion}
>
Use this
</button>
</div>
</div>
)}
{validation.status === "invalid" && (
<span className="validation-status invalid">
{validation.message}
</span>
)}
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Branch
</label>
{validation.status === "valid" ? (
<select
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="form-input"
>
{(
validation as Extract<ValidationState, { status: "valid" }>
).branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
) : (
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="form-input"
disabled={!isUrlValidated}
/>
)}
</div>
</div>
<div
style={{
opacity: isUrlValidated ? 1 : 0.5,
pointerEvents: isUrlValidated ? "auto" : "none",
}}
>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Mappings
</label>
<p
className="muted"
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
>
Source paths within the repo and where to mount them in the container.
{!isUrlValidated && (
<span style={{ color: "var(--warning)" }}>
{" "}
Validate the URL first.
</span>
)}
</p>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
{mappings.map((mapping, index) => (
<div
key={index}
className="form-row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
>
<input
type="text"
value={mapping.source_path}
onChange={(e) =>
updateMapping(index, "source_path", e.target.value)
}
placeholder="packages/api"
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
<span
style={{
padding: "0.5rem 0",
color: "var(--muted)",
fontSize: "0.875rem",
}}
>
</span>
<input
type="text"
value={mapping.target_path}
onChange={(e) =>
updateMapping(index, "target_path", e.target.value)
}
placeholder="/app/api"
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
{mappings.length > 1 && (
<button
type="button"
className="ghost-button small"
onClick={() => removeMapping(index)}
title="Remove mapping"
>
<Icon name="delete" size="sm" />
</button>
)}
{errors[`mapping_${index}_source`] && (
<span className="error-text">
{errors[`mapping_${index}_source`]}
</span>
)}
{errors[`mapping_${index}_target`] && (
<span className="error-text">
{errors[`mapping_${index}_target`]}
</span>
)}
</div>
))}
</div>
<button
type="button"
className="secondary-button small"
onClick={addMapping}
style={{ marginTop: "0.5rem" }}
>
<Icon name="add" size="sm" />
Add Mapping
</button>
</div>
<div
className="form-actions"
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
>
<button type="button" className="primary-button" onClick={handleSubmit}>
Save
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
@@ -0,0 +1,276 @@
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>
);
};
@@ -0,0 +1,144 @@
import { useState } from "react";
import { mergeBranches } from "../api/git-repositories";
import { Icon } from "./icon";
interface MergeDialogProps {
projectId: string;
repoId: string;
branches: string[];
currentBranch: string;
isOpen: boolean;
onClose: () => void;
onMerge: () => void;
}
export const MergeDialog = ({
projectId,
repoId,
branches,
currentBranch,
isOpen,
onClose,
onMerge,
}: MergeDialogProps) => {
const [sourceBranch, setSourceBranch] = useState("");
const [commitMessage, setCommitMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
if (!isOpen) return null;
const availableBranches = branches.filter((b) => b !== currentBranch);
const handleMerge = async () => {
if (!sourceBranch) {
setError("Please select a source branch");
return;
}
setLoading(true);
setError(null);
setSuccess(false);
try {
await mergeBranches(
projectId,
repoId,
sourceBranch,
currentBranch,
commitMessage || undefined
);
setSuccess(true);
setTimeout(() => {
onMerge();
onClose();
}, 1500);
} catch {
setError("Merge failed. There may be conflicts to resolve.");
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Merge Branch</h2>
<div className="merge-form">
<div className="form-field">
<label>Source Branch (merge from)</label>
<select
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
disabled={loading}
>
<option value="">Select branch...</option>
{availableBranches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
</div>
<div className="form-field">
<label>Target Branch (merge into)</label>
<input
type="text"
value={currentBranch}
disabled
className="input-disabled"
/>
</div>
<div className="form-field">
<label>Commit Message (optional)</label>
<textarea
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder={`Merge ${sourceBranch || "branch"} into ${currentBranch}`}
rows={3}
disabled={loading}
/>
</div>
{error && <div className="error-text">{error}</div>}
{success && (
<div className="success-text">Merge successful!</div>
)}
<div className="modal-actions">
<button
className="secondary-button"
onClick={onClose}
disabled={loading}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
className="primary-button"
onClick={handleMerge}
disabled={loading || !sourceBranch}
type="button"
>
{loading ? (
<>
<Icon name="loading" size="sm" />
Merging...
</>
) : (
<>
<Icon name="merge" size="sm" />
Merge
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};