1use crate::models::TodoStatus;
4use crate::services::{LinkService, RepoService, ScrapService, TaskService, TodoService};
5use crate::use_cases::{ApplyTodoActionUseCase, GetTaskInfoUseCase};
6use crate::utils::TrackError;
7use crate::webui::error::WebError;
8use crate::webui::state::{AppState, SseEvent};
9use crate::webui::templates::SharedTemplates;
10use crate::webui::view::{self, format_scraps, format_todos, StatusResponse};
11use axum::{
12 extract::{Path, State},
13 response::Html,
14 Form, Json,
15};
16use serde::Deserialize;
17
18#[derive(Clone)]
20pub struct WebState {
21 pub app: AppState,
22 pub templates: SharedTemplates,
23}
24
25pub type AppError = WebError;
27
28#[derive(Deserialize)]
30pub struct AddTodoForm {
31 pub content: String,
32 #[serde(default)]
33 pub create_worktree: bool,
34 #[serde(default)]
35 pub no_workspace: bool,
36}
37
38#[derive(Deserialize)]
40pub struct AddScrapForm {
41 pub content: String,
42}
43
44#[derive(Deserialize)]
46pub struct UpdateDescriptionForm {
47 pub description: String,
48}
49
50#[derive(Deserialize)]
52pub struct UpdateTicketForm {
53 pub ticket_id: String,
54 pub ticket_url: Option<String>,
55}
56
57#[derive(Deserialize)]
59pub struct AddLinkForm {
60 pub url: String,
61 pub title: Option<String>,
62}
63
64fn render_todo_list_html(
65 templates: &crate::webui::templates::Templates,
66 db: &crate::db::Database,
67 task_id: i64,
68) -> Result<String, AppError> {
69 let snapshot = GetTaskInfoUseCase::new(db).load(task_id)?;
70 let todos = format_todos(&snapshot.todos, &snapshot.worktrees, &snapshot.scraps)?;
71 Ok(templates.render(
72 "partials/todo_list.html",
73 serde_json::json!({ "todos": todos }),
74 )?)
75}
76
77pub async fn index(State(state): State<WebState>) -> Result<Html<String>, AppError> {
79 let db = state.app.db.lock().await;
80
81 let current_task_id = match db.get_current_task_id()? {
82 Some(id) => id,
83 None => {
84 let html = state.templates.render(
85 "index.html",
86 serde_json::json!({
87 "task": null,
88 "todos": [],
89 "scraps": [],
90 "links": [],
91 "repos": [],
92 "worktrees": [],
93 }),
94 )?;
95 return Ok(Html(html));
96 }
97 };
98
99 let context = view::build_template_context(&db, current_task_id)?;
100 let html = state.templates.render("index.html", context)?;
101 Ok(Html(html))
102}
103
104pub async fn api_status(State(state): State<WebState>) -> Result<Json<StatusResponse>, AppError> {
106 let db = state.app.db.lock().await;
107
108 let current_task_id = match db.get_current_task_id()? {
109 Some(id) => id,
110 None => return Ok(Json(StatusResponse::empty())),
111 };
112
113 let info = GetTaskInfoUseCase::new(&db);
114 let snapshot = info.load(current_task_id)?;
115 let response = view::build_api_status(&db, &snapshot)?;
116
117 Ok(Json(response))
118}
119
120pub async fn get_description(State(state): State<WebState>) -> Result<Html<String>, AppError> {
122 let db = state.app.db.lock().await;
123 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
124
125 let task_service = TaskService::new(&db);
126 let task = task_service.get_task(current_task_id)?;
127
128 let html = state.templates.render(
129 "partials/description.html",
130 serde_json::json!({
131 "task": task,
132 }),
133 )?;
134
135 Ok(Html(html))
136}
137
138pub async fn get_ticket(State(state): State<WebState>) -> Result<Html<String>, AppError> {
140 let db = state.app.db.lock().await;
141 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
142
143 let task_service = TaskService::new(&db);
144 let task = task_service.get_task(current_task_id)?;
145
146 let html = state.templates.render(
147 "partials/ticket.html",
148 serde_json::json!({
149 "task": task,
150 }),
151 )?;
152
153 Ok(Html(html))
154}
155
156pub async fn get_links(State(state): State<WebState>) -> Result<Html<String>, AppError> {
158 let db = state.app.db.lock().await;
159 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
160
161 let link_service = LinkService::new(&db);
162 let links = link_service.list_links(current_task_id)?;
163
164 let html = state.templates.render(
165 "partials/links.html",
166 serde_json::json!({
167 "links": links,
168 }),
169 )?;
170
171 Ok(Html(html))
172}
173
174pub async fn get_repos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
176 let db = state.app.db.lock().await;
177 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
178
179 let repo_service = RepoService::new(&db);
180 let repos = repo_service.list_repos(current_task_id)?;
181
182 let html = state.templates.render(
183 "partials/repos.html",
184 serde_json::json!({
185 "repos": repos,
186 }),
187 )?;
188
189 Ok(Html(html))
190}
191
192pub async fn get_todos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
194 let db = state.app.db.lock().await;
195 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
196 let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
197 Ok(Html(html))
198}
199
200pub async fn get_scraps(State(state): State<WebState>) -> Result<Html<String>, AppError> {
202 let db = state.app.db.lock().await;
203 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
204
205 let scrap_service = ScrapService::new(&db);
206 let scraps = scrap_service.list_scraps(current_task_id)?;
207
208 let html = state.templates.render(
209 "partials/scrap_list.html",
210 serde_json::json!({
211 "scraps": format_scraps(&scraps),
212 }),
213 )?;
214
215 Ok(Html(html))
216}
217
218pub async fn add_todo(
220 State(state): State<WebState>,
221 Form(form): Form<AddTodoForm>,
222) -> Result<Html<String>, AppError> {
223 let db = state.app.db.lock().await;
224
225 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
226
227 if form.create_worktree {
228 return Err(TrackError::WorktreeFlagRemoved.into());
229 }
230
231 let todo_service = TodoService::new(&db);
232 let _todo = todo_service.add_todo(
233 current_task_id,
234 &form.content,
235 crate::models::TodoAddOptions::from_flags(false, form.no_workspace),
236 )?;
237
238 state.app.broadcast(SseEvent::Todos);
240
241 let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
242 Ok(Html(html))
243}
244
245pub async fn update_todo_status(
247 State(state): State<WebState>,
248 Path((todo_index, new_status)): Path<(i64, String)>,
249) -> Result<Html<String>, AppError> {
250 let db = state.app.db.lock().await;
251
252 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
253
254 let todo_service = TodoService::new(&db);
255 let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
256
257 if new_status.as_str() == TodoStatus::PENDING {
258 todo_service.update_status(todo.id, &new_status)?;
259 } else {
260 let action = crate::models::TodoAction::from_web_route(&new_status)?;
261 ApplyTodoActionUseCase::new(&db).execute(current_task_id, todo_index, action)?;
262 }
263
264 state.app.broadcast(SseEvent::Todos);
266
267 let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
268 Ok(Html(html))
269}
270
271pub async fn delete_todo(
273 State(state): State<WebState>,
274 Path(todo_index): Path<i64>,
275) -> Result<Html<String>, AppError> {
276 let db = state.app.db.lock().await;
277
278 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
279
280 let todo_service = TodoService::new(&db);
281 let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
282 todo_service.delete_todo(todo.id)?;
283
284 state.app.broadcast(SseEvent::Todos);
286
287 let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
288 Ok(Html(html))
289}
290
291pub async fn move_todo_to_next(
293 State(state): State<WebState>,
294 Path(todo_index): Path<i64>,
295) -> Result<Html<String>, AppError> {
296 let db = state.app.db.lock().await;
297
298 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
299
300 let todo_service = TodoService::new(&db);
301 todo_service.move_to_next(current_task_id, todo_index)?;
302
303 state.app.broadcast(SseEvent::Todos);
305
306 let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
307 Ok(Html(html))
308}
309
310pub async fn add_scrap(
312 State(state): State<WebState>,
313 Form(form): Form<AddScrapForm>,
314) -> Result<Html<String>, AppError> {
315 let db = state.app.db.lock().await;
316
317 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
318
319 let scrap_service = ScrapService::new(&db);
320 let _scrap = scrap_service.add_scrap(current_task_id, &form.content)?;
321
322 state.app.broadcast(SseEvent::Scraps);
324
325 let scraps = scrap_service.list_scraps(current_task_id)?;
327 let html = state.templates.render(
328 "partials/scrap_list.html",
329 serde_json::json!({
330 "scraps": format_scraps(&scraps),
331 }),
332 )?;
333
334 Ok(Html(html))
335}
336
337pub async fn update_description(
339 State(state): State<WebState>,
340 Form(form): Form<UpdateDescriptionForm>,
341) -> Result<Html<String>, AppError> {
342 let db = state.app.db.lock().await;
343
344 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
345
346 let task_service = TaskService::new(&db);
347 task_service.set_description(current_task_id, &form.description)?;
348
349 let task = task_service.get_task(current_task_id)?;
351
352 state.app.broadcast(SseEvent::Description);
354
355 let html = state.templates.render(
357 "partials/description.html",
358 serde_json::json!({
359 "task": task,
360 }),
361 )?;
362
363 Ok(Html(html))
364}
365
366pub async fn update_ticket(
368 State(state): State<WebState>,
369 Form(form): Form<UpdateTicketForm>,
370) -> Result<Html<String>, AppError> {
371 let db = state.app.db.lock().await;
372
373 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
374
375 let task_service = TaskService::new(&db);
376
377 let ticket_url = form.ticket_url.filter(|url| !url.trim().is_empty());
379 let ticket_url_str = ticket_url.as_deref().unwrap_or("");
380
381 task_service.link_ticket(current_task_id, &form.ticket_id, ticket_url_str)?;
382
383 let task = task_service.get_task(current_task_id)?;
385
386 state.app.broadcast(SseEvent::Ticket);
388
389 let html = state.templates.render(
391 "partials/ticket.html",
392 serde_json::json!({
393 "task": task,
394 }),
395 )?;
396
397 Ok(Html(html))
398}
399
400pub async fn add_link(
402 State(state): State<WebState>,
403 Form(form): Form<AddLinkForm>,
404) -> Result<Html<String>, AppError> {
405 let db = state.app.db.lock().await;
406
407 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
408
409 let link_service = LinkService::new(&db);
410
411 let title = form.title.filter(|t| !t.trim().is_empty());
413
414 link_service.add_link(current_task_id, &form.url, title.as_deref())?;
415
416 state.app.broadcast(SseEvent::Links);
418
419 let links = link_service.list_links(current_task_id)?;
421 let html = state.templates.render(
422 "partials/links.html",
423 serde_json::json!({
424 "links": links,
425 }),
426 )?;
427
428 Ok(Html(html))
429}
430
431pub async fn delete_link(
433 State(state): State<WebState>,
434 Path(link_index): Path<i64>,
435) -> Result<Html<String>, AppError> {
436 let db = state.app.db.lock().await;
437
438 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
439
440 let link_service = LinkService::new(&db);
441 let links = link_service.list_links(current_task_id)?;
442
443 let link = links
445 .iter()
446 .find(|l| l.task_index == link_index)
447 .ok_or(TrackError::LinkNotFound(link_index))?;
448
449 link_service.delete_link(link.id)?;
451
452 state.app.broadcast(SseEvent::Links);
454
455 let links = link_service.list_links(current_task_id)?;
457 let html = state.templates.render(
458 "partials/links.html",
459 serde_json::json!({
460 "links": links,
461 }),
462 )?;
463
464 Ok(Html(html))
465}