Add interactive release search (M6)

Release listing and grab in the shared arr client, a full-screen release
list (sort, hide rejected, rejection reasons, confirmation before grabbing
rejected releases; Usenet only), and a season/episode picker for Sonarr.
Overlays are now a stack so dialogs can open above the release list.
Add a fake Newznab indexer to the dev setup.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 18:09:19 +02:00
co-authored by Claude Sonnet 5.5
parent 446cbdd6c5
commit f928427993
18 changed files with 1002 additions and 23 deletions
+42 -18
View File
@@ -3,6 +3,7 @@ package ui
import (
"fmt"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -14,8 +15,6 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
const modalPage = "modal"
type view struct {
key rune
name string
@@ -33,9 +32,14 @@ type App struct {
views []view
active int
message string
modalOpen bool
modalFocus tview.Primitive
message string
overlays []overlay // dialogs and full-screen views stacked above the pages
}
// overlay is a page stacked above the main views; focus is restored on close.
type overlay struct {
page string
focus tview.Primitive
}
// New builds the application shell: a tab bar, one page per view and a status bar.
@@ -96,6 +100,9 @@ func (a *App) Run() error {
func (a *App) renderStatus() {
hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search"
if len(a.overlays) > 0 {
hints = "[gray]Esc back · Ctrl+C quit"
}
if a.message == "" {
a.status.SetText(hints)
return
@@ -103,10 +110,17 @@ func (a *App) renderStatus() {
a.status.SetText(a.message + " [gray]│ " + hints)
}
// openModal shows p centered over the current view and remembers the focus.
// pushOverlay stacks p above everything and focuses focus.
func (a *App) pushOverlay(p, focus tview.Primitive) {
name := fmt.Sprintf("overlay-%d", len(a.overlays))
a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus()})
a.pages.AddPage(name, p, true, true)
a.app.SetFocus(focus)
a.renderStatus()
}
// openModal shows p centered above the current view.
func (a *App) openModal(p tview.Primitive, width, height int) {
a.modalFocus = a.app.GetFocus()
a.modalOpen = true
box := tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
@@ -114,21 +128,31 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
AddItem(p, height, 0, true).
AddItem(nil, 0, 1, false), width, 0, true).
AddItem(nil, 0, 1, false)
a.pages.AddPage(modalPage, box, true, true)
a.app.SetFocus(p)
a.pushOverlay(box, p)
}
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
func (a *App) openFull(p tview.Primitive) {
a.pushOverlay(p, p)
}
// closeModal removes the topmost overlay and restores the previous focus.
func (a *App) closeModal() {
a.pages.RemovePage(modalPage)
a.modalOpen = false
if a.modalFocus != nil {
a.app.SetFocus(a.modalFocus)
a.modalFocus = nil
if len(a.overlays) == 0 {
return
}
top := a.overlays[len(a.overlays)-1]
a.overlays = a.overlays[:len(a.overlays)-1]
a.pages.RemovePage(top.page)
if top.focus != nil {
a.app.SetFocus(top.focus)
}
a.renderStatus()
}
// confirm asks a yes/no question; yes runs only if the user picks Delete/Yes.
func (a *App) confirm(text string, yes func()) {
lines := strings.Count(text, "\n") + 1 + len([]rune(text))/44
m := tview.NewModal().
SetText(text).
AddButtons([]string{"Cancel", "Confirm"}). // Cancel first so a stray Enter is safe
@@ -138,13 +162,13 @@ func (a *App) confirm(text string, yes func()) {
yes()
}
})
a.openModal(m, 60, 9)
a.openModal(m, 60, 7+min(lines, 10))
}
// typing reports whether keystrokes belong to a text field or dialog and must
// not trigger global shortcuts.
func (a *App) typing() bool {
if a.modalOpen {
if len(a.overlays) > 0 {
return true
}
_, isInput := a.app.GetFocus().(*tview.InputField)
@@ -157,7 +181,7 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
return nil
}
// View switching that also works while typing in a search box. Not while a dialog is open.
if !a.modalOpen {
if len(a.overlays) == 0 {
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
a.switchTo(i)
return nil