feat(search): episode search scope with tab toggle

Search now covers individual episodes, not just shows: the iTunes
Search API (entity=podcastEpisode) matches episode titles and show
notes, so a guest or topic finds the episodes they appear in across
shows. Enter on an episode result subscribes to the parent show.

- types: SearchResult becomes a kind-discriminated union
  (podcast | episode); EpisodeSearchResult carries the parent show so
  existing consumers compile unchanged
- source-searcher: searchEpisodesByType (RSS/CUSTOM return []),
  buildItunesEpisodeUrl, cleanDescription (HTML -> text),
  mapItunesEpisodeResult (episode id/duration ms->s/audioUrl, reuses
  mapItunesResult so delisted shows keep a directoryUrl)
- search: searchEpisodes with an 'episode' cache/dedupe namespace so
  shows and episodes for the same query never mix
- stores/search: scope signal (podcast | episode) persisted to
  podtui_search_scope; search() branches on scope
- SearchPage: Shows/Episodes pills row with 'tab to toggle', scope-
  aware placeholder/empty state/result rows (episode row = title +
  Show · date) and preview; toggling re-runs the current query
- keybinds: search-scope-toggle bound to tab (keybinds.jsonc AND the
  runtime DEFAULT_KEYBINDS merge so the binding exists for users with
  a pre-existing config file); while the input is focused the Shell
  router never sees Tab, so the input handles it via onKeyDown +
  preventDefault (no double-toggle: the router path only fires when
  the input is defocused)
- Shell help overlay documents [tab] shows/episodes
This commit is contained in:
2026-08-11 00:50:13 -04:00
parent ef9fc13aaa
commit 0c3506beb5
7 changed files with 276 additions and 73 deletions

View File

@@ -447,6 +447,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
["enter", "open"], ["enter", "open"],
["r", "refresh"], ["r", "refresh"],
["s", "search"], ["s", "search"],
[p("search-scope-toggle"), "shows/episodes"],
["f", "filter"], ["f", "filter"],
[",", "sort"], [",", "sort"],
[".", "hidden"], [".", "hidden"],

View File

@@ -59,8 +59,11 @@
"help": ["~", "f1"], "help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
"search": ["s"], "search": ["s"],
"filter": ["f"], // tab toggles the Search page between show and episode scope (re-runs the
// current query when viewing results)
"search-scope-toggle": ["tab"],
"filter": ["f"],
"sort": [","], "sort": [","],
"toggle-hidden": ["."], "toggle-hidden": ["."],
"refresh": ["r"], "refresh": ["r"],

View File

@@ -63,6 +63,7 @@ export type KeybindActionName =
| "quit" | "quit"
| "help" | "help"
| "search" | "search"
| "search-scope-toggle"
| "filter" | "filter"
| "sort" | "sort"
| "toggle-hidden" | "toggle-hidden"

View File

@@ -8,6 +8,10 @@
* query (muted, read-only); preview shows the detail of * query (muted, read-only); preview shows the detail of
* the focused result. * the focused result.
* *
* Search scope: `tab` (search-scope-toggle) flips between shows and episodes
* (clickable pills on the query depth too); toggling while viewing results
* re-runs the current query in the new scope.
*
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell * Typed input owns its keys while `nav.inputFocused()` is true (the Shell
* router yields). Escape defocuses the input (handled in Shell) so j/k/h * router yields). Escape defocuses the input (handled in Shell) so j/k/h
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the * navigation resumes; `s` (the `search` action) refocuses it. Enter on the
@@ -38,12 +42,13 @@ import {
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source"; import type { SearchResult, SearchScope } from "@/types/source";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker"; import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export const SearchPaneCount = 1; export const SearchPaneCount = 1;
@@ -69,22 +74,29 @@ function SearchPage() {
// router yields keys to the <input> while this is true; Escape (in Shell) // router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true. // sets it false so navigation resumes; `s` (search action) sets it true.
// //
// Typing is the default only on the query depth (0); the results depth // The input's REAL focus is the source of truth for the flag:
// (1) is always list-navigation. Drive `inputFocused` straight off // useInputFocusNav (the same hook the Settings forms use) flips
// `depth()` rather than seeding it `true` on mount and patching on change: // `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// the depth stack persists across tab switches, so re-mounting this page // and the renderable in lockstep. That matters when the user clicks OFF the
// at depth 1 (e.g. after searching, leaving, and returning to the tab) // input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows // focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// j/k (yielding to a non-existent input) and only the scrollbox's native // event drops the flag, so the Shell router immediately resumes j/k/h
// scroll responds. // instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
// //
// The depth STACK signal is also written by focus moves (setDepthFocus), // The depth stack still SEEDS the flag on transitions, since the query
// so gate the sync on the depth VALUE via a memo: the effect must re-run // depth defaults to typing: re-entering depth 0 (h back from results, or a
// only on an actual depth transition. Without the memo every j/k at the // fresh mount) focuses the input; mounting at depth 1 (returning to the
// query depth re-focuses the input (undoing Escape), which keeps the // tab after a search) stays list-navigation — a stuck-on flag there would
// recents list unreachable by keyboard. // have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
onMount(() => nav.setInputFocused(depth() === 0)); onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false)); onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();
const isQueryDepth = createMemo(() => depth() === 0); const isQueryDepth = createMemo(() => depth() === 0);
createEffect(() => { createEffect(() => {
nav.setInputFocused(isQueryDepth()); nav.setInputFocused(isQueryDepth());
@@ -113,7 +125,10 @@ function SearchPage() {
// Register a visual-mode resolver for the results list (depth 1). // Register a visual-mode resolver for the results list (depth 1).
onMount(() => { onMount(() => {
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`; const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
nav.registerResolver(key, (i) => results()[i]?.podcast.id); nav.registerResolver(key, (i) => {
const r = results()[i];
return r?.kind === "episode" ? r.episode.id : r?.podcast.id;
});
}); });
// ── helpers ───────────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────────
@@ -138,6 +153,19 @@ function SearchPage() {
runSearch(query); runSearch(query);
}; };
/** Set show/episode scope; when viewing results, re-run the current query
* so the list switches immediately (the toggle is otherwise invisible on
* a list of results). */
const applyScope = (next: SearchScope) => {
searchStore.setScope(next);
if (depth() >= 1) {
const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {});
}
};
const toggleScope = () =>
applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast");
const handleSubscribe = async (result: SearchResult) => { const handleSubscribe = async (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result // Actually add the feed to the feed store, then mark the result
// subscribed. addFeed returns null when a feedless directory stub // subscribed. addFeed returns null when a feedless directory stub
@@ -171,13 +199,17 @@ function SearchPage() {
"toggle-select": () => { "toggle-select": () => {
if (depth() === 1) { if (depth() === 1) {
const r = focusedResult(); const r = focusedResult();
if (r) nav.toggleSelected(r.podcast.id); if (r)
nav.toggleSelected(
r.kind === "episode" ? r.episode.id : r.podcast.id,
);
} }
}, },
search: () => { search: () => {
// `s` refocuses the query input (typing mode) when on the query depth. // `s` refocuses the query input (typing mode) when on the query depth.
if (depth() === 0) nav.setInputFocused(true); if (depth() === 0) nav.setInputFocused(true);
}, },
"search-scope-toggle": () => toggleScope(),
refresh: () => { refresh: () => {
const q = submittedQuery() || inputValue().trim(); const q = submittedQuery() || inputValue().trim();
if (q) searchStore.search(q).catch(() => {}); if (q) searchStore.search(q).catch(() => {});
@@ -249,6 +281,9 @@ function SearchPage() {
<text fg={theme.textSecondary}>Query</text> <text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text> <text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}>
Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"}
</text>
<text fg={muted()}>h: back to query</text> <text fg={muted()}>h: back to query</text>
</box> </box>
</Show> </Show>
@@ -264,10 +299,33 @@ function SearchPage() {
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text> <text fg={muted()}>Query:</text>
<input <input
ref={focusNavRef}
value={inputValue()} value={inputValue()}
onInput={setInputValue} onInput={setInputValue}
onSubmit={() => handleSubmit()} onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..." onMouseDown={(evt) => {
// Clicking the input must focus it (typing mode).
// preventDefault stops opentui's click auto-focus from
// grabbing the pane scrollbox instead; setting the flag
// drives the `focused` prop → renderable focus → the
// useInputFocusNav FOCUSED handler.
evt.preventDefault();
nav.setInputFocused(true);
}}
onKeyDown={(evt) => {
// While the input owns keys the Shell router never sees
// Tab, so the scope toggle must be handled here (the
// pills and the tab keybind cover the defocused cases).
if (evt.name === "tab") {
evt.preventDefault();
toggleScope();
}
}}
placeholder={
searchStore.scope() === "episode"
? "Enter episode, guest, topic..."
: "Enter podcast name..."
}
focused={inputActive()} focused={inputActive()}
width={28} width={28}
textColor={theme.text} textColor={theme.text}
@@ -275,6 +333,44 @@ function SearchPage() {
cursorColor={theme.accent} cursorColor={theme.accent}
/> />
</box> </box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textSecondary}>Scope:</text>
<box
backgroundColor={
searchStore.scope() === "podcast" ? theme.primary : undefined
}
onMouseDown={() => applyScope("podcast")}
>
<text
fg={
searchStore.scope() === "podcast"
? theme.surface
: muted()
}
>
{" "}
Shows{" "}
</text>
</box>
<box
backgroundColor={
searchStore.scope() === "episode" ? theme.primary : undefined
}
onMouseDown={() => applyScope("episode")}
>
<text
fg={
searchStore.scope() === "episode"
? theme.surface
: muted()
}
>
{" "}
Episodes{" "}
</text>
</box>
<text fg={muted()}>tab to toggle</text>
</box>
<Show when={searchStore.isSearching()}> <Show when={searchStore.isSearching()}>
<LoadingIndicator label="Searching…" /> <LoadingIndicator label="Searching…" />
</Show> </Show>
@@ -347,7 +443,7 @@ function SearchPage() {
<text fg={muted()}> <text fg={muted()}>
{inputActive() {inputActive()
? "Enter to search · Esc to defocus" ? "Enter to search · Esc to defocus"
: "j/k recents · s to type · h back"} : "j/k recents · s to type · tab scope · h back"}
</text> </text>
</box> </box>
</Show> </Show>
@@ -363,7 +459,9 @@ function SearchPage() {
<text fg={muted()}> <text fg={muted()}>
{searchStore.query() {searchStore.query()
? "No results found" ? "No results found"
: "Enter a search term to find podcasts"} : searchStore.scope() === "episode"
? "Enter a search term to find episodes"
: "Enter a search term to find podcasts"}
</text> </text>
} }
> >
@@ -393,7 +491,9 @@ function SearchPage() {
{index() === fi() ? marker() : " "} {index() === fi() ? marker() : " "}
</text> </text>
<text fg={focusFg(index(), fi(), isActive())}> <text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title} {result.kind === "episode"
? result.episode.title
: result.podcast.title}
</text> </text>
<Show when={result.podcast.isSubscribed}> <Show when={result.podcast.isSubscribed}>
<text <text
@@ -403,14 +503,24 @@ function SearchPage() {
</text> </text>
</Show> </Show>
</box> </box>
<Show when={result.podcast.author}> {result.kind === "episode" ? (
<text <text
fg={index() === fi() ? theme.surface : muted()} fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2} paddingLeft={2}
> >
by {result.podcast.author} {result.podcast.title} ·{" "}
{formatDate(result.episode.pubDate)}
</text> </text>
</Show> ) : (
<Show when={result.podcast.author}>
<text
fg={index() === fi() ? theme.surface : muted()}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
)}
</box> </box>
); );
}} }}
@@ -428,6 +538,10 @@ function SearchPage() {
<strong>Search</strong> <strong>Search</strong>
</text> </text>
<text fg={muted()}>Type a query, press Enter to search.</text> <text fg={muted()}>Type a query, press Enter to search.</text>
<text fg={muted()}>
Tab toggles Shows Episodes (episode search finds guests
and topics).
</text>
<text fg={muted()}>Esc defocuses the input; h goes back.</text> <text fg={muted()}>Esc defocuses the input; h goes back.</text>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}>Recent · {recents().length}</text> <text fg={theme.textSecondary}>Recent · {recents().length}</text>
@@ -444,56 +558,102 @@ function SearchPage() {
</box> </box>
} }
> >
{(result) => ( {(result) => {
<box flexDirection="column" gap={1} padding={1}> const r = result();
<text fg={theme.text}> if (r.kind === "episode") {
<strong>{result().podcast.title}</strong> return (
</text> <box flexDirection="column" gap={1} padding={1}>
<Show when={result().podcast.author}> <text fg={theme.text}>
<text fg={muted()}>by {result().podcast.author}</text> <strong>{r.episode.title}</strong>
</Show> </text>
<Show when={result().podcast.description}> <text fg={theme.textSecondary}>{r.podcast.title}</text>
<text fg={theme.textSecondary}> <Show when={r.podcast.author}>
{result().podcast.description!.slice(0, 400)} <text fg={muted()}>by {r.podcast.author}</text>
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""} </Show>
</text> <Show when={r.episode.description}>
</Show> <text fg={theme.textSecondary}>
<Show when={(result().podcast.categories ?? []).length > 0}> {r.episode.description!.slice(0, 400)}
<box flexDirection="row" gap={1}> {(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
<For each={(result().podcast.categories ?? []).slice(0, 4)}> </text>
{(cat) => <text fg={theme.warning}>[{cat}]</text>} </Show>
</For> <text fg={muted()}>
Published: {formatDate(r.episode.pubDate)}
</text>
<Show when={(r.podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={r.sourceName}>
<text fg={muted()}>Source: {r.sourceName}</text>
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>
enter: subscribe to show · h: back to query
</text>
</box> </box>
</Show> );
<text fg={muted()}> }
Feed:{" "} return (
{result().podcast.feedUrl || <box flexDirection="column" gap={1} padding={1}>
"not listed by source — resolves on subscribe"} <text fg={theme.text}>
</text> <strong>{r.podcast.title}</strong>
<text fg={muted()}> </text>
Updated: {formatDate(result().podcast.lastUpdated)} <Show when={r.podcast.author}>
</text> <text fg={muted()}>by {r.podcast.author}</text>
<Show when={result().sourceName}> </Show>
<text fg={muted()}>Source: {result().sourceName}</text> <Show when={r.podcast.description}>
</Show> <text fg={theme.textSecondary}>
<box height={1} /> {r.podcast.description!.slice(0, 400)}
<Show when={!result().podcast.isSubscribed}> {(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
<text fg={theme.primary}>[+] Subscribe (enter)</text> </text>
</Show> </Show>
<Show when={result().podcast.isSubscribed}> <Show when={(r.podcast.categories ?? []).length > 0}>
<text fg={theme.success}>Already subscribed</text> <box flexDirection="row" gap={1}>
</Show> <For each={(r.podcast.categories ?? []).slice(0, 4)}>
<box height={1} /> {(cat) => <text fg={theme.warning}>[{cat}]</text>}
<text fg={muted()}>enter: subscribe · h: back to query</text> </For>
</box> </box>
)} </Show>
<text fg={muted()}>
Feed:{" "}
{r.podcast.feedUrl ||
"not listed by source — resolves on subscribe"}
</text>
<text fg={muted()}>
Updated: {formatDate(r.podcast.lastUpdated)}
</text>
<Show when={r.sourceName}>
<text fg={muted()}>Source: {r.sourceName}</text>
</Show>
<box height={1} />
<Show when={!r.podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={r.podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back to query</text>
</box>
);
}}
</Show> </Show>
); );
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
? `Search · ${recents().length} recent` ? `Search · ${recents().length} recent`
: `Results · ${results().length}`; : `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
return ( return (
<PaneRow <PaneRow

View File

@@ -4,11 +4,12 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { searchPodcasts, searchByFeedUrl } from "../utils/search"; import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source"; import type { SearchResult, SearchScope } from "../types/source";
const STORAGE_KEY = "podtui_search_history"; const STORAGE_KEY = "podtui_search_history";
const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 20; const MAX_HISTORY = 20;
export interface SearchState { export interface SearchState {
@@ -41,6 +42,27 @@ function saveHistory(history: string[]): void {
} }
} }
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
function loadScope(): SearchScope {
if (typeof localStorage === "undefined") return "podcast";
try {
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
return stored === "episode" ? "episode" : "podcast";
} catch {
return "podcast";
}
}
/** Save search scope to localStorage */
function saveScope(scope: SearchScope): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
} catch {
// Ignore errors
}
}
/** Create search store */ /** Create search store */
export function createSearchStore() { export function createSearchStore() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
@@ -50,6 +72,13 @@ export function createSearchStore() {
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory()); const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]); const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** Set the search scope (shows vs episodes) and persist it. */
const setScope = (next: SearchScope) => {
setScopeState(next);
saveScope(next);
};
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => { const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds(); const feeds = feedStore.feeds();
@@ -110,9 +139,14 @@ export function createSearchStore() {
return; return;
} }
const searchResults = await searchPodcasts(q, sourceIds, sources, { const searchResults =
cacheTtl: CACHE_TTL, scope() === "episode"
}); ? await searchEpisodes(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
: await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
setResults(applySubscribedStatus(searchResults)); setResults(applySubscribedStatus(searchResults));
} catch (e) { } catch (e) {
@@ -187,6 +221,7 @@ export function createSearchStore() {
error, error,
history, history,
selectedSources, selectedSources,
scope,
// Actions // Actions
search, search,
@@ -195,6 +230,7 @@ export function createSearchStore() {
clearHistory, clearHistory,
removeFromHistory, removeFromHistory,
setSelectedSources, setSelectedSources,
setScope,
markSubscribed, markSubscribed,
}; };
} }

View File

@@ -71,6 +71,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"open", "open",
"open-interactive", "open-interactive",
"search", "search",
"search-scope-toggle",
"filter", "filter",
"sort", "sort",
"toggle-hidden", "toggle-hidden",

View File

@@ -60,6 +60,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
help: ["~", "f1"], help: ["~", "f1"],
// list ops // list ops
search: ["s"], search: ["s"],
"search-scope-toggle": ["tab"],
filter: ["f"], filter: ["f"],
sort: [","], sort: [","],
"toggle-hidden": ["."], "toggle-hidden": ["."],