diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index bc23a23..35798ca 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -447,6 +447,7 @@ function helpSections(k: ReturnType) { ["enter", "open"], ["r", "refresh"], ["s", "search"], + [p("search-scope-toggle"), "shows/episodes"], ["f", "filter"], [",", "sort"], [".", "hidden"], diff --git a/src/config/keybinds.jsonc b/src/config/keybinds.jsonc index 4c3c60b..ce15965 100644 --- a/src/config/keybinds.jsonc +++ b/src/config/keybinds.jsonc @@ -59,8 +59,11 @@ "help": ["~", "f1"], // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) - "search": ["s"], - "filter": ["f"], + "search": ["s"], + // 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": [","], "toggle-hidden": ["."], "refresh": ["r"], diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index 21d6c3a..f27dc79 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -63,6 +63,7 @@ export type KeybindActionName = | "quit" | "help" | "search" + | "search-scope-toggle" | "filter" | "sort" | "toggle-hidden" diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 0020a19..31d5da6 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -8,6 +8,10 @@ * query (muted, read-only); preview shows the detail of * 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 * router yields). Escape defocuses the input (handled in Shell) so j/k/h * navigation resumes; `s` (the `search` action) refocuses it. Enter on the @@ -38,12 +42,13 @@ import { } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; 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 { TabListPane } from "@/components/TabPanel"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; +import { useInputFocusNav } from "@/hooks/useInputFocusNav"; export const SearchPaneCount = 1; @@ -69,22 +74,29 @@ function SearchPage() { // router yields keys to the while this is true; Escape (in Shell) // 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 - // (1) is always list-navigation. Drive `inputFocused` straight off - // `depth()` rather than seeding it `true` on mount and patching on change: - // the depth stack persists across tab switches, so re-mounting this page - // at depth 1 (e.g. after searching, leaving, and returning to the tab) - // must NOT leave `inputFocused` stuck on — otherwise the Shell swallows - // j/k (yielding to a non-existent input) and only the scrollbox's native - // scroll responds. + // The input's REAL focus is the source of truth for the flag: + // useInputFocusNav (the same hook the Settings forms use) flips + // `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag + // and the renderable in lockstep. That matters when the user clicks OFF the + // input: opentui's mouse dispatch auto-focuses the clicked target's nearest + // focusable ancestor (a pane scrollbox), blurring the input. The BLURRED + // event drops the flag, so the Shell router immediately resumes j/k/h + // 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), - // so gate the sync 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. + // The depth stack still SEEDS the flag on transitions, since the query + // depth defaults to typing: re-entering depth 0 (h back from results, or a + // fresh mount) focuses the input; mounting at depth 1 (returning to the + // tab after a search) stays list-navigation — a stuck-on flag there would + // 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)); onCleanup(() => nav.setInputFocused(false)); + const focusNavRef = useInputFocusNav(); const isQueryDepth = createMemo(() => depth() === 0); createEffect(() => { nav.setInputFocused(isQueryDepth()); @@ -113,7 +125,10 @@ function SearchPage() { // Register a visual-mode resolver for the results list (depth 1). onMount(() => { 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 ───────────────────────────────────────────────────────────────── @@ -138,6 +153,19 @@ function SearchPage() { 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) => { // Actually add the feed to the feed store, then mark the result // subscribed. addFeed returns null when a feedless directory stub @@ -171,13 +199,17 @@ function SearchPage() { "toggle-select": () => { if (depth() === 1) { const r = focusedResult(); - if (r) nav.toggleSelected(r.podcast.id); + if (r) + nav.toggleSelected( + r.kind === "episode" ? r.episode.id : r.podcast.id, + ); } }, search: () => { // `s` refocuses the query input (typing mode) when on the query depth. if (depth() === 0) nav.setInputFocused(true); }, + "search-scope-toggle": () => toggleScope(), refresh: () => { const q = submittedQuery() || inputValue().trim(); if (q) searchStore.search(q).catch(() => {}); @@ -249,6 +281,9 @@ function SearchPage() { Query {submittedQuery() || "(empty)"} + + Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"} + h: back to query @@ -264,10 +299,33 @@ function SearchPage() { Query: 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()} width={28} textColor={theme.text} @@ -275,6 +333,44 @@ function SearchPage() { cursorColor={theme.accent} /> + + Scope: + applyScope("podcast")} + > + + {" "} + Shows{" "} + + + applyScope("episode")} + > + + {" "} + Episodes{" "} + + + tab to toggle + @@ -347,7 +443,7 @@ function SearchPage() { {inputActive() ? "Enter to search · Esc to defocus" - : "j/k recents · s to type · h back"} + : "j/k recents · s to type · tab scope · h back"} @@ -363,7 +459,9 @@ function SearchPage() { {searchStore.query() ? "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"} } > @@ -393,7 +491,9 @@ function SearchPage() { {index() === fi() ? marker() : " "} - {result.podcast.title} + {result.kind === "episode" + ? result.episode.title + : result.podcast.title} - + {result.kind === "episode" ? ( - by {result.podcast.author} + {result.podcast.title} ·{" "} + {formatDate(result.episode.pubDate)} - + ) : ( + + + by {result.podcast.author} + + + )} ); }} @@ -428,6 +538,10 @@ function SearchPage() { Search Type a query, press Enter to search. + + Tab toggles Shows ↔ Episodes (episode search finds guests + and topics). + Esc defocuses the input; h goes back. Recent · {recents().length} @@ -444,56 +558,102 @@ function SearchPage() { } > - {(result) => ( - - - {result().podcast.title} - - - by {result().podcast.author} - - - - {result().podcast.description!.slice(0, 400)} - {(result().podcast.description?.length ?? 0) > 400 ? "…" : ""} - - - 0}> - - - {(cat) => [{cat}]} - + {(result) => { + const r = result(); + if (r.kind === "episode") { + return ( + + + {r.episode.title} + + {r.podcast.title} + + by {r.podcast.author} + + + + {r.episode.description!.slice(0, 400)} + {(r.episode.description?.length ?? 0) > 400 ? "…" : ""} + + + + Published: {formatDate(r.episode.pubDate)} + + 0}> + + + {(cat) => [{cat}]} + + + + + Source: {r.sourceName} + + + + [+] Subscribe (enter) + + + Already subscribed + + + + enter: subscribe to show · h: back to query + - - - Feed:{" "} - {result().podcast.feedUrl || - "not listed by source — resolves on subscribe"} - - - Updated: {formatDate(result().podcast.lastUpdated)} - - - Source: {result().sourceName} - - - - [+] Subscribe (enter) - - - Already subscribed - - - enter: subscribe · h: back to query - - )} + ); + } + return ( + + + {r.podcast.title} + + + by {r.podcast.author} + + + + {r.podcast.description!.slice(0, 400)} + {(r.podcast.description?.length ?? 0) > 400 ? "…" : ""} + + + 0}> + + + {(cat) => [{cat}]} + + + + + Feed:{" "} + {r.podcast.feedUrl || + "not listed by source — resolves on subscribe"} + + + Updated: {formatDate(r.podcast.lastUpdated)} + + + Source: {r.sourceName} + + + + [+] Subscribe (enter) + + + Already subscribed + + + enter: subscribe · h: back to query + + ); + }} ); const currentLabel = () => depth() === 0 ? `Search · ${recents().length} recent` - : `Results · ${results().length}`; + : `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`; return ( (null); const [history, setHistory] = createSignal(loadHistory()); const [selectedSources, setSelectedSources] = createSignal([]); + const [scope, setScopeState] = createSignal(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 feeds = feedStore.feeds(); @@ -110,9 +139,14 @@ export function createSearchStore() { return; } - const searchResults = await searchPodcasts(q, sourceIds, sources, { - cacheTtl: CACHE_TTL, - }); + const searchResults = + scope() === "episode" + ? await searchEpisodes(q, sourceIds, sources, { + cacheTtl: CACHE_TTL, + }) + : await searchPodcasts(q, sourceIds, sources, { + cacheTtl: CACHE_TTL, + }); setResults(applySubscribedStatus(searchResults)); } catch (e) { @@ -187,6 +221,7 @@ export function createSearchStore() { error, history, selectedSources, + scope, // Actions search, @@ -195,6 +230,7 @@ export function createSearchStore() { clearHistory, removeFromHistory, setSelectedSources, + setScope, markSubscribed, }; } diff --git a/src/utils/dispatch.ts b/src/utils/dispatch.ts index 7c4160b..586fbb1 100644 --- a/src/utils/dispatch.ts +++ b/src/utils/dispatch.ts @@ -71,6 +71,7 @@ export const PAGE_ACTIONS: ReadonlySet = "open", "open-interactive", "search", + "search-scope-toggle", "filter", "sort", "toggle-hidden", diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 8f20f08..113c8c2 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -60,6 +60,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = { help: ["~", "f1"], // list ops search: ["s"], + "search-scope-toggle": ["tab"], filter: ["f"], sort: [","], "toggle-hidden": ["."],