From e73e608b9f700292a01047a6cc6fe0d6d351b9b8 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 10 Aug 2026 22:37:51 -0400 Subject: [PATCH] feat(myshows): add Fetch More row to per-show episode lists Mirrors the Feed tab's '[Fetch More]' row inside a drilled show's episode list (My Shows depth 1): shows only while the show's cache holds episodes beyond its loaded window, and advances just that show's window by 50 on Enter (or automatically at the bottom in auto mode). Same fetchMoreMode preference drives both behaviors. Adds a store-contract test for the per-feed pagination path. --- README.md | 2 +- src/pages/MyShows/MyShowsPage.tsx | 129 ++++++++++++++++++--- src/pages/Settings/PreferencesPanel.tsx | 2 +- src/types/settings.ts | 2 +- tests/feed-pagination.test.ts | 143 ++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 tests/feed-pagination.test.ts diff --git a/README.md b/README.md index 5b634a9..eef054e 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ a suggestion in/out), and `w` in My Shows adds/removes the focused show. Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`. -**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed "Fetch More" row). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended. +**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed and per-show "Fetch More" rows). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended. ## Troubleshooting diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 213da62..8901c76 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -6,12 +6,15 @@ * depth 1 (current) — episodes of the drilled show. Parent pane = shows. * preview — detail of the hovered item in the current column. * + * Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior + * as the Feed tab) that loads the next batch of episodes for that show. + * * Renders entirely through ``; no bespoke 3-column flexbox JSX * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at * 0). j/k move only within the current column. */ -import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; +import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; @@ -28,6 +31,7 @@ import { } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; +import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; @@ -40,6 +44,8 @@ import { useSelectionMarker } from "@/hooks/useSelectionMarker"; export const MyShowsPaneCount = 1; export function MyShowsPage() { + // Static: detection never changes mid-session. + const nerd = supportsNerdFonts(); const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); const app = useAppStore(); @@ -71,17 +77,40 @@ export function MyShowsPage() { (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), ); }); + // ── Fetch More ─────────────────────────────────────────────────────────── + // A "[Fetch More]" row at the bottom of a drilled show's episode list + // advances that show's loaded window by 50 episodes — the per-show + // counterpart to the Feed page's row (which loads every feed). manual + // mode: Enter on the row. auto mode: reaching the bottom row fetches + // automatically (see the effect below). + const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual"; + const showFetchMore = () => + depth() >= 1 && + !!drilledShowId() && + feedStore.hasMoreEpisodes(drilledShowId()); + // Total navigable rows at depth 1: episodes + the optional Fetch More row. + const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); + const focusedRow = () => + rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1); + const focusedOnMore = () => + showFetchMore() && focusedRow() === episodes().length; + // -1 while the Fetch More row is focused so no episode row renders the + // cursor/highlight (the button is the focused row, not the last episode). const focusedEpIdx = () => - episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1); - const focusedEpisode = () => episodes()[focusedEpIdx()]; + focusedOnMore() + ? -1 + : Math.min(focusedRow(), Math.max(episodes().length - 1, 0)); + const focusedEpisode = () => + focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; + const moreRef = useScrollIntoView(() => focusedOnMore()); - const curLen = () => (depth() === 0 ? shows().length : episodes().length); + const curLen = () => (depth() === 0 ? shows().length : rowCount()); const ensureFocus = () => { if (shows().length > 0 && focus(0) >= shows().length) nav.setDepthFocus(shows().length - 1, 0); - if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length) - nav.setDepthFocus(episodes().length - 1, 1); + if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount()) + nav.setDepthFocus(rowCount() - 1, 1); }; onMount(ensureFocus); @@ -92,6 +121,17 @@ export function MyShowsPage() { }); }); + // Auto mode: reaching the bottom of a drilled show's list loads its next + // batch. Guarded by isLoadingMore so concurrent loads never stack. + createEffect(() => { + if (depth() < 1) return; + if (fetchMoreMode() !== "auto") return; + if (!showFetchMore()) return; + if (feedStore.isLoadingMore()) return; + if (focusedRow() < rowCount() - 1) return; + feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {}); + }); + // ── helpers ───────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDuration = (s: number) => { @@ -143,6 +183,10 @@ export function MyShowsPage() { return; } if (depth() >= 1) { + if (focusedOnMore()) { + feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {}); + return; + } const ep = focusedEpisode(); if (ep) playEpisode(ep); } @@ -405,9 +449,38 @@ export function MyShowsPage() { ); }} - - - + + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(episodes().length, 1); + }} + > + + {focusedOnMore() ? marker() : " "} + + {nerd && ( + + {NF_ICONS.more} + + )} + } + > + + [Fetch More] + + @@ -456,15 +529,33 @@ export function MyShowsPage() { )} ) : ( - // depth ≥1 preview: hovered episode - - No episode focused + // depth ≥1 preview: hovered episode (or the Fetch More row) + <> + + + + [Fetch More] + + + {feedStore.isLoadingMore() + ? "Loading the next batch of episodes…" + : fetchMoreMode() === "auto" + ? "Auto mode: the next batch loads automatically at the bottom of the list." + : "Load the next batch of older episodes for this show (Enter)."} + + + enter: load more · h back - } - > + + + + No episode focused + + } + > {(ep) => ( @@ -508,8 +599,10 @@ export function MyShowsPage() { )} + - ); + + ); return ( (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"), help: () => - `How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`, + `How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`, cycle: (dir) => { const modes: Array<"manual" | "auto"> = ["manual", "auto"]; const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual"); diff --git a/src/types/settings.ts b/src/types/settings.ts index fced4e2..591f370 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -86,7 +86,7 @@ export type AppSettings = { visualizer: VisualizerSettings; }; -/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */ +/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */ export type FetchMoreMode = "manual" | "auto"; /** Which shows the auto-download setting applies to (default: all). */ diff --git a/tests/feed-pagination.test.ts b/tests/feed-pagination.test.ts new file mode 100644 index 0000000..8e91917 --- /dev/null +++ b/tests/feed-pagination.test.ts @@ -0,0 +1,143 @@ +/** + * Per-feed pagination test — the store contract behind the "[Fetch More]" + * row in a drilled show's episode list (My Shows depth 1) and the Feed + * page's row. + * + * addFeed caches the FULL parsed feed while exposing only the first + * MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the + * cache holds more than the loaded window; `loadMoreEpisodes` advances that + * window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This + * pins: + * 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes. + * 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch + * needed) and hasMoreEpisodes flips false once the window reaches the end. + * 3. loadMoreEpisodes past the end is a no-op (the feed is untouched). + */ + +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Point the config dir at a throwaway directory BEFORE importing the stores +// (their module-level init reads it). +const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-")); +process.env.XDG_CONFIG_HOME = configHome; + +import { useFeedStore } from "../src/stores/feed"; +import type { Podcast } from "../src/types/podcast"; + +interface ServedEpisode { + title: string; + date: string; +} + +let server: ReturnType | null = null; +let servedEpisodes: ServedEpisode[] = []; +// Bun runs test files in ONE process, so the store singleton is shared with +// feed-refresh.test.ts / feedless-subscribe.test.ts. Track the feeds we add +// and remove them in afterAll so whichever file runs next sees a pristine +// store (execution order between files is not guaranteed). +const addedFeedIds: string[] = []; + +/** XML for the current served episode list (episode ids = feedUrl#index). */ +function feedXml(episodes: ServedEpisode[], origin: string): string { + const items = episodes + .map( + (ep, i) => ` + ${ep.title} + ${ep.date} + +`, + ) + .join("\n"); + return ` + +Paged Show +Pagination test feed +${items} +`; +} + +const makePodcast = (feedUrl: string): Podcast => ({ + id: feedUrl, + title: "Paged Show", + description: "Pagination test feed", + author: "tester", + feedUrl, + lastUpdated: new Date(), + isSubscribed: true, +}); + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith(".xml")) { + return new Response(feedXml(servedEpisodes, url.origin), { + headers: { "Content-Type": "application/rss+xml" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); +}); + +afterAll(() => { + // Leave the shared singleton as we found it (see addedFeedIds note). + const store = useFeedStore(); + for (const id of addedFeedIds) store.removeFeed(id); + server?.stop(true); + rmSync(configHome, { recursive: true, force: true }); +}); + +test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => { + const store = useFeedStore(); + // 60 episodes: 20 shown at subscribe, 40 held back in the cache. + servedEpisodes = Array.from({ length: 60 }, (_, i) => ({ + title: `Ep ${60 - i}`, + date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + })); + const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + // Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with more cached. + expect(store.getFeed(id)!.episodes.length).toBe(20); + expect(store.hasMoreEpisodes(id)).toBe(true); + + // One load-more covers the remaining 40 (20 + 50 >= 60). + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(60); + expect(store.hasMoreEpisodes(id)).toBe(false); + + // Exhausted: loadMoreEpisodes is a no-op — the feed object is untouched. + const before = store.getFeed(id)!; + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)).toBe(before); +}); + +test("hasMoreEpisodes stays true across chunked loads until the end", async () => { + const store = useFeedStore(); + // 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining. + servedEpisodes = Array.from({ length: 120 }, (_, i) => ({ + title: `Ep ${120 - i}`, + date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(), + })); + const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(70); + expect(store.hasMoreEpisodes(id)).toBe(true); + + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(120); + expect(store.hasMoreEpisodes(id)).toBe(false); +});