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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 `<PaneRow>`; 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() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator label="Loading more…" />
|
||||
<Show when={showFetchMore()}>
|
||||
<box
|
||||
ref={moreRef}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(
|
||||
episodes().length,
|
||||
focusedRow(),
|
||||
isActive(),
|
||||
)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 1);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{focusedOnMore() ? marker() : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{NF_ICONS.more}
|
||||
</text>
|
||||
)}
|
||||
<Show
|
||||
when={!feedStore.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
[Fetch More]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -456,7 +529,25 @@ export function MyShowsPage() {
|
||||
)}
|
||||
</Show>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode
|
||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{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)."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: load more · h back</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
@@ -509,6 +600,8 @@ export function MyShowsPage() {
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -211,7 +211,7 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
kind: "select",
|
||||
display: () => (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");
|
||||
|
||||
@@ -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). */
|
||||
|
||||
143
tests/feed-pagination.test.ts
Normal file
143
tests/feed-pagination.test.ts
Normal file
@@ -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<typeof Bun.serve> | 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) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Paged Show</title>
|
||||
<description>Pagination test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user