diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx
index c0b1c43..3687281 100644
--- a/src/pages/Feed/FeedPage.tsx
+++ b/src/pages/Feed/FeedPage.tsx
@@ -107,6 +107,30 @@ function FeedPage() {
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
+ // ── Render window ────────────────────────────────────────────────────────
+ // The union grows to thousands of episodes after repeated fetch-more
+ // presses; rendering every row per frame froze the UI. Render only a
+ // bounded slice around the focus (real indexes preserved) — the scrollbox
+ // still keeps the focused row in view. Spacers above/below the window
+ // restore the full content height so the scrollbar tracks the real list.
+ // Each EpisodeRow is 3 lines tall (title, subtitle, date).
+ const LIST_WINDOW = 30;
+ const ROW_HEIGHT = 3;
+ const listWindow = createMemo<[number, number]>(() => {
+ const len = episodes().length;
+ // Focusing the Fetch More button keeps the window anchored at the
+ // last episode — no jump when the focus crosses onto the button.
+ const f = focusedOnMore() ? len - 1 : focusedEpIdx();
+ return [
+ Math.max(0, f - LIST_WINDOW),
+ Math.min(len, f + LIST_WINDOW + 1),
+ ];
+ });
+ const visibleEpisodes = createMemo(() => {
+ const [start, end] = listWindow();
+ return episodes().slice(start, end);
+ });
+
const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount())
nav.setDepthFocus(rowCount() - 1, 0);
@@ -245,17 +269,22 @@ function FeedPage() {
}
>
-
+
}
>
-
+ {/* Spacers keep the scrollbox content at the FULL list height so
+ the scrollbar reflects the real list, not the render window. */}
+ 0}>
+
+
+
{(item, index) => (
item.feed.customName || item.feed.podcast.title}
- index={index}
+ index={() => listWindow()[0] + index()}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(item.episode.id)}
@@ -264,11 +293,14 @@ function FeedPage() {
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
- nav.setDepthFocus(index(), 0);
+ nav.setDepthFocus(listWindow()[0] + index(), 0);
}}
/>
)}
+ 0}>
+
+
episodes().length}
@@ -286,7 +318,7 @@ function FeedPage() {
-
+
diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx
index c75fe53..2e2a24c 100644
--- a/src/pages/MyShows/MyShowsPage.tsx
+++ b/src/pages/MyShows/MyShowsPage.tsx
@@ -89,13 +89,23 @@ function ShowRow(props: {
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
- {isFocused() ? props.marker() : " "}
- {props.title}
-
+
+ {isFocused() ? props.marker() : " "}
+
+ {/* Long titles truncate with middle-ellipsis instead of wrapping —
+ a wrapped title grows the row to 2+ lines and shifts every row
+ below (see EpisodeList for the same guard). The episode-count
+ and watchlist cells are flexShrink=0 so they never shrink or
+ wrap; the flexible title takes the remaining width. */}
+
+ {props.title}
+
+
({props.feed.episodes.length})
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
+ // ── Render window ────────────────────────────────────────────────────────
+ // The drilled show's list grows deep after repeated fetch-more presses;
+ // rendering every row per frame froze the UI. Render only a bounded slice
+ // around the focus (real indexes preserved) — the scrollbox still keeps
+ // the focused row in view. Spacers above/below the window restore the
+ // full content height so the scrollbar tracks the real list.
+ // Each episode row is 2 lines tall (title, date) — no subtitle here.
+ const LIST_WINDOW = 30;
+ const ROW_HEIGHT = 2;
+ const listWindow = createMemo<[number, number]>(() => {
+ const len = episodes().length;
+ // Focusing the Fetch More button keeps the window anchored at the
+ // last episode — no jump when the focus crosses onto the button.
+ const f = focusedOnMore() ? len - 1 : focusedEpIdx();
+ return [
+ Math.max(0, f - LIST_WINDOW),
+ Math.min(len, f + LIST_WINDOW + 1),
+ ];
+ });
+ const visibleEpisodes = createMemo(() => {
+ const [start, end] = listWindow();
+ return episodes().slice(start, end);
+ });
+
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
const ensureFocus = () => {
@@ -537,9 +571,17 @@ export function MyShowsPage() {
paddingRight={1}
backgroundColor={focused() ? theme.border : undefined}
>
- {focused() ? marker() : " "}
- {showTitle(feed)}
- ({feed.episodes.length})
+
+ {focused() ? marker() : " "}
+
+ {/* 20%-wide parent pane truncates hard — same
+ middle-ellipsis guard as the depth-0 rows. */}
+
+ {showTitle(feed)}
+
+
+ ({feed.episodes.length})
+
);
}}
@@ -622,11 +664,17 @@ export function MyShowsPage() {
}
>
-
+ {/* Spacers keep the scrollbox content at the FULL list
+ height so the scrollbar reflects the real list, not the
+ render window. */}
+ 0}>
+
+
+
{(ep, index) => (
listWindow()[0] + index()}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
@@ -635,11 +683,14 @@ export function MyShowsPage() {
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
- nav.setDepthFocus(index(), 1);
+ nav.setDepthFocus(listWindow()[0] + index(), 1);
}}
/>
)}
+ 0}>
+
+
episodes().length}
diff --git a/tests/show-row-wrap.test.tsx b/tests/show-row-wrap.test.tsx
new file mode 100644
index 0000000..a1b1b1e
--- /dev/null
+++ b/tests/show-row-wrap.test.tsx
@@ -0,0 +1,189 @@
+/**
+ * Show-row height regression — My Shows depth-0 rows must stay exactly one
+ * line tall: marker + show title + episode count (+ watchlist dot). The
+ * flexible title carries `wrapMode="none"` + `truncate` (middle-ellipsis:
+ * head and tail of the title stay visible) and the fixed-width cells carry
+ * `flexShrink={0}`, so Yoga can never shrink them and wrap the row — a
+ * wrapped row grows to 2+ lines and the episode count + watchlist dot shift
+ * below the title while scrolling (the original bug). Same guard for the
+ * 20%-wide parent-pane shows list at depth ≥1.
+ *
+ * Rendered at 70 columns so the 35-col current pane / 14-col parent pane are
+ * narrow enough to force truncation on the long title; at the default
+ * 100-col/50-col pane the same rows show everything in full.
+ */
+
+import { describe, test, expect, afterAll } from "bun:test";
+import type { JSX } from "solid-js";
+import { testRender } from "@opentui/solid";
+import { ThemeProvider } from "../src/context/ThemeContext";
+import { PaneRow } from "../src/components/PaneRow";
+
+type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
+
+function frameText(spans: Frame): string[] {
+ return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
+}
+
+const LONG_TITLE =
+ "Out of Whiskey and Reaching for the Rotgut (Members Only #338)";
+
+// The exact depth-0 row shape MyShowsPage renders: marker + title + count +
+// watchlist dot. Static text, no store hooks — pure layout probe.
+const ShowRowFixed = () => (
+
+ ❯
+
+ {LONG_TITLE}
+
+ (123)
+ ●
+
+);
+
+// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
+// wraps at the shrunken width and pushes the count + dot onto wrapped lines.
+const ShowRowNaive = () => (
+
+ ❯
+ {LONG_TITLE}
+ (123)
+ ●
+
+);
+
+// The depth-1 parent-pane shows-list row (marker + title + count).
+const ParentRowFixed = () => (
+
+ ❯
+
+ {LONG_TITLE}
+
+ (123)
+
+);
+
+const ParentRowNaive = () => (
+
+ ❯
+ {LONG_TITLE}
+ (123)
+
+);
+
+async function renderRow(
+ row: () => JSX.Element,
+ pane: "current" | "parent",
+ width = 70,
+): Promise<{ lines: string[]; destroy: () => Promise }> {
+ const setup = await testRender(
+ () => (
+
+
+
+ ),
+ { width, height: 10, useThread: false },
+ );
+ // ThemeProvider mounts children only once the theme resolves; poll for
+ // the header row so the captured frame is a mounted PaneRow.
+ let lines: string[] | null = null;
+ for (let i = 0; i < 40 && !lines; i++) {
+ await setup.renderOnce();
+ const frame = setup.captureSpans() as unknown as Frame;
+ const ls = frameText(frame);
+ if (ls.some((l) => l.includes("List"))) lines = ls;
+ else await new Promise((r) => setTimeout(r, 100));
+ }
+ if (!lines) throw new Error("PaneRow did not render before timeout");
+ return {
+ lines,
+ destroy: async () => {
+ setup.renderer.destroy();
+ },
+ };
+}
+
+const cleanups: (() => void | Promise)[] = [];
+afterAll(async () => {
+ for (const c of cleanups) {
+ try {
+ await c();
+ } catch {
+ // renderer already torn down — ignore
+ }
+ }
+});
+
+describe("show row height in the current pane (70-wide → 35-col pane)", () => {
+ test("long title stays on one line — middle-ellipsis keeps head AND tail, count + dot stay aligned", async () => {
+ const { lines, destroy } = await renderRow(ShowRowFixed, "current");
+ cleanups.push(destroy);
+
+ // Middle-ellipsis: the title head and its tail both survive, on a
+ // single line (end-truncation would drop the tail).
+ expect(lines.filter((l) => l.includes("Out of Wh"))).toHaveLength(1);
+ expect(lines.filter((l) => l.includes("#338)"))).toHaveLength(1);
+ // The count and watchlist dot sit on that same line — nothing wrapped.
+ const aligned = lines.filter(
+ (l) =>
+ l.includes("Out of Wh") &&
+ l.includes("#338)") &&
+ l.includes("(123)") &&
+ l.includes("●"),
+ );
+ expect(aligned).toHaveLength(1);
+ // Row occupies exactly 1 content line below the header.
+ const content = lines.filter(
+ (l) => l.includes("Out of Wh") || l.includes("(123)"),
+ );
+ expect(content).toHaveLength(1);
+ });
+
+ test("naive row (pre-fix props) wraps the title — the regression the test guards", async () => {
+ const { lines, destroy } = await renderRow(ShowRowNaive, "current");
+ cleanups.push(destroy);
+
+ // The title's wrapped fragments span 3 frame lines instead of 1 —
+ // every row below shifts while scrolling.
+ const titleFragments = lines.filter(
+ (l) =>
+ l.includes("Out of Whiskey") ||
+ l.includes("Rotgut") ||
+ l.includes("#338)"),
+ );
+ expect(titleFragments).toHaveLength(3);
+ });
+});
+
+describe("show row in the parent pane (70-wide → 14-col pane)", () => {
+ test("long title stays on one line with count aligned", async () => {
+ const { lines, destroy } = await renderRow(ParentRowFixed, "parent");
+ cleanups.push(destroy);
+
+ // The 14-col slot truncates the title to a head stub (too narrow for
+ // head + tail), but the row stays one line and the count pins to it.
+ expect(lines.filter((l) => l.includes("O..."))).toHaveLength(1);
+ expect(
+ lines.filter((l) => l.includes("O...") && l.includes("(123)")),
+ ).toHaveLength(1);
+ });
+
+ test("naive parent row wraps the title — the regression the test guards", async () => {
+ const { lines, destroy } = await renderRow(ParentRowNaive, "parent");
+ cleanups.push(destroy);
+
+ const titleFragments = lines.filter(
+ (l) =>
+ l.includes("Out of") ||
+ l.includes("Whiskey") ||
+ l.includes("Rotgut") ||
+ l.includes("#3"),
+ );
+ expect(titleFragments.length).toBeGreaterThan(1);
+ });
+});