Skip to content

Commit a3d5854

Browse files
authored
Merge pull request #313 from tbrandenburg/codex/add-template-modal-dialog-to-repo-panel
2 parents e29a300 + 8acc3da commit a3d5854

File tree

9 files changed

+336
-7
lines changed

9 files changed

+336
-7
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from "react";
2+
3+
export const DiamondIcon: React.FC = () => (
4+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
5+
<path
6+
d="M4 10.5L8.5 5h7L20 10.5 12 20 4 10.5Z"
7+
stroke="currentColor"
8+
strokeWidth="1.8"
9+
strokeLinejoin="round"
10+
/>
11+
<path
12+
d="M8.5 5 12 10.5 15.5 5"
13+
stroke="currentColor"
14+
strokeWidth="1.8"
15+
strokeLinejoin="round"
16+
/>
17+
<path d="M4 10.5h16" stroke="currentColor" strokeWidth="1.8" />
18+
</svg>
19+
);

packages/frontend/src/hooks/useApi.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ export type HarnessDefinition = {
222222
source: string;
223223
};
224224

225+
export type TemplateApplyResponse = {
226+
repository: string;
227+
template: string;
228+
};
229+
225230
export type WorkflowStep = {
226231
type: "agent" | "bash";
227232
agent?: string;
@@ -308,6 +313,13 @@ export const api = {
308313
method: "POST",
309314
body: JSON.stringify({ url, name, branch }),
310315
}),
316+
listRepositoryTemplates: () =>
317+
request<{ templates: string[] }>("/repositories/templates"),
318+
applyRepositoryTemplate: (name: string, template: string) =>
319+
request<TemplateApplyResponse>(`/repositories/${name}/templates/apply`, {
320+
method: "POST",
321+
body: JSON.stringify({ template }),
322+
}),
311323
getRepository: (name: string) =>
312324
request<RepositorySummary>(`/repositories/${name}`),
313325
getRepositoryFiles: (name: string, path = ".") =>

packages/frontend/src/pages/RepositoriesPage.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ vi.mock("../hooks/useApi", async () => {
1717
...actual.api,
1818
listRepositories: vi.fn(),
1919
removeRepositoryWorktree: vi.fn(),
20+
listRepositoryTemplates: vi.fn(),
21+
applyRepositoryTemplate: vi.fn(),
2022
},
2123
};
2224
});
@@ -102,4 +104,50 @@ describe("RepositoriesPage", () => {
102104
);
103105
});
104106
});
107+
108+
it("opens template modal and applies template", async () => {
109+
vi.mocked(api.listRepositories).mockResolvedValue({
110+
repositories: [
111+
{
112+
name: "main-repo",
113+
path: "/tmp/main-repo",
114+
hasGit: true,
115+
isWorktreeChild: false,
116+
lastCommit: null,
117+
branch: "main",
118+
technology: "TypeScript",
119+
license: "MIT",
120+
},
121+
],
122+
});
123+
vi.mocked(api.listRepositoryTemplates).mockResolvedValue({
124+
templates: ["starter-kit"],
125+
});
126+
vi.mocked(api.applyRepositoryTemplate).mockResolvedValue({
127+
repository: "main-repo",
128+
template: "starter-kit",
129+
});
130+
131+
render(
132+
<MemoryRouter>
133+
<RepositoriesPage />
134+
</MemoryRouter>,
135+
);
136+
137+
fireEvent.click(await screen.findByLabelText("Apply template to main-repo"));
138+
139+
expect(await screen.findByRole("button", { name: "starter-kit" })).toBeInTheDocument();
140+
fireEvent.click(screen.getByRole("button", { name: "starter-kit" }));
141+
142+
await waitFor(() => {
143+
expect(api.applyRepositoryTemplate).toHaveBeenCalledWith(
144+
"main-repo",
145+
"starter-kit",
146+
);
147+
});
148+
149+
expect(
150+
await screen.findByText("Template 'starter-kit' applied successfully."),
151+
).toBeInTheDocument();
152+
});
105153
});

packages/frontend/src/pages/RepositoriesPage.tsx

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { api, RepositorySummary } from "../hooks/useApi";
33
import { Panel } from "../components/Panel";
44
import { TabView } from "../components/TabView";
55
import { Modal } from "../components/Modal";
6+
import { DiamondIcon } from "../components/icons/DiamondIcon";
67
import { TrashIcon } from "../components/icons/TrashIcon";
78
import "../styles/page.css";
89

@@ -17,6 +18,16 @@ export const RepositoriesPage: React.FC = () => {
1718
const [cloneBranch, setCloneBranch] = useState("");
1819
const [isCloning, setIsCloning] = useState(false);
1920
const [isRemovingWorktree, setIsRemovingWorktree] = useState(false);
21+
const [templates, setTemplates] = useState<string[]>([]);
22+
const [templateModal, setTemplateModal] = useState<{
23+
open: boolean;
24+
repoName: string | null;
25+
}>({ open: false, repoName: null });
26+
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
27+
const [templateResult, setTemplateResult] = useState<{
28+
type: "success" | "error";
29+
message: string;
30+
} | null>(null);
2031
const [removeWorktreeModal, setRemoveWorktreeModal] = useState<{
2132
open: boolean;
2233
name: string | null;
@@ -47,6 +58,51 @@ export const RepositoriesPage: React.FC = () => {
4758
loadRepositories();
4859
}, []);
4960

61+
const openTemplateModal = async (repoName: string) => {
62+
setTemplateModal({ open: true, repoName });
63+
setTemplateResult(null);
64+
setIsApplyingTemplate(false);
65+
try {
66+
const response = await api.listRepositoryTemplates();
67+
setTemplates(response.templates);
68+
} catch (error) {
69+
console.error("Failed to load templates", error);
70+
setTemplates([]);
71+
setTemplateResult({
72+
type: "error",
73+
message:
74+
error instanceof Error ? error.message : "Failed to load templates",
75+
});
76+
}
77+
};
78+
79+
const closeTemplateModal = () => {
80+
if (isApplyingTemplate) return;
81+
setTemplateModal({ open: false, repoName: null });
82+
setTemplateResult(null);
83+
};
84+
85+
const handleApplyTemplate = async (templateName: string) => {
86+
if (!templateModal.repoName || isApplyingTemplate) return;
87+
setIsApplyingTemplate(true);
88+
setTemplateResult(null);
89+
try {
90+
await api.applyRepositoryTemplate(templateModal.repoName, templateName);
91+
setTemplateResult({
92+
type: "success",
93+
message: `Template '${templateName}' applied successfully.`,
94+
});
95+
} catch (error) {
96+
setTemplateResult({
97+
type: "error",
98+
message:
99+
error instanceof Error ? error.message : "Failed to apply template",
100+
});
101+
} finally {
102+
setIsApplyingTemplate(false);
103+
}
104+
};
105+
50106
const handleCreate = async () => {
51107
if (!newRepoName.trim()) {
52108
setAlert({ type: "error", message: "Repository name cannot be empty" });
@@ -154,21 +210,36 @@ export const RepositoriesPage: React.FC = () => {
154210
to={`/repositories/${repo.name}`}
155211
className={repo.isWorktreeChild ? "worktree-child-pill" : undefined}
156212
actions={
157-
repo.isWorktreeChild ? (
213+
<div className="repo-panel-actions">
158214
<button
159215
type="button"
160216
className="copy-button"
161217
onClick={(event) => {
162218
event.preventDefault();
163219
event.stopPropagation();
164-
setRemoveWorktreeModal({ open: true, name: repo.name });
220+
openTemplateModal(repo.name);
165221
}}
166-
aria-label={`Remove ${repo.name} worktree`}
167-
title={`Remove ${repo.name} worktree`}
222+
aria-label={`Apply template to ${repo.name}`}
223+
title={`Apply template to ${repo.name}`}
168224
>
169-
<TrashIcon />
225+
<DiamondIcon />
170226
</button>
171-
) : undefined
227+
{repo.isWorktreeChild ? (
228+
<button
229+
type="button"
230+
className="copy-button"
231+
onClick={(event) => {
232+
event.preventDefault();
233+
event.stopPropagation();
234+
setRemoveWorktreeModal({ open: true, name: repo.name });
235+
}}
236+
aria-label={`Remove ${repo.name} worktree`}
237+
title={`Remove ${repo.name} worktree`}
238+
>
239+
<TrashIcon />
240+
</button>
241+
) : null}
242+
</div>
172243
}
173244
>
174245
<div className="metadata">
@@ -276,6 +347,36 @@ export const RepositoriesPage: React.FC = () => {
276347
</button>
277348
</div>
278349
</Modal>
350+
<Modal
351+
open={templateModal.open}
352+
title={
353+
templateModal.repoName
354+
? `Apply Template: ${templateModal.repoName}`
355+
: "Apply Template"
356+
}
357+
onClose={closeTemplateModal}
358+
>
359+
{templateResult && (
360+
<div className={`alert ${templateResult.type}`}>{templateResult.message}</div>
361+
)}
362+
<div className="repo-template-grid">
363+
{templates.length === 0 ? (
364+
<div className="empty">No templates found.</div>
365+
) : (
366+
templates.map((templateName) => (
367+
<button
368+
key={templateName}
369+
type="button"
370+
className="primary"
371+
disabled={isApplyingTemplate}
372+
onClick={() => handleApplyTemplate(templateName)}
373+
>
374+
{templateName}
375+
</button>
376+
))
377+
)}
378+
</div>
379+
</Modal>
279380
<Modal
280381
open={removeWorktreeModal.open}
281382
title="Remove Worktree"

packages/frontend/src/styles/page.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@
181181
align-items: center;
182182
}
183183

184+
.repo-panel-actions {
185+
display: flex;
186+
align-items: center;
187+
gap: 0.35rem;
188+
}
189+
190+
.repo-template-grid {
191+
display: grid;
192+
gap: 0.6rem;
193+
}
194+
184195
.commands-grid {
185196
display: grid;
186197
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));

packages/pybackend/app.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
stop_cron_clock,
6363
)
6464
from repository_service import (
65+
apply_repository_template,
6566
create_repository,
6667
create_repository_file,
6768
clone_repository,
@@ -71,6 +72,7 @@
7172
get_repository_info,
7273
get_repository_git_status,
7374
list_repositories,
75+
list_repository_templates,
7476
list_repository_files,
7577
pull_repository,
7678
read_repository_file,
@@ -278,6 +280,43 @@ def clone_repo(payload: dict = Body(...)):
278280
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
279281

280282

283+
@app.get("/api/repositories/templates")
284+
def repository_templates():
285+
try:
286+
logger.info("Listing repository templates")
287+
return {"templates": list_repository_templates()}
288+
except Exception as exc:
289+
logger.exception("Failed to list repository templates")
290+
raise HTTPException(
291+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
292+
)
293+
294+
295+
@app.post("/api/repositories/{name}/templates/apply")
296+
def apply_template_to_repository(name: str, payload: dict = Body(...)):
297+
template_name = payload.get("template")
298+
if not template_name:
299+
raise HTTPException(
300+
status_code=status.HTTP_400_BAD_REQUEST,
301+
detail="Template name is required",
302+
)
303+
304+
try:
305+
logger.info("Applying template '%s' to repository '%s'", template_name, name)
306+
return apply_repository_template(name, template_name)
307+
except FileNotFoundError as exc:
308+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
309+
except ValueError as exc:
310+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
311+
except Exception as exc:
312+
logger.exception(
313+
"Failed to apply template '%s' to repository '%s'", template_name, name
314+
)
315+
raise HTTPException(
316+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
317+
)
318+
319+
281320
@app.get("/api/repositories/{name}")
282321
def repository_info(name: str):
283322
try:

0 commit comments

Comments
 (0)