Merge branch 'main' of ssh://git.commumedia.org:2222/alex/headquarter

This commit is contained in:
Fusion
2026-05-22 20:34:20 +02:00
20 changed files with 867 additions and 252 deletions
+12 -6
View File
@@ -17,6 +17,7 @@ interface GitToolbarProps {
repoId: string;
currentBranch: string;
branches: string[];
hasRemote: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
@@ -26,6 +27,7 @@ export const GitToolbar = ({
repoId,
currentBranch,
branches,
hasRemote,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
@@ -55,6 +57,7 @@ export const GitToolbar = ({
}, [loadStatus]);
const handleFetch = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await fetchRepository(projectId, repoId);
@@ -67,9 +70,10 @@ export const GitToolbar = ({
};
const handlePull = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await pullRepository(projectId, repoId, currentBranch);
await pullRepository(projectId, repoId, currentBranch || undefined);
await loadStatus();
onRefresh();
} catch {
@@ -108,7 +112,7 @@ export const GitToolbar = ({
if (!newBranchName.trim()) return;
setLoading(true);
try {
await createBranch(projectId, repoId, newBranchName, newBranchBase || "HEAD");
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
setShowNewBranch(false);
setNewBranchName("");
setNewBranchBase("");
@@ -127,6 +131,8 @@ export const GitToolbar = ({
status.untracked.length > 0
);
const canSync = hasRemote;
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
@@ -162,10 +168,10 @@ export const GitToolbar = ({
</div>
<div className="toolbar-group">
<button
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading}
disabled={loading || !canSync}
type="button"
>
<Icon name="fetch" size="sm" /> Fetch
@@ -173,7 +179,7 @@ export const GitToolbar = ({
<button
className="toolbar-button"
onClick={handlePull}
disabled={loading}
disabled={loading || !canSync}
type="button"
>
<Icon name="pull" size="sm" /> Pull
@@ -182,7 +188,7 @@ export const GitToolbar = ({
<button
className="toolbar-button"
onClick={handlePush}
disabled={loading || !status?.ahead}
disabled={loading || !canSync || !status?.ahead}
type="button"
>
<Icon name="push" size="sm" /> Push
@@ -0,0 +1,114 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
import * as gitRepositoriesApi from "../api/git_repositories";
const mockRepositories = [
{
id: "repo-1",
name: "Main Repo",
path: "/repos/main",
project_id: "proj-1",
owner_id: "user-1",
is_mirror: false,
remote_url: null,
last_push: null,
created_at: null,
},
];
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
useParams: () => ({ projectId: "proj-1" }),
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("RepositoriesSettingsTab", () => {
it("opens create dialog and clones an existing repository", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "alice" },
});
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
target: { value: "demo" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "git@git.commumedia.org:alice/demo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("uses advanced url fallback when requested", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
target: { value: "https://github.com/user/repo.git" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "https://github.com/user/repo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows validation when cloning without a remote url", async () => {
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
});
});
@@ -1,37 +1,45 @@
import React, { useState, useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { GitRepository } from "../api/git_repositories";
import { deleteRepository, listRepositories, type GitRepository } from "../api/git_repositories";
import { RepositoryCreateDialog } from "./repository-create-dialog";
import { Icon } from "./icon";
export const RepositoriesSettingsTab: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(">");
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const fetchRepositories = async () => {
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories`
);
setRepositories(response.data);
} catch (err) {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
};
const loadRepositories = useCallback(async () => {
if (!projectId) {
setLoading(false);
return;
}
fetchRepositories();
setLoading(true);
try {
const data = await listRepositories(projectId);
setRepositories(data);
} catch {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const handleDelete = async (repoId: string) => {
if (!projectId) return;
if (!window.confirm("Are you sure you want to delete this repository?")) return;
try {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
setRepositories(repositories.filter((r) => r.id !== repoId));
} catch (err) {
await deleteRepository(projectId, repoId);
setRepositories((current) => current.filter((r) => r.id !== repoId));
} catch {
setError("Failed to delete repository");
}
};
@@ -40,7 +48,17 @@ export const RepositoriesSettingsTab: React.FC = () => {
return (
<div className="repositories-settings-tab">
<h2>Repositories</h2>
<div className="page-header">
<h2>Repositories</h2>
<button
className="primary-button"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
{error && <div className="error-message">{error}</div>}
<div className="repositories-list">
@@ -66,6 +84,16 @@ export const RepositoriesSettingsTab: React.FC = () => {
))
)}
</div>
{showCreate && (
<RepositoryCreateDialog
projectId={projectId!}
open={showCreate}
title="Add Repository"
onClose={() => setShowCreate(false)}
onCreated={loadRepositories}
/>
)}
</div>
);
};
@@ -0,0 +1,293 @@
import { useEffect, useRef, useState } from "react";
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
import { Icon } from "./icon";
type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
interface RepositoryCreateDialogProps {
projectId: string;
open: boolean;
title: string;
onClose: () => void;
onCreated: () => Promise<void> | void;
}
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [formName, setFormName] = useState("");
const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [open]);
useEffect(() => {
if (!open) return;
if (!useAdvancedUrl) {
setUrlValidation({ status: "idle", result: null });
return;
}
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (!advancedUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => {
try {
const result = await parseGitUrl(advancedUrl.trim());
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [advancedUrl, open, useAdvancedUrl]);
const resetForm = () => {
setCreateMode("clone");
setFormName("");
setOwner("");
setRepoName("");
setAdvancedUrl("");
setUseAdvancedUrl(false);
setFormError(null);
setUrlValidation({ status: "idle", result: null });
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: undefined,
};
if (createMode === "clone") {
if (useAdvancedUrl) {
if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning");
return;
}
input.remote_url = advancedUrl.trim();
} else {
if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required");
return;
}
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
}
}
await createRepository(projectId, input);
handleClose();
await onCreated();
} catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail;
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
}
};
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setAdvancedUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
if (!open) return null;
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>{title}</h3>
<p className="muted">
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field">
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
Clone existing repository
</label>
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
Create blank repository
</label>
</div>
<label className="form-field">
Repository name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
{createMode === "clone" && !useAdvancedUrl && (
<>
<label className="form-field">
Owner
<input
type="text"
value={owner}
onChange={(event) => setOwner(event.target.value)}
placeholder="owner"
/>
</label>
<label className="form-field">
Repository
<input
type="text"
value={repoName}
onChange={(event) => setRepoName(event.target.value)}
placeholder="repo-name"
/>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(true)}
>
Use full URL instead
</button>
</>
)}
{createMode === "clone" && useAdvancedUrl && (
<label className="form-field">
Remote URL
<input
type="text"
value={advancedUrl}
onChange={(event) => setAdvancedUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(false)}
>
Use owner/repo instead
</button>
</label>
)}
{formError && (
<div className="error-message">
<p className="error-text">{formError}</p>
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={handleClose} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
);
};