Веб-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
+82 -20
View File
@@ -113,6 +113,8 @@ func NewRouter(d Deps) (http.Handler, error) {
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
@@ -199,6 +201,7 @@ type downloadView struct {
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
ActionError string // ошибка действия на htmx-пути (своп карточки), не error_msg
// Обзор жизненного цикла (мета-строка карточки).
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
@@ -290,22 +293,31 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
now := time.Now()
for _, d := range downloads {
v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
l, ok := s.liveFor(d)
v.Ratio = ratioText(l, ok)
v.Size = sizeText(l, ok, layoutSizes[d.ID])
// Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading {
v.Progress = buildProgress(d.ID, true, l, ok)
}
view.Downloads = append(view.Downloads, v)
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
}
s.render(w, "index.html", view)
}
// buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести
// прогресс-поллер — поэтому Progress заполняется здесь.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
l, ok := s.liveFor(d)
v.Ratio = ratioText(l, ok)
v.Size = sizeText(l, ok, layoutSize)
// Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading {
v.Progress = buildProgress(d.ID, true, l, ok)
}
return v
}
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
func parseGroup(s string) store.StateGroup {
switch store.StateGroup(s) {
@@ -400,11 +412,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Commander.Cancel(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.Commander.Cancel(r.Context(), id))
}
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
@@ -413,11 +421,65 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
s.surfaceAction(w, r, id, s.deps.Commander.Retry(r.Context(), id))
}
// surfaceAction завершает мутирующее действие, доступное и в списке, и на
// странице загрузки (undo/relink/retry/cancel). На htmx свопит фрагмент той
// поверхности, откуда пришло действие (скрытое поле surface=list|download), на
// ошибке — тот же фрагмент с сообщением и HTTP 200 (иначе htmx не подменит DOM).
// Без htmx — прежний PRG-редирект на список (форма выхода из ревью тоже сюда:
// нет htmx → навигация). actionErr — результат доменного вызова.
func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
if !isHTMX(r) {
if actionErr != nil {
redirectErr(w, r, userErr(r, actionErr, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
if r.PostFormValue("surface") == "download" {
s.renderDownloadFragment(w, r, id, actionErr)
return
}
s.renderCardFragment(w, r, id, actionErr)
}
// renderCardFragment перечитывает загрузку и рендерит партиал карточки списка
// (htmx-своп). На ошибке действия кладёт сообщение в ActionError и всё равно
// отвечает 200 — htmx не свопит DOM на 4xx/5xx.
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
if err != nil {
s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
}
v := s.buildCardView(*d, time.Now(), sizes[id])
if actionErr != nil {
v.ActionError = userErr(r, actionErr, id)
}
s.render(w, "card", v)
}
// renderDownloadFragment перечитывает загрузку и рендерит главную область
// страницы загрузки (htmx-своп). Ошибка — в ActionError, ответ 200.
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
v := s.buildDownloadView(id, rd)
if actionErr != nil {
v.ActionError = userErr(r, actionErr, id)
}
s.render(w, "download_main", v)
}
// --- REST API ---