Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/frontend/src/components/icons/DiamondIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";

export const DiamondIcon: React.FC = () => (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path
d="M4 10.5L8.5 5h7L20 10.5 12 20 4 10.5Z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path
d="M8.5 5 12 10.5 15.5 5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M4 10.5h16" stroke="currentColor" strokeWidth="1.8" />
</svg>
);
12 changes: 12 additions & 0 deletions packages/frontend/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ export type HarnessDefinition = {
source: string;
};

export type TemplateApplyResponse = {
repository: string;
template: string;
};

export type WorkflowStep = {
type: "agent" | "bash";
agent?: string;
Expand Down Expand Up @@ -308,6 +313,13 @@ export const api = {
method: "POST",
body: JSON.stringify({ url, name, branch }),
}),
listRepositoryTemplates: () =>
request<{ templates: string[] }>("/repositories/templates"),
applyRepositoryTemplate: (name: string, template: string) =>
request<TemplateApplyResponse>(`/repositories/${name}/templates/apply`, {
method: "POST",
body: JSON.stringify({ template }),
}),
getRepository: (name: string) =>
request<RepositorySummary>(`/repositories/${name}`),
getRepositoryFiles: (name: string, path = ".") =>
Expand Down
48 changes: 48 additions & 0 deletions packages/frontend/src/pages/RepositoriesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ vi.mock("../hooks/useApi", async () => {
...actual.api,
listRepositories: vi.fn(),
removeRepositoryWorktree: vi.fn(),
listRepositoryTemplates: vi.fn(),
applyRepositoryTemplate: vi.fn(),
},
};
});
Expand Down Expand Up @@ -102,4 +104,50 @@ describe("RepositoriesPage", () => {
);
});
});

it("opens template modal and applies template", async () => {
vi.mocked(api.listRepositories).mockResolvedValue({
repositories: [
{
name: "main-repo",
path: "/tmp/main-repo",
hasGit: true,
isWorktreeChild: false,
lastCommit: null,
branch: "main",
technology: "TypeScript",
license: "MIT",
},
],
});
vi.mocked(api.listRepositoryTemplates).mockResolvedValue({
templates: ["starter-kit"],
});
vi.mocked(api.applyRepositoryTemplate).mockResolvedValue({
repository: "main-repo",
template: "starter-kit",
});

render(
<MemoryRouter>
<RepositoriesPage />
</MemoryRouter>,
);

fireEvent.click(await screen.findByLabelText("Apply template to main-repo"));

expect(await screen.findByRole("button", { name: "starter-kit" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "starter-kit" }));

await waitFor(() => {
expect(api.applyRepositoryTemplate).toHaveBeenCalledWith(
"main-repo",
"starter-kit",
);
});

expect(
await screen.findByText("Template 'starter-kit' applied successfully."),
).toBeInTheDocument();
});
});
113 changes: 107 additions & 6 deletions packages/frontend/src/pages/RepositoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { api, RepositorySummary } from "../hooks/useApi";
import { Panel } from "../components/Panel";
import { TabView } from "../components/TabView";
import { Modal } from "../components/Modal";
import { DiamondIcon } from "../components/icons/DiamondIcon";
import { TrashIcon } from "../components/icons/TrashIcon";
import "../styles/page.css";

Expand All @@ -17,6 +18,16 @@ export const RepositoriesPage: React.FC = () => {
const [cloneBranch, setCloneBranch] = useState("");
const [isCloning, setIsCloning] = useState(false);
const [isRemovingWorktree, setIsRemovingWorktree] = useState(false);
const [templates, setTemplates] = useState<string[]>([]);
const [templateModal, setTemplateModal] = useState<{
open: boolean;
repoName: string | null;
}>({ open: false, repoName: null });
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateResult, setTemplateResult] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
const [removeWorktreeModal, setRemoveWorktreeModal] = useState<{
open: boolean;
name: string | null;
Expand Down Expand Up @@ -47,6 +58,51 @@ export const RepositoriesPage: React.FC = () => {
loadRepositories();
}, []);

const openTemplateModal = async (repoName: string) => {
setTemplateModal({ open: true, repoName });
setTemplateResult(null);
setIsApplyingTemplate(false);
try {
const response = await api.listRepositoryTemplates();
setTemplates(response.templates);
} catch (error) {
console.error("Failed to load templates", error);
setTemplates([]);
setTemplateResult({
type: "error",
message:
error instanceof Error ? error.message : "Failed to load templates",
});
}
};

const closeTemplateModal = () => {
if (isApplyingTemplate) return;
setTemplateModal({ open: false, repoName: null });
setTemplateResult(null);
};

const handleApplyTemplate = async (templateName: string) => {
if (!templateModal.repoName || isApplyingTemplate) return;
setIsApplyingTemplate(true);
setTemplateResult(null);
try {
await api.applyRepositoryTemplate(templateModal.repoName, templateName);
setTemplateResult({
type: "success",
message: `Template '${templateName}' applied successfully.`,
});
} catch (error) {
setTemplateResult({
type: "error",
message:
error instanceof Error ? error.message : "Failed to apply template",
});
} finally {
setIsApplyingTemplate(false);
}
};

const handleCreate = async () => {
if (!newRepoName.trim()) {
setAlert({ type: "error", message: "Repository name cannot be empty" });
Expand Down Expand Up @@ -154,21 +210,36 @@ export const RepositoriesPage: React.FC = () => {
to={`/repositories/${repo.name}`}
className={repo.isWorktreeChild ? "worktree-child-pill" : undefined}
actions={
repo.isWorktreeChild ? (
<div className="repo-panel-actions">
<button
type="button"
className="copy-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setRemoveWorktreeModal({ open: true, name: repo.name });
openTemplateModal(repo.name);
}}
aria-label={`Remove ${repo.name} worktree`}
title={`Remove ${repo.name} worktree`}
aria-label={`Apply template to ${repo.name}`}
title={`Apply template to ${repo.name}`}
>
<TrashIcon />
<DiamondIcon />
</button>
) : undefined
{repo.isWorktreeChild ? (
<button
type="button"
className="copy-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setRemoveWorktreeModal({ open: true, name: repo.name });
}}
aria-label={`Remove ${repo.name} worktree`}
title={`Remove ${repo.name} worktree`}
>
<TrashIcon />
</button>
) : null}
</div>
}
>
<div className="metadata">
Expand Down Expand Up @@ -276,6 +347,36 @@ export const RepositoriesPage: React.FC = () => {
</button>
</div>
</Modal>
<Modal
open={templateModal.open}
title={
templateModal.repoName
? `Apply Template: ${templateModal.repoName}`
: "Apply Template"
}
onClose={closeTemplateModal}
>
{templateResult && (
<div className={`alert ${templateResult.type}`}>{templateResult.message}</div>
)}
<div className="repo-template-grid">
{templates.length === 0 ? (
<div className="empty">No templates found.</div>
) : (
templates.map((templateName) => (
<button
key={templateName}
type="button"
className="primary"
disabled={isApplyingTemplate}
onClick={() => handleApplyTemplate(templateName)}
>
{templateName}
</button>
))
)}
</div>
</Modal>
<Modal
open={removeWorktreeModal.open}
title="Remove Worktree"
Expand Down
11 changes: 11 additions & 0 deletions packages/frontend/src/styles/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@
align-items: center;
}

.repo-panel-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}

.repo-template-grid {
display: grid;
gap: 0.6rem;
}

.commands-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
Expand Down
39 changes: 39 additions & 0 deletions packages/pybackend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
stop_cron_clock,
)
from repository_service import (
apply_repository_template,
create_repository,
create_repository_file,
clone_repository,
Expand All @@ -71,6 +72,7 @@
get_repository_info,
get_repository_git_status,
list_repositories,
list_repository_templates,
list_repository_files,
pull_repository,
read_repository_file,
Expand Down Expand Up @@ -278,6 +280,43 @@ def clone_repo(payload: dict = Body(...)):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))


@app.get("/api/repositories/templates")
def repository_templates():
try:
logger.info("Listing repository templates")
return {"templates": list_repository_templates()}
except Exception as exc:
logger.exception("Failed to list repository templates")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)


@app.post("/api/repositories/{name}/templates/apply")
def apply_template_to_repository(name: str, payload: dict = Body(...)):
template_name = payload.get("template")
if not template_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name is required",
)

try:
logger.info("Applying template '%s' to repository '%s'", template_name, name)
return apply_repository_template(name, template_name)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
except Exception as exc:
logger.exception(
"Failed to apply template '%s' to repository '%s'", template_name, name
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)


@app.get("/api/repositories/{name}")
def repository_info(name: str):
try:
Expand Down
Loading
Loading