7 Commits

Author SHA1 Message Date
ebed49237c style(tabpanel): convert tab indentation to 2 spaces 2026-08-10 20:57:16 -04:00
dc2b22eaa5 feat(settings): accent-colored input cursor in add-source form
Match the input's focusedTextColor with cursorColor=theme.accent on
both the name and URL fields.
2026-08-10 20:57:13 -04:00
2bb612ee07 fix(player): render help hint via content prop to avoid escaping
The babel-preset-solid JSX transform HTML-escapes static string
children (< > → &lt; &gt;), which opentui renders verbatim — so the
"< > seek" hint displayed its entities. Pass the string as the
content prop instead, which bypasses the transform.
2026-08-10 20:57:11 -04:00
dc855ab8a0 feat(shell): hold now-playing marquee at start between scroll passes
Previously the marquee looped continuously, cycling back to the
start the moment the text finished. Now it holds at the start for
SCROLL_HOLD_MS (10s), scrolls one pass at SCROLL_STEP_MS per char,
then holds again.
2026-08-10 20:57:08 -04:00
f976bdc2b7 fix(rows): keep episode rows exactly 3 lines — truncate, don't wrap
Feed and My Shows rows could grow to 4+ lines when a long title was
shrunk by the current pane: flexible text wrapped instead of
truncating, shifting every row below while scrolling. Add
wrapMode=none + truncate to flexible text and flexShrink=0 to
fixed-width cells so rows stay one line tall. Feed rows also move
the podcast name onto its own line. Adds a rendered-layout
regression test at 70 columns (35-col current pane).
2026-08-10 20:57:06 -04:00
1cf3361e59 fix(feed): stop refreshes from re-sorting the updated list
Two fixes to refresh order stability (My Shows / Feed sort by
lastUpdated):

- A refresh that fetches identical episodes no longer bumps
  lastUpdated (id-set comparison via sameEpisodes), so unchanged
  feeds keep their position instead of reordering every cycle.
- refreshAllFeeds now fetches in parallel and applies ONE atomic
  update instead of a per-feed setFeeds, which re-sorted the list
  once per completion and made order flap until the batch finished.

Adds feed-refresh regression tests with mocked clock.
2026-08-10 20:57:02 -04:00
ada441300a feat(layout): widen current pane to 50% — PANE_RATIO 2:5:3
Change the parent|current|preview split from 1:2:2 (20/40/40) to
2:5:3 (20/50/30) so the focused list gets more room. 2-pane tabs
now give current the combined 80%. Updates ratio comments and the
PaneRow test expectations.
2026-08-10 20:56:58 -04:00
13 changed files with 636 additions and 157 deletions

View File

@@ -1,15 +1,15 @@
/**
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
*
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
* the ratio is exact regardless of content width — a column's content can
* never stretch its slot.
* Implements yazi's `mgr.ratio` contract: three columns grow at
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
* so every list tab renders an identical, layout-stable shell. Columns use
* `flexBasis={0}` so the ratio is exact regardless of content width — a
* column's content can never stretch its slot.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 1/5 slot when blank (never collapses to width 0).
* KEEPS its 20% slot when blank (never collapses to width 0).
* Borderless (no left/right/top/bottom edge). Carries the single
* header row: the CURRENT column's title renders top-left in the
* parent's slot (the panes above current/preview were removed).
@@ -184,7 +184,7 @@ export function PaneRow(props: PaneRowProps) {
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/5) — previous-depth list; title row top-left ───────── */}
{/* ── parent (20%) — previous-depth list; title row top-left ───────── */}
<Pane
grow={PANE_RATIO.parent}
label={currentLabel}
@@ -200,7 +200,7 @@ export function PaneRow(props: PaneRowProps) {
border={["left", "right"]}
scrollFocused={() => focused()}
/>
{/* ── preview (2/5) — hovered-item detail; no border, no header ────── */}
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
<Show when={panes() === 3}>
<Pane
grow={PANE_RATIO.preview}

View File

@@ -239,10 +239,14 @@ export function Shell() {
// ── Now-playing marquee ────────────────────────────────────────────────────
// The now-playing segment takes the full remaining status-bar width and
// marquee-scrolls on a 300ms timer when its text overflows; when it fits
// (or the bar is too narrow to show anything) it renders statically.
// marquee-scrolls when its text overflows; when it fits (or the bar is too
// narrow to show anything) it renders statically. Each pass scrolls at
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
// before scrolling again.
const dims = useTerminalDimensions();
const GAP = 3;
const SCROLL_STEP_MS = 150;
const SCROLL_HOLD_MS = 10_000;
const [scrollOffset, setScrollOffset] = createSignal(0);
const leftFixed = () =>
modeLabel().length +
@@ -270,10 +274,29 @@ export function Shell() {
setScrollOffset(0);
if (!text || avail <= 0 || text.length <= avail) return;
const cycle = text.length + GAP - avail;
const id = setInterval(() => {
setScrollOffset((o) => (o + 1) % cycle);
}, 150);
onCleanup(() => clearInterval(id));
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
// then hold again before the next pass.
let holdId: ReturnType<typeof setTimeout> | null = null;
let scrollId: ReturnType<typeof setInterval> | null = null;
const startHold = () => {
setScrollOffset(0);
holdId = setTimeout(() => {
scrollId = setInterval(() => {
const next = scrollOffset() + 1;
if (next >= cycle) {
clearInterval(scrollId!);
startHold();
} else {
setScrollOffset(next);
}
}, SCROLL_STEP_MS);
}, SCROLL_HOLD_MS);
};
startHold();
onCleanup(() => {
if (holdId) clearTimeout(holdId);
if (scrollId) clearInterval(scrollId);
});
});
return (

View File

@@ -24,101 +24,100 @@ import { TABS } from "@/utils/navigation";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed",
[TABS.MYSHOWS]: "My Shows",
[TABS.DISCOVER]: "Discover",
[TABS.SEARCH]: "Search",
[TABS.PLAYER]: "Player",
[TABS.SETTINGS]: "Settings",
[TABS.FEED]: "Feed",
[TABS.MYSHOWS]: "My Shows",
[TABS.DISCOVER]: "Discover",
[TABS.SEARCH]: "Search",
[TABS.PLAYER]: "Player",
[TABS.SETTINGS]: "Settings",
};
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
const TAB_ICON: Record<TABS, string> = {
[TABS.FEED]: NF_ICONS.feed,
[TABS.MYSHOWS]: NF_ICONS.shows,
[TABS.DISCOVER]: NF_ICONS.discover,
[TABS.SEARCH]: NF_ICONS.search,
[TABS.PLAYER]: NF_ICONS.player,
[TABS.SETTINGS]: NF_ICONS.settings,
[TABS.FEED]: NF_ICONS.feed,
[TABS.MYSHOWS]: NF_ICONS.shows,
[TABS.DISCOVER]: NF_ICONS.discover,
[TABS.SEARCH]: NF_ICONS.search,
[TABS.PLAYER]: NF_ICONS.player,
[TABS.SETTINGS]: NF_ICONS.settings,
};
/** Numeric TABS values, in declaration order (1..TabsCount). */
const TAB_ORDER = Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
(v): v is TABS => typeof v === "number",
) as TABS[];
export function TabListPane(props: { muted?: boolean }) {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const { theme } = useTheme();
const nav = useNavigation();
const marker = useSelectionMarker();
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const { theme } = useTheme();
const nav = useNavigation();
const marker = useSelectionMarker();
const cursor = () => nav.tabCursor();
const activeTab = () => nav.activeTab();
/** `active=true` when this pane is the CURRENT column (Shell root);
* `false` when it is the muted UP/parent column (pages' parent pane). */
const active = () => !props.muted;
const cursor = () => nav.tabCursor();
const activeTab = () => nav.activeTab();
/** `active=true` when this pane is the CURRENT column (Shell root);
* `false` when it is the muted UP/parent column (pages' parent pane). */
const active = () => !props.muted;
// Same focus-bg / focus-fg contract every other pane uses.
const focusBg = (t: TABS) =>
t === cursor() && active()
? theme.primary
: t === cursor()
? theme.border
: undefined;
const focusFg = (t: TABS) =>
t === cursor() && active()
? theme.surface
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
// Same focus-bg / focus-fg contract every other pane uses.
const focusBg = (t: TABS) =>
t === cursor() && active()
? theme.primary
: t === cursor()
? theme.border
: undefined;
const focusFg = (t: TABS) =>
t === cursor() && active()
? theme.surface
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<For each={TAB_ORDER}>
{(tab) => {
const isCursor = () => cursor() === tab;
const isActive = () => activeTab() === tab;
// The active tab is only accented in the Up/parent position — when this
// pane is CURRENT, the cursor highlight is the only highlight.
const labelFg = () =>
isCursor()
? focusFg(tab)
: isActive() && !active()
? theme.accent
: theme.text;
const ref = useScrollIntoView(isCursor);
return (
<box
ref={ref}
width="100%"
height={1}
flexDirection="row"
paddingRight={1}
backgroundColor={focusBg(tab)}
onMouseDown={() => {
// Click = hover + open, the yazi "open" of the row
// (switches to the tab and enters its content), the same
// as l/Enter. Restores mouse support the tab-strip
// refactor dropped.
nav.setTabCursor(tab);
nav.activateTabCursor();
}}
>
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
{nerd && (
<text fg={focusFg(tab)} paddingRight={1}>
{TAB_ICON[tab]}
</text>
)}
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
<text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
);
return (
<For each={TAB_ORDER}>
{(tab) => {
const isCursor = () => cursor() === tab;
const isActive = () => activeTab() === tab;
// The active tab is only accented in the Up/parent position — when this
// pane is CURRENT, the cursor highlight is the only highlight.
const labelFg = () =>
isCursor()
? focusFg(tab)
: isActive() && !active()
? theme.accent
: theme.text;
const ref = useScrollIntoView(isCursor);
return (
<box
ref={ref}
width="100%"
height={1}
flexDirection="row"
paddingRight={1}
backgroundColor={focusBg(tab)}
onMouseDown={() => {
// Click = hover + open, the yazi "open" of the row
// (switches to the tab and enters its content), the same
// as l/Enter. Restores mouse support the tab-strip
// refactor dropped.
nav.setTabCursor(tab);
nav.activateTabCursor();
}}
>
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
{nerd && (
<text fg={focusFg(tab)} paddingRight={1}>
{TAB_ICON[tab]}
</text>
)}
<text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
);
}

View File

@@ -13,7 +13,7 @@
*
* parent | current | preview
*
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused.

View File

@@ -267,31 +267,55 @@ function FeedPage() {
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
<text
flexShrink={0}
fg={focusFg(index(), fi(), isActive())}
>
{index() === fi() ? marker() : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
<text
wrapMode="none"
truncate
fg={focusFg(index(), fi(), isActive())}
>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi() ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi() ? theme.surface : muted()}>
{/* podcast name on its own row — readable at a glance; the
50% current pane fits it in full for typical names, and
truncate keeps the row one line tall either way */}
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={index() === fi() ? theme.surface : theme.textSecondary}
>
{item.feed.customName || item.feed.podcast.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : theme.info}
>
{formatDate(item.episode.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : muted()}
>
{formatDuration(item.episode.duration)}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>

View File

@@ -362,26 +362,41 @@ export function MyShowsPage() {
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
<text
flexShrink={0}
fg={focusFg(index(), lf(), isActive())}
>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf() ? theme.surface : theme.info}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>

View File

@@ -112,9 +112,13 @@ export function PlayerPage() {
/>
<box height={1} />
<text fg={muted()}>
{"P play/pause N next B prev < > seek h back"}
</text>
{/* content prop (not a text child): the babel-preset-solid JSX
* transform HTML-escapes static string children (`<` → `&lt;`),
* which opentui renders verbatim; content bypasses that. */}
<text
fg={muted()}
content={"P play/pause N next B prev < > seek h back"}
/>
</box>
);

View File

@@ -105,6 +105,7 @@ function AddSourceForm() {
width={25}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box flexDirection="row" gap={1}>
@@ -120,6 +121,7 @@ function AddSourceForm() {
width={35}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box

View File

@@ -43,6 +43,17 @@ function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources);
}
/** True when two episode lists hold the same episodes (id-set equality,
* order-insensitive). Refreshes compare fetched content against this so an
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
* "updated" sort — instead of reordering the list on every background
* refresh. */
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
if (a.length !== b.length) return false;
const ids = new Set(a.map((e) => e.id));
return b.every((e) => ids.has(e.id));
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
@@ -249,6 +260,26 @@ function createFeedStore() {
}
};
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
* only when the content actually changed (see sameEpisodes). Returns the
* ORIGINAL array reference when nothing changed so callers skip
* persistence entirely — a refresh that fetched identical episodes must
* not re-sort the "updated" view. */
const applyRefreshedEpisodes = (
prev: Feed[],
feedId: string,
episodes: Episode[],
): Feed[] => {
let changed = false;
const updated = prev.map((f) => {
if (f.id !== feedId) return f;
if (sameEpisodes(f.episodes, episodes)) return f;
changed = true;
return { ...f, episodes, lastUpdated: new Date() };
});
return changed ? updated : prev;
};
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId);
@@ -259,10 +290,8 @@ function createFeedStore() {
feedId,
);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (updated !== prev) saveFeeds(updated);
return updated;
});
@@ -271,14 +300,35 @@ function createFeedStore() {
runAutoDownload();
};
/** Refresh all feeds */
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
* atomic update. Per-feed incremental setFeeds re-sorted the list once
* per completion (each refresh bumped lastUpdated and the "updated" sort
* re-ran), which showed up as the list order flapping until the batch
* finished. */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
const currentFeeds = feeds();
for (const feed of currentFeeds) {
await refreshFeed(feed.id);
}
const results = await Promise.all(
currentFeeds.map(async (feed) => [
feed.id,
await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feed.id,
),
] as const),
);
setFeeds((prev) => {
let updated = prev;
for (const [feedId, episodes] of results) {
updated = applyRefreshedEpisodes(updated, feedId, episodes);
}
if (updated !== prev) saveFeeds(updated);
return updated;
});
// Global auto-download: one idempotent pass after the batch.
runAutoDownload();
} finally {
setIsLoadingFeeds(false);
}

View File

@@ -57,13 +57,13 @@ export function rootFrameFor(
// terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
//
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
// give `current` the combined 4/5.
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
// give `current` the combined 8/10 (80%).
export const PANE_RATIO = {
parent: 1,
current: 2,
preview: 2,
parent: 2,
current: 5,
preview: 3,
} as const;
// Number of *focusable* content panes per tab. The three visible columns

155
tests/feed-refresh.test.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* Feed refresh order-stability regression test.
*
* My Shows / Feed sort by `lastUpdated` ("updated") by default, and every
* refresh bumped it unconditionally — so a startup refresh-all re-sorted the
* list once per feed as each fetch landed (order flapping until the batch
* finished). These tests pin the contract:
*
* 1. A refresh that fetches identical episodes does NOT bump lastUpdated —
* the feed object is untouched, so the list cannot reorder.
* 2. A refresh that fetches genuinely new episodes DOES bump lastUpdated.
* 3. refreshAllFeeds applies one atomic update: unchanged feeds keep their
* order and timestamps after a full refresh.
*
* The clock is mocked (fake timers) so the "did lastUpdated advance?" checks
* are deterministic — no real sleeps that would race under load.
*/
import { test, expect, beforeAll, afterAll, beforeEach, vi } 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-refresh-"));
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[] = [];
let feedAId = "";
/** 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>Test Show</title>
<description>Regression test feed</description>
${items}
</channel></rss>`;
}
const makePodcast = (feedUrl: string): Podcast => ({
id: feedUrl,
title: "Test Show",
description: "Regression 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 });
},
});
});
beforeEach(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
test("refresh with identical episodes does not bump lastUpdated", async () => {
const store = useFeedStore();
servedEpisodes = [
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
];
const feedUrl = `http://127.0.0.1:${server!.port}/show-a.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
feedAId = feed!.id;
const before = store.getFeed(feedAId)!;
const beforeUpdated = before.lastUpdated.getTime();
// Advance the (mocked) clock, then refresh with identical content.
vi.advanceTimersByTime(60_000);
await store.refreshFeed(feedAId);
const after = store.getFeed(feedAId)!;
expect(after).toBe(before); // same object: no update applied at all
expect(after.lastUpdated.getTime()).toBe(beforeUpdated);
expect(after.episodes.length).toBe(3);
});
test("refresh with a genuinely new episode bumps lastUpdated", async () => {
const store = useFeedStore();
servedEpisodes.push({ title: "Ep 0 (new)", date: "2026-08-04T00:00:00Z" });
const before = store.getFeed(feedAId)!.lastUpdated.getTime();
vi.advanceTimersByTime(60_000);
await store.refreshFeed(feedAId);
const after = store.getFeed(feedAId)!;
expect(after.lastUpdated.getTime()).toBeGreaterThan(before);
expect(after.episodes.length).toBe(4);
});
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
const store = useFeedStore();
// Feed B: distinct URL, identical served content, so refreshing it is a
// no-op too.
const feedBUrl = `http://127.0.0.1:${server!.port}/show-b.xml`;
const feedB = await store.addFeed(makePodcast(feedBUrl), "test-source");
expect(feedB).not.toBeNull();
const feedBId = feedB!.id;
const orderBefore = store.getFilteredFeeds().map((f) => f.id);
const tsBefore: Record<string, number> = {};
for (const id of [feedAId, feedBId]) {
tsBefore[id] = store.getFeed(id)!.lastUpdated.getTime();
}
vi.advanceTimersByTime(60_000);
await store.refreshAllFeeds();
expect(store.getFilteredFeeds().map((f) => f.id)).toEqual(orderBefore);
for (const id of [feedAId, feedBId]) {
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
}
});

View File

@@ -0,0 +1,207 @@
/**
* Episode-row height regression — Feed rows must stay exactly 3 lines tall:
* title, podcast name, meta (date + duration + markers). Every line carries
* `wrapMode="none"` + `truncate` on flexible text and `flexShrink={0}` on
* fixed-width cells so Yoga can never shrink a text below its content width
* and wrap it — a wrapped row grows to 4+ lines and every entry below
* shifts its starting position while scrolling (the original bug).
*
* Rendered at 70 columns so the 35-col current pane is 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(""));
}
// The exact row shape FeedPage renders: title, podcast name, meta. Static
// text, no store hooks — pure layout probe.
const FixedRow = () => (
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
<box flexDirection="row" gap={1}>
<text flexShrink={0}></text>
<text wrapMode="none" truncate>
#674 - Scott Payne
</text>
</box>
<box paddingLeft={2}>
<text wrapMode="none" truncate>
This Past Weekend w/ Theo Von
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text flexShrink={0}>Aug 10, 2026</text>
<text flexShrink={0}>3h 50m</text>
</box>
</box>
);
// A long title proves the truncate guard: without it the title wraps to
// multiple lines and the row grows past 3 lines.
const LongTitleFixed = () => (
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
<box flexDirection="row" gap={1}>
<text flexShrink={0}></text>
<text wrapMode="none" truncate>
Out of Whiskey and Reaching for the Rotgut (Members Only #338)
</text>
</box>
<box paddingLeft={2}>
<text wrapMode="none" truncate>
This Past Weekend w/ Theo Von
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text flexShrink={0}>Aug 10, 2026</text>
<text flexShrink={0}>3h 50m</text>
</box>
</box>
);
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
// wraps at the shrunken width and the row grows.
const NaiveRow = () => (
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
<box flexDirection="row" gap={1}>
<text></text>
<text>Out of Whiskey and Reaching for the Rotgut (Members Only #338)</text>
</box>
<box paddingLeft={2}>
<text>This Past Weekend w/ Theo Von</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text>Aug 10, 2026</text>
<text>3h 50m</text>
</box>
</box>
);
async function renderCurrent(
row: () => JSX.Element,
width = 70,
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={null}
current={row}
preview={null}
currentLabel="List"
/>
</ThemeProvider>
),
{ 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<void>)[] = [];
afterAll(async () => {
for (const c of cleanups) {
try {
await c();
} catch {
// renderer already torn down — ignore
}
}
});
describe("episode row height in the current pane (70-wide → 35-col pane)", () => {
test("fixed row: title, podcast name and meta each render on exactly one line", async () => {
const { lines, destroy } = await renderCurrent(() => <FixedRow />);
cleanups.push(destroy);
// Podcast name is fully visible on its own line — the usability the
// dedicated row exists for.
expect(lines.filter((l) => l.includes("This Past Weekend w/ Theo Von"))).toHaveLength(1);
// Title and meta each on a single frame line (a wrapped date would
// split "Aug 10, 2026" across lines).
expect(lines.filter((l) => l.includes("Scott Payne"))).toHaveLength(1);
expect(lines.filter((l) => l.includes("Aug 10, 2026"))).toHaveLength(1);
expect(lines.filter((l) => l.includes("3h 50m"))).toHaveLength(1);
// The row occupies exactly the 3 content lines below the header.
const content = lines.filter(
(l) =>
l.includes("Scott Payne") ||
l.includes("This Past Weekend") ||
l.includes("Aug 10,"),
);
expect(content).toHaveLength(3);
});
test("long title stays on one line (truncated), keeping the row at 3 lines", async () => {
const { lines, destroy } = await renderCurrent(() => <LongTitleFixed />);
cleanups.push(destroy);
// Truncated: the title head appears on exactly one line and the full
// title never appears on any line (middle-ellipsis clips it).
expect(lines.filter((l) => l.includes("Out of Whiske"))).toHaveLength(1);
expect(
lines.some((l) =>
l.includes(
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)",
),
),
).toBe(false);
// Podcast name and meta still each on one line — row total 3.
expect(lines.filter((l) => l.includes("This Past Weekend"))).toHaveLength(1);
expect(lines.filter((l) => l.includes("Aug 10,"))).toHaveLength(1);
const content = lines.filter(
(l) =>
l.includes("Out of Whiske") ||
l.includes("This Past Weekend") ||
l.includes("Aug 10,"),
);
expect(content).toHaveLength(3);
});
test("naive row (pre-fix props) wraps the long title — the regression the test guards", async () => {
const { lines, destroy } = await renderCurrent(() => <NaiveRow />);
cleanups.push(destroy);
// The title's wrapped fragments span 3 frame lines instead of 1.
const titleFragments = lines.filter(
(l) =>
l.includes("Out of Whiske") ||
l.includes("for the Rotgut") ||
l.includes("#338)"),
);
expect(titleFragments).toHaveLength(3);
// Row total: 3 title lines + podcast + meta = 5 content lines.
const content = lines.filter(
(l) =>
l.includes("Out of Whiske") ||
l.includes("for the Rotgut") ||
l.includes("#338)") ||
l.includes("This Past Weekend") ||
l.includes("Aug 10,"),
);
expect(content).toHaveLength(5);
});
});

View File

@@ -1,11 +1,11 @@
/**
* PaneRow tests — the 1:2:2 parent|current|preview layout primitive.
* PaneRow tests — the 2:5:3 (20/50/30) parent|current|preview layout primitive.
*
* Verified through the opentui test renderer's captured frames (the same
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
* observable as rendered column widths.
* observable in rendered output, not in unit-testable state.
*
* • Unit: three columns render at 1:2:2 (e.g. 20/40/40 of 100) even when the
* • Unit: three columns render at 2:5:3 (e.g. 20/50/30 of 100) even when the
* parent and preview children are null, and the blank parent keeps its
* slot with a muted placeholder.
* • Integration: the current pane renders muted left/right border edges
@@ -119,9 +119,9 @@ afterAll(async () => {
}
});
// ── Unit: three columns at 1:2:2 regardless of null children ───────────────
// ── Unit: three columns at 2:5:3 regardless of null children ───────────────
describe("PaneRow layout", () => {
test("renders three columns at 1:2:2 even with null parent/preview", async () => {
test("renders three columns at 2:5:3 even with null parent/preview", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
@@ -132,15 +132,15 @@ describe("PaneRow layout", () => {
const widths = columnWidths(spans);
expect(widths).toHaveLength(3);
const [p, c, v] = widths;
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5).
// 100-wide row splits as 20 / 50 / 30 (2 : 5 : 3 of 10).
expect(p).toBe(20);
expect(c).toBe(40);
expect(v).toBe(40);
// Exact 1:2:2 proportion (within 1 col rounding).
expect(c).toBeGreaterThanOrEqual(p * 2 - 1);
expect(c).toBeLessThanOrEqual(p * 2 + 1);
expect(v).toBeGreaterThanOrEqual(p * 2 - 1);
expect(v).toBeLessThanOrEqual(p * 2 + 1);
expect(c).toBe(50);
expect(v).toBe(30);
// Exact 2:5:3 proportion (within 1 col rounding).
expect(c).toBeGreaterThanOrEqual(Math.round(p * 2.5) - 1);
expect(c).toBeLessThanOrEqual(Math.round(p * 2.5) + 1);
expect(v).toBeGreaterThanOrEqual(Math.round(p * 1.5) - 1);
expect(v).toBeLessThanOrEqual(Math.round(p * 1.5) + 1);
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
expect(p).toBeGreaterThan(4);
const body = spans.lines
@@ -150,7 +150,7 @@ describe("PaneRow layout", () => {
expect(body).toContain("ITEM");
});
test("keeps the 1/5 parent slot across widths (ratio stable)", async () => {
test("keeps the 20% parent slot across widths (ratio stable)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>x</text>,
@@ -159,9 +159,9 @@ describe("PaneRow layout", () => {
});
cleanups.push(destroy);
const [p, c, v] = columnWidths(spans);
expect(p).toBe(14); // 70 → 14 / 28 / 28
expect(c).toBe(28);
expect(v).toBe(28);
expect(p).toBe(14); // 70 → 14 / 35 / 21
expect(c).toBe(35);
expect(v).toBe(21);
});
});
@@ -181,9 +181,9 @@ describe("PaneRow current-pane borders", () => {
});
cleanups.push(destroy);
// 100-wide row splits as 20 / 40 / 40: the current pane's edges sit at
// columns 20 and 59. No horizontal or corner glyphs — edges only.
expect(borderColumns(spans)).toEqual([20, 59]);
// 100-wide row splits as 20 / 50 / 30: the current pane's edges sit at
// columns 20 and 69. No horizontal or corner glyphs — edges only.
expect(borderColumns(spans)).toEqual([20, 69]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
@@ -196,7 +196,7 @@ describe("PaneRow current-pane borders", () => {
});
cleanups.push(destroy);
expect(borderColumns(spans)).toEqual([20, 59]);
expect(borderColumns(spans)).toEqual([20, 69]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
@@ -208,7 +208,7 @@ describe("PaneRow current-pane borders", () => {
focused: () => true,
});
cleanups.push(destroy);
expect(borderColumns(spans)).toEqual([20, 59]);
expect(borderColumns(spans)).toEqual([20, 69]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
@@ -218,7 +218,7 @@ describe("PaneRow current-pane borders", () => {
focused: () => false,
});
cleanups.push(destroy2);
expect(borderColumns(spans2)).toEqual([20, 59]);
expect(borderColumns(spans2)).toEqual([20, 69]);
expect(frameText(spans2)).not.toMatch(boxGlyphs);
});
@@ -229,7 +229,7 @@ describe("PaneRow current-pane borders", () => {
preview: null,
});
cleanups.push(destroy);
expect(borderColumns(spans)).toEqual([20, 59]);
expect(borderColumns(spans)).toEqual([20, 69]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
});