diff --git a/apps/web/src/components/repositories-settings-tab.test.tsx b/apps/web/src/components/repositories-settings-tab.test.tsx index 69e5304..eccecc6 100644 --- a/apps/web/src/components/repositories-settings-tab.test.tsx +++ b/apps/web/src/components/repositories-settings-tab.test.tsx @@ -46,6 +46,7 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); + fireEvent.click(screen.getByLabelText(/clone existing repository/i)); fireEvent.change(screen.getByPlaceholderText(/github.com\/user\/repo.git/i), { target: { value: "https://github.com/user/repo.git" }, }); @@ -72,6 +73,7 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); + fireEvent.click(screen.getByLabelText(/clone existing repository/i)); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); expect(screen.getByText(/remote url is required/i)).toBeInTheDocument(); diff --git a/apps/web/src/components/repositories-settings-tab.tsx b/apps/web/src/components/repositories-settings-tab.tsx index c7545ee..af3b315 100644 --- a/apps/web/src/components/repositories-settings-tab.tsx +++ b/apps/web/src/components/repositories-settings-tab.tsx @@ -1,13 +1,8 @@ import React, { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { - createRepository, - deleteRepository, - listRepositories, - type GitRepository, - type GitRepositoryCreate, -} 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 = () => { @@ -15,9 +10,6 @@ export const RepositoriesSettingsTab: React.FC = () => { const [repositories, setRepositories] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); - const [createMode, setCreateMode] = useState<"clone" | "blank">("clone"); - const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); const [error, setError] = useState(""); const loadRepositories = useCallback(async () => { @@ -41,36 +33,6 @@ export const RepositoriesSettingsTab: React.FC = () => { void loadRepositories(); }, [loadRepositories]); - const handleCreate = async (event: React.FormEvent) => { - event.preventDefault(); - setError(""); - - if (!projectId) return; - if (!formName.trim()) { - setError("Repository name is required"); - return; - } - if (createMode === "clone" && !formRemoteUrl.trim()) { - setError("Remote URL is required to clone an existing repository"); - return; - } - - try { - const input: GitRepositoryCreate = { - name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, - }; - await createRepository(projectId, input); - setShowCreate(false); - setCreateMode("clone"); - setFormName(""); - setFormRemoteUrl(""); - await loadRepositories(); - } catch { - setError("Failed to create repository"); - } - }; - const handleDelete = async (repoId: string) => { if (!projectId) return; if (!window.confirm("Are you sure you want to delete this repository?")) return; @@ -124,62 +86,13 @@ export const RepositoriesSettingsTab: React.FC = () => { {showCreate && ( -
-
-

Add Repository

-

- Clone an existing repository from a git server, or create a blank bare repo here. -

-
-
- - -
- - -
- - -
-
-
-
+ setShowCreate(false)} + onCreated={loadRepositories} + /> )} ); diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx new file mode 100644 index 0000000..5c58521 --- /dev/null +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -0,0 +1,230 @@ +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; +} + +export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { + const [createMode, setCreateMode] = useState("clone"); + const [formName, setFormName] = useState(""); + const [formRemoteUrl, setFormRemoteUrl] = useState(""); + const [formError, setFormError] = useState(null); + const [urlValidation, setUrlValidation] = useState<{ + status: UrlValidationStatus; + result: URLParseResult | null; + }>({ status: "idle", result: null }); + const debounceTimer = useRef | null>(null); + + useEffect(() => { + if (!open && debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + }, [open]); + + useEffect(() => { + if (!open) return; + + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + if (!formRemoteUrl.trim()) { + setUrlValidation({ status: "idle", result: null }); + return; + } + + setUrlValidation({ status: "validating", result: null }); + + debounceTimer.current = setTimeout(async () => { + try { + const result = await parseGitUrl(formRemoteUrl.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); + } + }; + }, [formRemoteUrl, open]); + + const resetForm = () => { + setCreateMode("clone"); + setFormName(""); + setFormRemoteUrl(""); + 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; + } + + if (createMode === "clone" && !formRemoteUrl.trim()) { + setFormError("Remote URL is required to clone an existing repository"); + return; + } + + try { + const input: GitRepositoryCreate = { + name: formName.trim(), + remote_url: formRemoteUrl.trim() || undefined, + }; + await createRepository(projectId, input); + handleClose(); + await onCreated(); + } catch { + setFormError("Failed to create repository"); + } + }; + + const handleUseSuggestedUrl = () => { + if (urlValidation.result?.base_url) { + setFormRemoteUrl(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 ( +
+
+

{title}

+

+ Clone an existing repository from a git server, or create a blank bare repo here. +

+
+
+ + +
+ + + {formError && ( +
+

{formError}

+
+ )} +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/git-repositories.tsx b/apps/web/src/pages/git-repositories.tsx index 45ddc80..1ad2c4e 100644 --- a/apps/web/src/pages/git-repositories.tsx +++ b/apps/web/src/pages/git-repositories.tsx @@ -1,19 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { - createRepository, deleteRepository, listRepositories, - parseGitUrl, - type GitRepositoryCreate, - type URLParseResult, } from "../api/git_repositories"; import type { GitRepository } from "../api/git_repositories"; import { Icon } from "../components/icon"; +import { RepositoryCreateDialog } from "../components/repository-create-dialog"; type RepoStatus = "loading" | "ready" | "error"; -type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; export const GitRepositoriesPage = () => { const { projectId } = useParams<{ projectId: string }>(); @@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => { const [status, setStatus] = useState("loading"); const [repositories, setRepositories] = useState([]); const [showCreate, setShowCreate] = useState(false); - const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); - const [formError, setFormError] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); - // URL validation state - const [urlValidation, setUrlValidation] = useState<{ - status: UrlValidationStatus; - result: URLParseResult | null; - }>({ status: "idle", result: null }); - - const debounceTimer = useRef | null>(null); - const loadRepositories = useCallback(async () => { if (!projectId) return; setStatus("loading"); @@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => { void loadRepositories(); }, [loadRepositories]); - // Validate URL with debounce - useEffect(() => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - - if (!formRemoteUrl.trim()) { - setUrlValidation({ status: "idle", result: null }); - return; - } - - setUrlValidation({ status: "validating", result: null }); - - debounceTimer.current = setTimeout(async () => { - try { - const result = await parseGitUrl(formRemoteUrl.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); - } - }; - }, [formRemoteUrl]); - - const getUrlInputClass = () => { - switch (urlValidation.status) { - case "valid": - return "valid-url"; - case "needs-parsing": - return "needs-parsing-url"; - case "invalid": - return "invalid-url"; - default: - return ""; - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setFormError(null); - - if (!formName.trim()) { - setFormError("Repository name is required"); - return; - } - - if (!projectId) return; - - try { - const input: GitRepositoryCreate = { - name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, - }; - await createRepository(projectId, input); - setShowCreate(false); - setFormName(""); - setFormRemoteUrl(""); - setUrlValidation({ status: "idle", result: null }); - await loadRepositories(); - } catch (err: unknown) { - const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } }; - if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) { - // Show URL correction suggestion - const detail = axiosError.response.data.detail; - setFormError( - `${detail.message}\nSuggested: ${detail.suggested_url}` - ); - } else { - setFormError("Failed to create repository"); - } - } - }; - - const handleUseSuggestedUrl = () => { - if (urlValidation.result?.base_url) { - setFormRemoteUrl(urlValidation.result.base_url); - setUrlValidation({ status: "idle", result: null }); - setFormError(null); - } - }; - const handleDelete = async (repoId: string) => { if (!projectId) return; try { @@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => { )} {showCreate && ( -
-
-

Create Repository

-
- - - {formError && ( -
- {formError.split("\n").map((line, i) => ( -

{line}

- ))} -
- )} -
- - -
-
-
-
+ setShowCreate(false)} + onCreated={loadRepositories} + /> )} );