Веб-UI: htmx-своп действий на месте вместо PRG-редиректа

Мутирующие действия больше не уводят со страницы: undo/relink/retry/cancel
в списке свопят карточку (#card-{id}), на /download/{id} — #download-main;
петля ревью (refine/rerecognize) свопит #review-main и допалливает
recognizing до готового плана (fragment /fragments/downloads/{id}/review,
every 2s). Выходы ревью (apply/defer/cancel) остаются навигацией. Без htmx —
прежний PRG-редирект (деградация). Ошибка действия на htmx-пути — HTTP 200
с сообщением в фрагменте (ActionError), иначе htmx не свопит DOM.

Разметка вынесена в партиалы card/download_main/review_main (корень = элемент
с целевым id), различение поверхности — скрытым полем surface=list|download.
Извлечены buildCardView/buildDownloadView. handleSetProvider переведён на
reviewBlockAction (консистентность source-действий).

Реализация change htmx-action-swap (OpenSpec).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 14:37:51 +03:00
co-authored by Claude Opus 4.8
parent 5d704059ee
commit 2f8e6e3576
16 changed files with 1140 additions and 248 deletions
+50 -18
View File
@@ -201,7 +201,9 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id string) error {
// Смена provider/id — операция над выбранным источником, как candidate/nobase/
// source: свопит блок источника (#source-block), а не всё тело ревью.
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
})
@@ -327,11 +329,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
@@ -342,28 +340,62 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
s.surfaceAction(w, r, id, s.deps.Reviewer.Relink(r.Context(), id))
}
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче).
// reviewAction — общий помощник петлевых действий ревью (уточнить/распознать
// заново/…): выполнить действие и обновить экран ревью. На htmx перечитывает
// состояние и рендерит партиал `review_main` на месте (при ошибке — сообщение в
// баннере и HTTP 200, иначе htmx не свопит DOM); без htmx деградирует до
// PRG-редиректа на `/review/{id}`. Действия асинхронны — своп отдаёт актуальное
// состояние (обычно `recognizing`), которое дальше само допалливается фрагментом
// (см. handleFragReview).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := fn(r.Context(), id); err != nil {
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
// логируя повторно (доменный слой/валидация — не дело транспорта).
redirectReview(w, r, id, userErr(r, err, id))
actionErr := fn(r.Context(), id)
if !isHTMX(r) {
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
redirectReview(w, r, id, msg)
return
}
redirectReview(w, r, id, "")
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
s.render(w, "review_main", buildReviewView(id, rd, msg))
}
// handleFragReview отдаёт партиал тела ревью (htmx-поллинг recognizing). Пока
// загрузка в `recognizing`, `review_main` несёт поллер и экран сам обновляется;
// как только состояние стало `review`, фрагмент возвращается без поллера — опрос
// прекращается.
func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
s.render(w, "review_main", buildReviewView(id, rd, ""))
}
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).