/** * SearchPage — yazi depth-stack view of podcast search. * * depth 0 (current) — query input row + recent-searches list (navigable * with j/k when the input is defocused). Parent pane * shows the tab list (muted); preview shows a hint. * depth 1 (current) — search results list. Parent pane shows the submitted * query (muted, read-only); preview shows the detail of * the focused result. * * 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 * input (or on a focused recent at depth 0) submits the query and pushes to * depth 1 (results). `h` pops: results→query, query→tab root. */ import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup, } from "solid-js"; import { useSearchStore } from "@/stores/search"; import { useFeedStore } from "@/stores/feed"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation, NavMode, DEPTH_CENTER_PANE, type PaneId, type DepthFrame, } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { SearchResult } from "@/types/source"; import { YaziPaneRow } from "@/components/YaziPaneRow"; import { TabListPane } from "@/components/TabPanel"; export const SearchPaneCount = 1; function SearchPage() { const searchStore = useSearchStore(); const feedStore = useFeedStore(); const [inputValue, setInputValue] = createSignal(""); const { theme } = useTheme(); const muted = () => theme.muted || theme.text; const nav = useNavigation(); const stack = nav.depthStack; const depth = nav.currentDepth; const focus = (d: number = depth()) => nav.depthFocus(d); // depth 1's ctx carries the submitted query string. const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query(); // ── input focusing ──────────────────────────────────────────────────────── // `inputFocused` is true while the query input is being typed in. The Shell // 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 effect only re-runs on a depth transition, so Escape (defocus) and // `s` (refocus) at the same depth are not clobbered. onMount(() => nav.setInputFocused(depth() === 0)); onCleanup(() => nav.setInputFocused(false)); createEffect(() => { nav.setInputFocused(depth() === 0); }); // ── results (depth 1) ───────────────────────────────────────────────────── const results = () => searchStore.results(); const focusedResultIdx = () => results().length === 0 ? 0 : Math.min(focus(1), results().length - 1); const focusedResult = createMemo(() => { const list = results(); if (list.length === 0) return undefined; return list[focusedResultIdx()]; }); // ── recents (depth 0) ──────────────────────────────────────────────────── const recents = () => searchStore.history(); const curLen = () => (depth() === 0 ? recents().length : results().length); const ensureFocus = () => { if (depth() === 1 && results().length > 0 && focus(1) >= results().length) nav.setDepthFocus(results().length - 1, 1); }; onMount(ensureFocus); // 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); }); // ── helpers ───────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const runSearch = (query: string) => { const q = query.trim(); if (!q) return; searchStore.search(q).catch(() => {}); nav.pushDepth({ kind: "search:results", ctx: q, focus: 0, } as DepthFrame); nav.setActivePane(DEPTH_CENTER_PANE); }; const handleSubmit = () => runSearch(inputValue()); const selectRecent = (query: string) => { setInputValue(query); runSearch(query); }; const handleSubscribe = (result: SearchResult) => { // Actually add the feed to the feed store, then mark the result subscribed feedStore.addFeed(result.podcast, result.sourceId).catch(() => {}); searchStore.markSubscribed(result.podcast.id); }; // ── nav.action handler ────────────────────────────────────────────────────── const PAGE_ACTIONS: Partial void>> = { "move-down": () => step(1), "move-up": () => step(-1), "jump-down": () => step(5), "jump-up": () => step(-5), "page-down": () => step(10), "page-up": () => step(-10), "goto-top": () => nav.gotoIndex(0, curLen()), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), open: () => open(), "toggle-select": () => { if (depth() === 1) { const r = focusedResult(); if (r) nav.toggleSelected(r.podcast.id); } }, search: () => { // `s` refocuses the query input (typing mode) when on the query depth. if (depth() === 0) nav.setInputFocused(true); }, refresh: () => { const q = submittedQuery() || inputValue().trim(); if (q) searchStore.search(q).catch(() => {}); }, }; function step(delta: number) { nav.move(delta, curLen()); } function open() { if (depth() === 0) { // Enter/l on a focused recent search → submit it and drill to results. const list = recents(); const idx = Math.min(focus(0), list.length - 1); const q = list[idx]; if (q) selectRecent(q); return; } if (depth() === 1) { const r = focusedResult(); if (r) handleSubscribe(r); } } const onAction = (data: { action: KeybindActionName; pane: PaneId; mode: NavMode; }) => { if (data.pane !== DEPTH_CENTER_PANE) return; if (nav.activePane() !== DEPTH_CENTER_PANE) return; ensureFocus(); PAGE_ACTIONS[data.action]?.(); }; onMount(() => { on("nav.action", onAction); onCleanup(() => off("nav.action", onAction)); }); // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const inputActive = () => nav.inputFocused() && depth() === 0; const focusBg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.primary : i === listFocus ? theme.border : undefined; const focusFg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.surface : theme.text; // ── parent pane: previous-depth content (tab list at depth 0) ────────────── const parentContent = () => ( = 1} fallback={}> Query {submittedQuery() || "(empty)"} h: back to query ); // ── current pane ──────────────────────────────────────────────────────────── const currentContent = () => ( <> {/* query input row + recent searches */} Query: handleSubmit()} placeholder="Enter podcast name..." focused={inputActive()} width={28} /> Searching... {searchStore.error()} Recent 0} fallback={ {inputActive() ? "Enter to search" : "s to type · Enter to search"} } > {(query, index) => { const lf = () => focus(0); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); }} > {index() === lf() ? "❯" : " "} {query} ); }} {inputActive() ? "Enter to search · Esc to defocus" : "j/k recents · s to type · h back"} = 1}> {/* results list */} 0} fallback={ {searchStore.query() ? "No results found" : "Enter a search term to find podcasts"} } > {(result, index) => { const fi = () => focusedResultIdx(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} > {index() === fi() ? "❯" : " "} {result.podcast.title} [+] by {result.podcast.author} ); }} ); // ── preview pane ──────────────────────────────────────────────────────────── const previewContent = () => depth() === 0 ? ( Search Type a query, press Enter to search. Esc defocuses the input; h goes back. Recent · {recents().length} {(q) => ‣ {q}} ) : ( No result focused } > {(result) => ( {result().podcast.title} by {result().podcast.author} {result().podcast.description!.slice(0, 400) ?? "No description available."} {(result().podcast.description?.length ?? 0) > 400 ? "…" : ""} 0}> {(cat) => [{cat}]} Feed: {result().podcast.feedUrl} Updated: {formatDate(result().podcast.lastUpdated)} Source: {result().sourceName} [+] Subscribe (enter) Already subscribed enter: subscribe · h: back to query )} ); const currentLabel = () => depth() === 0 ? `Search · ${recents().length} recent` : `Results · ${results().length}`; return ( (depth() >= 1 ? "Query" : "Up")} currentLabel={currentLabel} previewLabel="Detail" focused={isActive} /> ); } export { SearchPage };