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
+30
View File
@@ -2,6 +2,9 @@ package ui
import (
"context"
"time"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
@@ -41,6 +44,8 @@ type backend interface {
search(ctx context.Context, term string) ([]result, error)
monitorModes() []monitorMode // nil if not applicable
add(ctx context.Context, r result, c addChoice) (id int, err error)
// interactive starts an interactive release search for a library item.
interactive(v *searchView, r result)
}
// --- Radarr ---
@@ -83,6 +88,13 @@ func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, err
return created.ID, err
}
func (b radarrBackend) interactive(v *searchView, r result) {
m := r.(movieResult)
v.app.openReleases("Releases for "+tview.Escape(movieTitleYear(m.Movie)),
func(ctx context.Context) ([]arr.Release, error) { return b.c.Releases(ctx, m.ID) },
b.c.Grab)
}
// --- Sonarr ---
type seriesResult struct{ sonarr.Series }
@@ -133,3 +145,21 @@ func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, err
})
return created.ID, err
}
func (b sonarrBackend) interactive(v *searchView, r result) {
s := r.(seriesResult)
v.app.SetMessage("Loading episodes…")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
episodes, err := b.c.Episodes(ctx, s.ID)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]Loading episodes failed: " + tview.Escape(err.Error()))
return
}
v.app.SetMessage("")
v.app.pickSeason(b.c, s.Series, episodes)
})
}()
}
+8
View File
@@ -223,3 +223,11 @@ func arrStatusText(it arr.QueueItem) string {
}
return label
}
// movieTitleYear renders "Title (Year)".
func movieTitleYear(m radarr.Movie) string {
if m.Year > 0 {
return fmt.Sprintf("%s (%d)", m.Title, m.Year)
}
return m.Title
}
+114
View File
@@ -0,0 +1,114 @@
package ui
import (
"cmp"
"context"
"fmt"
"slices"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
// seasonSummary counts the episodes of one season.
type seasonSummary struct {
number, total, have int
}
// summarizeSeasons groups episodes by season, newest season first and specials last.
func summarizeSeasons(episodes []sonarr.Episode) []seasonSummary {
byNumber := map[int]*seasonSummary{}
for _, e := range episodes {
s := byNumber[e.SeasonNumber]
if s == nil {
s = &seasonSummary{number: e.SeasonNumber}
byNumber[e.SeasonNumber] = s
}
s.total++
if e.HasFile {
s.have++
}
}
out := make([]seasonSummary, 0, len(byNumber))
for _, s := range byNumber {
out = append(out, *s)
}
slices.SortFunc(out, func(a, b seasonSummary) int {
// specials (season 0) last, otherwise descending
return cmp.Or(cmp.Compare(boolInt(a.number == 0), boolInt(b.number == 0)), cmp.Compare(b.number, a.number))
})
return out
}
func seasonLabel(n int) string {
if n == 0 {
return "Specials"
}
return fmt.Sprintf("Season %d", n)
}
// pickList shows a selectable list in a dialog; Esc closes it.
func (a *App) pickList(title string, labels []string, onPick func(i int)) {
list := tview.NewList().ShowSecondaryText(false)
for _, l := range labels {
list.AddItem(l, "", 0, nil)
}
list.SetSelectedFunc(func(i int, _, _ string, _ rune) {
a.closeModal()
onPick(i)
})
list.SetDoneFunc(a.closeModal)
list.SetBorder(true).SetTitle(" " + title + " ")
a.openModal(list, 56, min(len(labels)+2, 22))
}
// pickSeason lets the user choose a season, then an episode or the whole season,
// and opens the interactive search for it.
func (a *App) pickSeason(c *sonarr.Client, s sonarr.Series, episodes []sonarr.Episode) {
seasons := summarizeSeasons(episodes)
if len(seasons) == 0 {
a.SetMessage("[yellow]Sonarr has no episodes for " + tview.Escape(s.Title))
return
}
labels := make([]string, len(seasons))
for i, se := range seasons {
labels[i] = fmt.Sprintf("%s (%d/%d downloaded)", seasonLabel(se.number), se.have, se.total)
}
a.pickList("Search releases for "+tview.Escape(s.Title), labels, func(i int) {
a.pickEpisode(c, s, seasons[i].number, episodes)
})
}
func (a *App) pickEpisode(c *sonarr.Client, s sonarr.Series, season int, all []sonarr.Episode) {
var episodes []sonarr.Episode
for _, e := range all {
if e.SeasonNumber == season {
episodes = append(episodes, e)
}
}
slices.SortFunc(episodes, func(x, y sonarr.Episode) int { return cmp.Compare(x.EpisodeNumber, y.EpisodeNumber) })
labels := []string{"Whole season"}
for _, e := range episodes {
mark := ""
if e.HasFile {
mark = " ✓"
}
labels = append(labels, fmt.Sprintf("E%02d %s%s", e.EpisodeNumber, tview.Escape(e.Title), mark))
}
title := tview.Escape(s.Title) + ", " + seasonLabel(season)
a.pickList(title, labels, func(i int) {
if i == 0 {
a.openReleases("Releases for "+title,
func(ctx context.Context) ([]arr.Release, error) { return c.SeasonReleases(ctx, s.ID, season) },
c.Grab)
return
}
e := episodes[i-1]
a.openReleases(fmt.Sprintf("Releases for %s S%02dE%02d", tview.Escape(s.Title), season, e.EpisodeNumber),
func(ctx context.Context) ([]arr.Release, error) { return c.EpisodeReleases(ctx, e.ID) },
c.Grab)
})
}
+335
View File
@@ -0,0 +1,335 @@
package ui
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// Sort modes of the release list; sortLabels[i] names mode i.
const (
sortBest = iota
sortSize
sortAge
numSortModes
)
var sortLabels = [numSortModes]string{"best", "size", "age"}
// releasesView is the full-screen interactive search result list.
type releasesView struct {
app *App
title string
grab func(ctx context.Context, r arr.Release) error
root *tview.Flex
header *tview.TextView
table *tview.Table
detail *tview.TextView
cancel context.CancelFunc // aborts the running indexer search
all []arr.Release // usenet releases only
shown []arr.Release
torrents int // torrent releases left out
searched bool // the indexer search has finished
sortMode int
hideRejected bool
}
// openReleases starts an interactive search and shows the results as they arrive.
func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Release, error), grab func(context.Context, arr.Release) error) {
v := &releasesView{
app: a,
title: title,
grab: grab,
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
}
foot := tview.NewTextView().SetDynamicColors(true).
SetText("[gray]Enter/g grab · s sort · f hide rejected · Esc back")
v.table.SetBorder(true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(v.table, 0, 1, true).
AddItem(v.detail, 3, 0, false).
AddItem(foot, 1, 0, false)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
v.table.SetSelectedFunc(func(int, int) { v.grabSelected() })
v.table.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
switch {
case ev.Key() == tcell.KeyEscape:
v.close()
case ev.Key() == tcell.KeyRune && ev.Rune() == 'g':
v.grabSelected()
case ev.Key() == tcell.KeyRune && ev.Rune() == 's':
v.sortMode = (v.sortMode + 1) % numSortModes
v.render()
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
v.hideRejected = !v.hideRejected
v.render()
default:
return ev
}
return nil
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
v.cancel = cancel
v.render() // shows the "searching" placeholder
a.openFull(v.root)
go func() {
releases, err := fetch(ctx)
if ctx.Err() == context.Canceled {
return
}
a.post(func() {
if err != nil {
v.header.SetText("[red]Search failed: " + tview.Escape(err.Error()))
v.table.Clear()
return
}
v.setReleases(releases)
})
}()
}
func (v *releasesView) close() {
v.cancel()
v.app.closeModal()
}
func (v *releasesView) setReleases(releases []arr.Release) {
v.all, v.torrents = nil, 0
for _, r := range releases {
// Downloads go to SABnzbd, which can only handle Usenet.
if r.Protocol == "torrent" {
v.torrents++
continue
}
v.all = append(v.all, r)
}
v.searched = true
v.render()
}
// render rebuilds the table, keeping the selected release selected.
func (v *releasesView) render() {
selGUID := ""
if r, ok := v.selected(); ok {
selGUID = r.GUID
}
v.shown = visibleReleases(v.all, v.hideRejected, v.sortMode)
v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
if c == "Title" {
cell.SetExpansion(1)
}
v.table.SetCell(0, i, cell)
}
selRow := 0
for i, r := range v.shown {
cells := []string{
releaseMark(r), tview.Escape(r.Title), tview.Escape(r.QualityName()), formatSize(r.Size),
formatReleaseAge(r.AgeMinutes), tview.Escape(r.Indexer), fmt.Sprint(r.CustomFormatScore),
}
for c, text := range cells {
cell := tview.NewTableCell(text)
if c == 1 {
cell.SetExpansion(1)
}
v.table.SetCell(i+1, c, cell)
}
if r.GUID == selGUID {
selRow = i + 1
}
}
switch {
case !v.searched:
v.table.SetCell(1, 1, tview.NewTableCell("[gray]Searching indexers… (Esc to cancel)").SetSelectable(false))
case len(v.shown) == 0:
msg := "No releases found. Are indexers configured?"
if len(v.all) > 0 {
msg = "All releases are hidden by the filter (f)"
}
v.table.SetCell(1, 1, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
}
if len(v.shown) > 0 {
v.table.Select(max(selRow, 1), 0)
} else {
v.table.Select(0, 0)
}
v.renderHeader()
v.renderDetail()
}
func (v *releasesView) renderHeader() {
s := "[::b]" + v.title + "[::-]" // callers escape the title
if v.searched {
s += fmt.Sprintf(" · %d of %d releases", len(v.shown), len(v.all))
}
s += " · sort: " + sortLabels[v.sortMode]
if v.hideRejected {
s += " · [yellow]rejected hidden[-]"
}
if v.torrents > 0 {
s += fmt.Sprintf(" · [gray]%d torrent release%s hidden[-]", v.torrents, plural(v.torrents))
}
v.header.SetText(s)
}
func (v *releasesView) renderDetail() {
r, ok := v.selected()
if !ok {
v.detail.SetText("")
return
}
var lines []string
switch {
case len(r.Rejections) > 0:
lines = append(lines, "[red]Rejected:[-] "+tview.Escape(strings.Join(r.Rejections, "; ")))
case r.Approved:
lines = append(lines, "[green]Approved[-]")
}
var facts []string
if r.ReleaseGroup != "" {
facts = append(facts, "group "+tview.Escape(r.ReleaseGroup))
}
if l := r.LanguageNames(); l != "" {
facts = append(facts, tview.Escape(l))
}
if len(facts) > 0 {
lines = append(lines, "[gray]"+strings.Join(facts, " · ")+"[-]")
}
v.detail.SetText(strings.Join(lines, "\n"))
}
func (v *releasesView) selected() (arr.Release, bool) {
row, _ := v.table.GetSelection()
if row < 1 || row > len(v.shown) {
return arr.Release{}, false
}
return v.shown[row-1], true
}
// grabSelected sends the selected release to the download client; rejected
// releases need an explicit confirmation first.
func (v *releasesView) grabSelected() {
r, ok := v.selected()
if !ok {
return
}
if len(r.Rejections) == 0 && !r.Rejected {
v.doGrab(r)
return
}
reasons := r.Rejections
extra := ""
if len(reasons) > 3 {
extra = fmt.Sprintf("\n+%d more", len(reasons)-3)
reasons = reasons[:3]
}
v.app.confirm("This release was rejected:\n"+tview.Escape(strings.Join(reasons, "\n"))+extra+"\n\nGrab it anyway?",
func() { v.doGrab(r) })
}
func (v *releasesView) doGrab(r arr.Release) {
v.app.SetMessage("Grabbing " + tview.Escape(truncate(r.Title, 60)) + "…")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := v.grab(ctx, r)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]Grab failed: " + tview.Escape(err.Error()))
return
}
v.app.SetMessage("[green]Sent to the download client: " + tview.Escape(truncate(r.Title, 60)))
})
}()
}
// visibleReleases applies the filter and sort order, returning a new slice.
func visibleReleases(all []arr.Release, hideRejected bool, mode int) []arr.Release {
out := make([]arr.Release, 0, len(all))
for _, r := range all {
if hideRejected && isRejected(r) {
continue
}
out = append(out, r)
}
slices.SortStableFunc(out, func(a, b arr.Release) int {
switch mode {
case sortSize:
return cmp.Compare(b.Size, a.Size)
case sortAge:
return cmp.Compare(a.AgeMinutes, b.AgeMinutes)
}
// best: approved first, then format score, quality, and newest
return cmp.Or(
cmp.Compare(boolInt(isRejected(a)), boolInt(isRejected(b))),
cmp.Compare(b.CustomFormatScore, a.CustomFormatScore),
cmp.Compare(b.QualityWeight, a.QualityWeight),
cmp.Compare(a.AgeMinutes, b.AgeMinutes),
)
})
return out
}
func isRejected(r arr.Release) bool { return r.Rejected || len(r.Rejections) > 0 }
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// releaseMark is the status glyph in the first column.
func releaseMark(r arr.Release) string {
switch {
case !isRejected(r):
return "[green]✓[-]"
case r.TemporarilyRejected:
return "[yellow]~[-]"
}
return "[red]✗[-]"
}
// formatSize renders a byte count as "1.4 GB".
func formatSize(b int64) string {
const k = 1024.0
f := float64(b)
switch {
case f >= k*k*k:
return fmt.Sprintf("%.1f GB", f/(k*k*k))
case f >= k*k:
return fmt.Sprintf("%.0f MB", f/(k*k))
case f > 0:
return fmt.Sprintf("%.0f KB", f/k)
}
return "-"
}
// formatReleaseAge renders an age given in minutes as "45m", "5h" or "3d".
func formatReleaseAge(minutes float64) string {
switch {
case minutes < 60:
return fmt.Sprintf("%dm", int(minutes))
case minutes < 48*60:
return fmt.Sprintf("%dh", int(minutes/60))
}
return fmt.Sprintf("%dd", int(minutes/(24*60)))
}
+110
View File
@@ -0,0 +1,110 @@
package ui
import (
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
func sampleReleases() []arr.Release {
return []arr.Release{
{GUID: "rej-big", Size: 40 << 30, AgeMinutes: 60, QualityWeight: 2000, Rejections: []string{"no space"}},
{GUID: "ok-720", Size: 4 << 30, AgeMinutes: 600, QualityWeight: 1000, Approved: true},
{GUID: "ok-1080", Size: 8 << 30, AgeMinutes: 100, QualityWeight: 1800, Approved: true},
{GUID: "ok-1080-score", Size: 6 << 30, AgeMinutes: 900, QualityWeight: 1800, CustomFormatScore: 50, Approved: true},
}
}
func guids(rs []arr.Release) []string {
out := make([]string, len(rs))
for i, r := range rs {
out[i] = r.GUID
}
return out
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestVisibleReleasesSort(t *testing.T) {
all := sampleReleases()
tests := []struct {
name string
hide bool
mode int
want []string
}{
{"best: approved first, score, quality", false, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720", "rej-big"}},
{"size desc", false, sortSize, []string{"rej-big", "ok-1080", "ok-1080-score", "ok-720"}},
{"newest first", false, sortAge, []string{"rej-big", "ok-1080", "ok-720", "ok-1080-score"}},
{"hide rejected", true, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720"}},
}
for _, tc := range tests {
if got := guids(visibleReleases(all, tc.hide, tc.mode)); !equal(got, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
if all[0].GUID != "rej-big" {
t.Error("visibleReleases must not reorder its input")
}
}
func TestReleaseMark(t *testing.T) {
tests := map[string]arr.Release{
"[green]✓[-]": {Approved: true},
"[red]✗[-]": {Rejected: true, Rejections: []string{"x"}},
"[yellow]~[-]": {Rejected: true, TemporarilyRejected: true},
}
for want, r := range tests {
if got := releaseMark(r); got != want {
t.Errorf("releaseMark(%+v) = %q, want %q", r, got, want)
}
}
}
func TestFormatSizeAndAge(t *testing.T) {
sizes := map[int64]string{0: "-", 2048: "2 KB", 5 << 20: "5 MB", 3<<30 + 1<<29: "3.5 GB"}
for in, want := range sizes {
if got := formatSize(in); got != want {
t.Errorf("formatSize(%d) = %q, want %q", in, got, want)
}
}
ages := map[float64]string{5: "5m", 120: "2h", 47 * 60: "47h", 3 * 24 * 60: "3d"}
for in, want := range ages {
if got := formatReleaseAge(in); got != want {
t.Errorf("formatReleaseAge(%v) = %q, want %q", in, got, want)
}
}
}
func TestSummarizeSeasons(t *testing.T) {
eps := []sonarr.Episode{
{SeasonNumber: 1, EpisodeNumber: 1, HasFile: true},
{SeasonNumber: 1, EpisodeNumber: 2},
{SeasonNumber: 0, EpisodeNumber: 1},
{SeasonNumber: 2, EpisodeNumber: 1},
}
got := summarizeSeasons(eps)
want := []seasonSummary{{2, 1, 0}, {1, 2, 1}, {0, 1, 0}}
if len(got) != len(want) {
t.Fatalf("got %+v", got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("season %d: got %+v, want %+v", i, got[i], want[i])
}
}
if seasonLabel(0) != "Specials" || seasonLabel(3) != "Season 3" {
t.Error("seasonLabel")
}
}
+16
View File
@@ -95,6 +95,9 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
case 'a':
v.add(v.list.GetCurrentItem())
return nil
case 'i':
v.interactive(v.list.GetCurrentItem())
return nil
}
return ev
})
@@ -239,6 +242,19 @@ func (v *searchView) showDetails(i int) {
v.details.SetText(v.results[i].details()).ScrollToBeginning()
}
// interactive opens the release list for the result at index i.
func (v *searchView) interactive(i int) {
if i < 0 || i >= len(v.results) {
return
}
r := v.results[i]
if !r.inLibrary() {
v.app.SetMessage(fmt.Sprintf("[yellow]Add %s to %s first (a), then search for releases", tview.Escape(r.title()), v.be.name()))
return
}
v.be.interactive(v, r)
}
// add opens the add dialog for the result at index i.
func (v *searchView) add(i int) {
if i < 0 || i >= len(v.results) {