From 3f61303756b13dea18655347c3e79b35a3c3ad21 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 31 Jul 2026 17:54:22 -0400 Subject: [PATCH] refactor(yazi): render depth-stack list tabs through YaziPaneRow primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert Discover, Feed, MyShows, and Settings pages from bespoke 3-column flexbox JSX to render entirely via the shared parent|current| preview primitive. Each page now supplies parent/current/preview content accessors plus labels; the pane row handles layout, borders, focus styling, and the muted placeholder for the blank parent slot at depth 0. Replace Solid's children() helper in YaziPaneRow with a local normalizeContent that hands the raw accessor to a reactive expression. children() flattens to a stable resolved-nodes array and does not re-resolve on truthy<->truthy root swaps (e.g. depth switching a pane's root between a list fragment and an editor), which would freeze the previous subtree. The insert effect from the reactive expression disposes and remounts on element-identity change instead. Add tests/yazi-pages-depth.test.ts covering the nav-store depth-stack contract the pages depend on (push/pop stack growth, parent-slot data model across root→child→grandchild→pop transitions). --- src/components/YaziPaneRow.tsx | 49 ++- src/pages/Discover/DiscoverPage.tsx | 483 +++++++++++------------- src/pages/Feed/FeedPage.tsx | 561 +++++++++++++--------------- src/pages/MyShows/MyShowsPage.tsx | 503 +++++++++++-------------- src/pages/Settings/SettingsPage.tsx | 242 +++++------- tests/yazi-pages-depth.test.ts | 105 ++++++ 6 files changed, 944 insertions(+), 999 deletions(-) create mode 100644 tests/yazi-pages-depth.test.ts diff --git a/src/components/YaziPaneRow.tsx b/src/components/YaziPaneRow.tsx index 8f33d0f..9f7c752 100644 --- a/src/components/YaziPaneRow.tsx +++ b/src/components/YaziPaneRow.tsx @@ -31,7 +31,7 @@ * /> */ -import { children as solidChildren, createMemo, Show } from "solid-js"; +import { createMemo } from "solid-js"; import type { JSX } from "solid-js"; import type { RGBA } from "@opentui/core"; import { useTheme } from "@/context/ThemeContext"; @@ -64,6 +64,24 @@ function resolveLabel(v: PaneLabel | undefined): string { return typeof v === "function" ? v() : v; } +/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor. + * We deliberately do NOT use Solid's `children()` helper here: that helper + * flattens accessor children into a stable resolved-nodes array and is the + * wrong tool for content whose ROOT swaps at runtime (e.g. the current pane + * switching between a depth-1 list fragment and a depth-2 editor — both + * truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy + * root swap, freezing the previous subtree in place. Instead we hand the + * raw accessor to a reactive `{ expr ?? }` expression below, + * which Solid compiles into a tracked `insert` effect that disposes the old + * subtree and mounts the new whenever the accessor returns a different + * element identity. */ +function normalizeContent( + v: PaneContent | undefined, +): () => JSX.Element | undefined { + if (v == null) return () => undefined; + return typeof v === "function" ? (v as () => JSX.Element) : () => v; +} + function Placeholder(props: { color: () => RGBA }) { return ( @@ -102,12 +120,20 @@ function YaziPane(props: { borderColor={borderColor()} backgroundColor={theme.background} > - } - > - {props.content()} - + {/* + * Render the content accessor directly via a reactive expression. + * `{ accessor() ?? }` compiles to a Solid `insert` + * effect that re-runs whenever the accessor's tracked signals + * change (e.g. `depth()` swapping the root from a list fragment to + * an editor). Solid disposes the previously-rendered subtree and + * mounts the new element identity. `null`/`undefined` falls back + * to the muted placeholder so the parent pane keeps its 1/7 slot + * visibly blank at depth 0. This is the correct tool for root + * swapping — unlike Solid's `children()` / ``-children, + * which only react to truthiness flips, not truthy<@->truthy root + * identity changes. + */} + {props.content() ?? } ); @@ -123,10 +149,11 @@ export function YaziPaneRow(props: YaziPaneRowProps) { return typeof f === "function" ? f() : f ?? true; }); - // Normalize static JSX and accessor children into reactive accessors. - const parentContent = solidChildren(() => props.parent); - const currentContent = solidChildren(() => props.current); - const previewContent = solidChildren(() => props.preview); + // Normalize static JSX and accessor children into reactive accessors + // (see normalizeContent for why we avoid Solid's `children()` helper). + const parentContent = normalizeContent(props.parent); + const currentContent = normalizeContent(props.current); + const previewContent = normalizeContent(props.preview); const parentLabel = createMemo(() => resolveLabel(props.parentLabel)); const currentLabel = createMemo(() => resolveLabel(props.currentLabel)); diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index 2447b53..b49788a 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -1,15 +1,18 @@ /** * DiscoverPage — yazi depth-stack view of discoverable podcasts. * - * depth 0 (current) — category list. Left pane empty at root. - * depth 1 (current) — podcast results for the drilled category. - * right (preview) — detail of the hovered item (category summary, or + * depth 0 (current) — category list. Parent pane shows the muted + * placeholder (1/7 slot kept). + * depth 1 (current) — podcast results for the drilled category. Parent + * pane = the categories list. + * preview — detail of the hovered item (category summary, or * podcast detail + subscribe action). * - * `l`/Enter drills in (category → results) or subscribes (on a podcast); - * `h` pops back (or yields to the sidebar at depth 0). j/k move within the - * current column. Moving through categories at depth 0 updates the store's - * selected category so the preview follows. + * Renders entirely through ``; no bespoke 3-column flexbox JSX + * remains. `l`/Enter drills in (category → results) or subscribes (on a + * podcast); `h` pops a depth (noop at 0). j/k move only within the current + * column. Moving through categories at depth 0 updates the store's selected + * category so the preview follows. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; @@ -25,7 +28,7 @@ import { } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; -import { PANE_RATIO } from "@/utils/navigation"; +import { YaziPaneRow } from "@/components/YaziPaneRow"; export const DiscoverPaneCount = 1; @@ -132,269 +135,219 @@ function DiscoverPage() { }); // ── render ────────────────────────────────────────────────────────────────── - const isActive = nav.activePane() === DEPTH_CENTER_PANE; - const border = (active: boolean) => (active ? theme.accent : theme.border); + const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const focusBg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined; const focusFg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.surface : theme.text; - const headerBg = theme.background; + + const currentLabel = () => + depth() === 0 + ? "Categories" + : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`; + + // ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── + // ── parent pane: previous-depth list (muted/blank at depth 0) ────────── + // Stable gate (not a ternary root swap) so the parent list + // mounts/unmounts cleanly on depth change. + const parentContent = () => ( + = 1}> + + {(cat, index) => ( + + + {index() === nav.depthFocus(0) ? "❯" : " "} + + + {cat.name} + + + )} + + + ); + + // ── current pane ─────────────────────────────────────────────────────────── + const currentContent = () => ( + <> + {/* depth 0: categories */} + + + {(cat, index) => { + const lf = focusedCatIdx(); + const selected = () => cat.id === discoverStore.selectedCategory(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + discoverStore.setSelectedCategory(cat.id); + }} + > + + {index() === lf ? "❯" : " "} + + {cat.name} + + + * + + + + ); + }} + + + {/* depth ≥1: results */} + = 1}> + 0} + fallback={ + + No podcasts found. :refresh + + } + > + + {(podcast, index) => { + const lf = focusedPodIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + > + + + {index() === lf ? "❯" : " "} + + + {podcast.title} + + + + [+] + + + + + + by {podcast.author} + + + + ); + }} + + + + + ); + + // ── preview pane ─────────────────────────────────────────────────────────── + const previewContent = () => + depth() === 0 ? ( + // depth 0 preview: hovered category + + No category focused + + } + > + {(cat) => ( + + + {cat().name} + + + {(cat() as any).description ?? + `Browse top podcasts in ${cat().name}.`} + + + enter/l: open · h: back + + )} + + ) : ( + // depth ≥1 preview: hovered podcast + subscribe + + No podcast focused + + } + > + {(pod) => ( + + + {pod().title} + + + by {pod().author} + + + ✓ Subscribed + + + [+] Subscribe (enter) + + + + {pod().description?.slice(0, 400) ?? + "No description available."} + {(pod().description?.length ?? 0) > 400 ? "…" : ""} + + 0}> + + + {(cat) => [{cat}]} + + + + + Feed: {pod().feedUrl} + + Updated: {formatDate(pod().lastUpdated)} + + enter: subscribe · h: back · r: refresh + + )} + + ); return ( - - {/* ── left: previous depth (empty at root) ──────────────────────────── */} - - = 1}> - - Categories - - - - {(cat, index) => ( - - - {index() === nav.depthFocus(0) ? "❯" : " "} - - - {cat.name} - - - )} - - - - - - {/* ── center: current depth ─────────────────────────────────────────── */} - - - - {depth() === 0 - ? "Categories" - : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`} - - - - {/* depth 0: categories */} - - - {(cat, index) => { - const lf = focusedCatIdx(); - const selected = () => - cat.id === discoverStore.selectedCategory(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - discoverStore.setSelectedCategory(cat.id); - }} - > - - {index() === lf ? "❯" : " "} - - {cat.name} - - - * - - - - ); - }} - - - - {/* depth ≥1: results */} - = 1}> - 0} - fallback={ - - No podcasts found. :refresh - - } - > - - {(podcast, index) => { - const lf = focusedPodIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); - }} - > - - - {index() === lf ? "❯" : " "} - - - {podcast.title} - - - - [+] - - - - - - by {podcast.author} - - - - ); - }} - - - - - - - {/* ── right: preview ────────────────────────────────────────────────── */} - - - Preview - - - {/* depth 0 preview: hovered category */} - - - No category focused - - } - > - {(cat) => ( - - - {cat().name} - - - {(cat() as any).description ?? - `Browse top podcasts in ${cat().name}.`} - - - enter/l: open · h: back - - )} - - - - {/* depth ≥1 preview: hovered podcast + subscribe */} - = 1}> - - No podcast focused - - } - > - {(pod) => ( - - - {pod().title} - - - by {pod().author} - - - ✓ Subscribed - - - [+] Subscribe (enter) - - - - {pod().description?.slice(0, 400) ?? - "No description available."} - {(pod().description?.length ?? 0) > 400 ? "…" : ""} - - 0}> - - - {(cat) => [{cat}]} - - - - - Feed: {pod().feedUrl} - - - Updated: {formatDate(pod().lastUpdated)} - - - - enter: subscribe · h: back · r: refresh - - - )} - - - - - + (depth() >= 1 ? "Categories" : "Up")} + currentLabel={currentLabel} + previewLabel="Detail" + focused={isActive} + /> ); } diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 4ece6bc..a723fc8 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -2,14 +2,17 @@ * FeedPage — yazi depth-stack view of episodes across subscribed shows. * * depth 0 (current) — subscribed feeds list (containers); index 0 is a - * virtual "All Feeds". Left pane empty at root. + * virtual "All Feeds". Parent pane shows the muted + * placeholder (1/7 slot kept). * depth 1 (current) — flat episodes list for the drilled feed (reverse - * chronological). Left pane = the feeds list (prev). - * right (preview) — detail of the hovered item in the current column. + * chronological). Parent pane = the feeds list (prev). + * preview — detail of the hovered item in the current column. * - * `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the - * sidebar at depth 0). j/k move within the current column. The Shell router - * drives everything over nav.action; this page only handles list/preview data. + * Renders entirely through `` (the shared parent|current|preview + * primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in + * (push); `h` pops a depth (noop at 0). j/k move only within the current + * column. The Shell router drives everything over `nav.action`; this page + * only handles list/preview data. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; @@ -32,7 +35,7 @@ import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; -import { PANE_RATIO } from "@/utils/navigation"; +import { YaziPaneRow } from "@/components/YaziPaneRow"; export const FeedPaneCount = 1; @@ -200,8 +203,8 @@ function FeedPage() { }); // ── render ────────────────────────────────────────────────────────────────── - const isActive = nav.activePane() === DEPTH_CENTER_PANE; - const border = (active: boolean) => (active ? theme.accent : theme.border); + const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; + // Row highlight within a list. `active=true` only for the current pane. const focusBg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.primary @@ -210,7 +213,6 @@ function FeedPage() { : undefined; const focusFg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.surface : theme.text; - const headerBg = theme.background; const feedLabel = (item: FeedListItem) => item.kind === "all" @@ -221,309 +223,264 @@ function FeedPage() { ? feedStore.getAllEpisodesChronological().length : item.feed.episodes.length; - return ( - - {/* ── left: previous depth (empty at root) ──────────────────────────── */} - - = 1}> - - - Feeds · {feedList().length - 1} + const currentLabel = () => + depth() === 0 + ? `Feeds · ${feedList().length - 1}` + : `${(() => { + const fi = focusedFeedItem(); + return fi?.kind === "feed" + ? fi.feed.customName || fi.feed.podcast.title + : "All Episodes"; + })()} · ${episodes().length}`; + + // ── parent pane: previous-depth list (muted/blank at depth 0) ────────── + // Wrap in a stable (the sibling-Show pattern) so the parent list + // mounts/unmounts cleanly on depth change instead of swapping roots. + const parentContent = () => ( + = 1}> + + {(item, index) => { + const lf = nav.depthFocus(0); + return ( + + + {index() === lf ? "❯" : " "} + + {feedLabel(item)} + ({feedCount(item)}) + + ); + }} + + + ); + + // ── current pane: the current-depth list (the only focusable column) ────── + const currentContent = () => ( + <> + {/* depth 0: feeds — stable sibling so the swap disposes cleanly */} + + 1} + fallback={ + + + No feeds. Subscribe from Discover/Search. - - - {(item, index) => ( - - - {index() === nav.depthFocus(0) ? "❯" : " "} + } + > + + {(item, index) => { + const fi = focusedFeedIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + > + + {index() === fi ? "❯" : " "} + + + {feedLabel(item)} + + + ({feedCount(item)}) + + + ); + }} + + + + = 1}> + {/* depth ≥1: episodes */} + 0} + fallback={ + + No episodes. :refresh + + } + > + + {(item, index) => { + const fi = focusedEpIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + > + + + {index() === fi ? "❯" : " "} - - {feedLabel(item)} + + {item.episode.episodeNumber + ? `#${item.episode.episodeNumber} ` + : ""} + {item.episode.title} - ({feedCount(item)}) - )} - - + + + {formatDate(item.episode.pubDate)} + + + {formatDuration(item.episode.duration)} + + + {item.feed.customName || item.feed.podcast.title} + + + + + + + {downloadLabel(item.episode.id)} + + + + + ); + }} + + + + + - + + + + ); - {/* ── center: current depth ─────────────────────────────────────────── */} - + depth() === 0 ? ( + // depth 0 preview: hovered feed + + No feed focused + + } > - - - {depth() === 0 - ? `Feeds · ${feedList().length - 1}` - : `${(() => { - const fi = focusedFeedItem(); - return fi?.kind === "feed" - ? fi.feed.customName || fi.feed.podcast.title - : "All Episodes"; - })()} · ${episodes().length}`} - - - - {/* depth 0: feeds */} - - 1} - fallback={ - - - No feeds. Subscribe from Discover/Search. + {(item) => { + const it = item(); + return ( + + + {feedLabel(it)} + + + {it.kind === "feed" + ? `by ${it.feed.podcast.author ?? "unknown"}` + : ""} + + + {it.kind === "all" + ? `${feedCount(it)} episodes across all feeds` + : `${feedCount(it)} episodes`} + + + {it.kind === "feed" + ? (it.feed.podcast.description?.slice(0, 400) ?? + "No description.") + : "Drill in to see episodes across every feed."} + + + enter/l: open · h: back + + ); + }} + + ) : ( + // depth ≥1 preview: hovered episode + + No episode focused + + } + > + {(item) => { + const it = item(); + return ( + + + + {it.episode.episodeNumber + ? `#${it.episode.episodeNumber} ` + : ""} + {it.episode.title} + + + + + {formatDate(it.episode.pubDate)} + + + {formatDuration(it.episode.duration)} + + + + {downloadLabel(it.episode.id)} - - } - > - - {(item, index) => { - const fi = focusedFeedIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - {index() === fi ? "❯" : " "} - - - {feedLabel(item)} - - - ({feedCount(item)}) - - - ); - }} - - - - - {/* depth ≥1: episodes */} - = 1}> - 0} - fallback={ - - No episodes. :refresh - - } - > - - {(item, index) => { - const fi = focusedEpIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); - }} - > - - - {index() === fi ? "❯" : " "} - - - {item.episode.episodeNumber - ? `#${item.episode.episodeNumber} ` - : ""} - {item.episode.title} - - - - - {formatDate(item.episode.pubDate)} - - - {formatDuration(item.episode.duration)} - - - {item.feed.customName || item.feed.podcast.title} - - - - - - - {downloadLabel(item.episode.id)} - - - - - ); - }} - - - - - + + + + {it.feed.customName || it.feed.podcast.title} + + + by {it.feed.podcast.author} - - - - + + + {it.episode.description?.slice(0, 400) ?? + "No description available."} + {(it.episode.description?.length ?? 0) > 400 ? "…" : ""} + + + enter: play · space: select · h: back + + ); + }} + + ); - {/* ── right: preview of hovered item ───────────────────────────────── */} - - - Preview - - - {/* depth 0 preview: hovered feed */} - - - No feed focused - - } - > - {(item) => { - const it = item(); - return ( - - - {feedLabel(it)} - - - {it.kind === "feed" - ? `by ${it.feed.podcast.author ?? "unknown"}` - : ""} - - - {it.kind === "all" - ? `${feedCount(it)} episodes across all feeds` - : `${feedCount(it)} episodes`} - - - {it.kind === "feed" - ? (it.feed.podcast.description?.slice(0, 400) ?? - "No description.") - : "Drill in to see episodes across every feed."} - - - enter/l: open · h: back - - ); - }} - - - - {/* depth ≥1 preview: hovered episode */} - = 1}> - - No episode focused - - } - > - {(item) => { - const it = item(); - return ( - - - - {it.episode.episodeNumber - ? `#${it.episode.episodeNumber} ` - : ""} - {it.episode.title} - - - - - {formatDate(it.episode.pubDate)} - - - {formatDuration(it.episode.duration)} - - - - {downloadLabel(it.episode.id)} - - - - - {it.feed.customName || it.feed.podcast.title} - - - by {it.feed.podcast.author} - - - - {it.episode.description?.slice(0, 400) ?? - "No description available."} - {(it.episode.description?.length ?? 0) > 400 ? "…" : ""} - - - - enter: play · space: select · h: back - - - ); - }} - - - - - + return ( + (depth() >= 1 ? "Feeds" : "Up")} + currentLabel={currentLabel} + previewLabel="Detail" + focused={isActive} + /> ); } diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 6a6e7c8..775e72c 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -1,12 +1,14 @@ /** * MyShowsPage — yazi depth-stack view of subscribed shows. * - * depth 0 (current) — subscribed shows. Left pane empty at root. - * depth 1 (current) — episodes of the drilled show. Left pane = shows (prev). - * right (preview) — detail of the hovered item in the current column. + * depth 0 (current) — subscribed shows. Parent pane shows the muted + * placeholder (1/7 slot kept). + * depth 1 (current) — episodes of the drilled show. Parent pane = shows. + * preview — detail of the hovered item in the current column. * - * `l`/Enter drills in (show → episodes); `h` pops back (or yields to the - * sidebar at depth 0). j/k move within the current column. + * Renders entirely through ``; no bespoke 3-column flexbox JSX + * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at + * 0). j/k move only within the current column. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; @@ -29,7 +31,7 @@ import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; -import { PANE_RATIO } from "@/utils/navigation"; +import { YaziPaneRow } from "@/components/YaziPaneRow"; export const MyShowsPaneCount = 1; @@ -181,289 +183,240 @@ export function MyShowsPage() { }); // ── render ────────────────────────────────────────────────────────────────── - const isActive = nav.activePane() === DEPTH_CENTER_PANE; - const border = (active: boolean) => (active ? theme.accent : theme.border); + const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const focusBg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined; const focusFg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.surface : theme.text; - const headerBg = theme.background; const showTitle = (f: Feed) => f.customName || f.podcast.title; - return ( - - {/* ── left: previous depth (empty at root) ──────────────────────────── */} - - = 1}> - - Shows ({shows().length}) + const currentLabel = () => + depth() === 0 + ? `Shows (${shows().length})` + : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; + + // ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── + // ── parent pane: previous-depth list (muted/blank at depth 0) ────────── + // Stable gate (not a ternary root swap) so the parent list + // mounts/unmounts cleanly on depth change. + const parentContent = () => ( + = 1}> + + {(feed, index) => { + const lf = nav.depthFocus(0); + return ( + + + {index() === lf ? "❯" : " "} + + {showTitle(feed)} + ({feed.episodes.length}) + + ); + }} + + + ); + + // ── current pane: the current-depth list ─────────────────────────────────── + const currentContent = () => ( + <> + {/* depth 0: shows — stable sibling so the swap disposes cleanly */} + + 0} + fallback={ + + + No shows. Subscribe from Discover/Search. + - - - {(feed, index) => { - const lf = nav.depthFocus(0); - return ( - - - {index() === lf ? "❯" : " "} + } + > + + {(feed, index) => { + const lf = focusedShowIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + > + + {index() === lf ? "❯" : " "} + + + {showTitle(feed)} + + + ({feed.episodes.length}) + + + ); + }} + + + + {/* depth ≥1: episodes */} + = 1}> + 0} + fallback={ + + No episodes. :refresh + + } + > + + {(ep, index) => { + const lf = focusedEpIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + > + + + {index() === lf ? "❯" : " "} + + + {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} + {ep.title} + + + + + {formatDate(ep.pubDate)} + + + {formatDuration(ep.duration)} + + + + + + + {downloadLabel(ep.id)} - - {showTitle(feed)} - - ({feed.episodes.length}) - - ); - }} - - + + + + ); + }} + + + + + - + + + + ); - {/* ── center: current depth ─────────────────────────────────────────── */} - + depth() === 0 ? ( + // depth 0 preview: hovered show + + No show focused + + } > - - - {depth() === 0 - ? `Shows (${shows().length})` - : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`} - - - - {/* depth 0: shows */} - - 0} - fallback={ - - - No shows. Subscribe from Discover/Search. - - - } - > - - {(feed, index) => { - const lf = focusedShowIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - {index() === lf ? "❯" : " "} - - - {showTitle(feed)} - - - ({feed.episodes.length}) - - - ); - }} - + {(show) => ( + + + {showTitle(show())} + + + by {show().podcast.author} - - - {/* depth ≥1: episodes */} - = 1}> - 0} - fallback={ - - No episodes. :refresh - - } - > - - {(ep, index) => { - const lf = focusedEpIdx(); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); - }} - > - - - {index() === lf ? "❯" : " "} - - - {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} - {ep.title} - - - - - {formatDate(ep.pubDate)} - - - {formatDuration(ep.duration)} - - - - - - - {downloadLabel(ep.id)} - - - - - ); - }} - - - - - + + {show().episodes.length} episodes + + + {show().podcast.description?.slice(0, 400) ?? + "No description."} + + + enter/l: open · h: back + + )} + + ) : ( + // depth ≥1 preview: hovered episode + + No episode focused + + } + > + {(ep) => ( + + + + {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} + {ep().title} + + + + {formatDate(ep().pubDate)} + {formatDuration(ep().duration)} + + + {downloadLabel(ep().id)} + + + + by {selectedShow()!.podcast.author} - - - + + + {ep().description?.slice(0, 400) ?? + "No description available."} + {(ep().description?.length ?? 0) > 400 ? "…" : ""} + + + enter: play · space: select · h: back + + )} + + ); - {/* ── right: preview ────────────────────────────────────────────────── */} - - - Preview - - - {/* depth 0 preview: hovered show */} - - - No show focused - - } - > - {(show) => ( - - - {showTitle(show())} - - - by {show().podcast.author} - - - {show().episodes.length} episodes - - - {show().podcast.description?.slice(0, 400) ?? - "No description."} - - - enter/l: open · h: back - - )} - - - - {/* depth ≥1 preview: hovered episode */} - = 1}> - - No episode focused - - } - > - {(ep) => ( - - - - {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} - {ep().title} - - - - {formatDate(ep().pubDate)} - {formatDuration(ep().duration)} - - - {downloadLabel(ep().id)} - - - - - - by {selectedShow()!.podcast.author} - - - - - {ep().description?.slice(0, 400) ?? - "No description available."} - {(ep().description?.length ?? 0) > 400 ? "…" : ""} - - - - enter: play · space: select · h: back - - - )} - - - - - + return ( + (depth() >= 1 ? "Shows" : "Up")} + currentLabel={currentLabel} + previewLabel="Detail" + focused={isActive} + /> ); } diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 25389ac..8e2ba32 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -5,9 +5,12 @@ * depth 1 — the focused section's items as a navigable list * depth 2 — per-item editor (for editor-kind items) or value adjuster * - * Columns render as yazi's prev | current | preview: - * left = previous depth's list (empty at depth 0) - * right = preview/help text for the hovered item in center + * Renders entirely through `` (parent | current | preview): + * parent = previous depth's list (sections at depth 1, items at depth 2); + * blank placeholder at depth 0 (1/7 slot kept). + * current = the current-depth list (or editor at depth 2); the only + * focusable column. + * preview = help/preview text for the hovered item in current. * * All movement comes from the Shell router over `nav.action` (j/k move, * Enter/l drill, h back). Panels no longer register their own useKeyboard — @@ -24,12 +27,12 @@ import { } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; -import { PANE_RATIO } from "@/utils/navigation"; import type { SettingItem, SettingsSectionDef } from "./types"; import { usePreferencesItems } from "./PreferencesPanel"; import { useVisualizerItems } from "./VisualizerSettings"; import { useSyncItems, closeSyncEditor } from "./SyncPanel"; import { useSourceItems } from "./SourceManager"; +import { YaziPaneRow } from "@/components/YaziPaneRow"; export const SettingsPaneCount = 1; @@ -224,9 +227,7 @@ export function SettingsPage() { onCleanup(() => closeSyncEditor()); // ── render helpers ─────────────────────────────────────────────────────── - const isActive = nav.activePane() === DEPTH_CENTER_PANE; - const border = (active: boolean) => (active ? theme.accent : theme.border); - const headerBg = theme.background; + const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; // preview text for the right column const previewText = createMemo(() => { @@ -245,120 +246,82 @@ export function SettingsPage() { : "No editor."; }); - // ── column content builders ────────────────────────────────────────────── - // left = previous depth (read-only list), or empty at depth 0 - const LeftCol = () => ( - - - - = 1} fallback=" "> - {depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")} - - - + // ── column label ─────────────────────────────────────────────────────────── + const currentLabel = () => { + const d = depth(); + if (d === 0) return "Settings"; + if (d === 1) return sectionForDepth1()?.label ?? "Items"; + return editorItem()?.label ?? "Editor"; + }; + const parentLabel = () => { + const d = depth(); + if (d === 1) return "Sections"; + if (d === 2) return sectionForDepth1()?.label ?? ""; + return "Up"; + }; + + // ── parent pane: previous-depth list (blank at depth 0) ──────────────── + // Sibling blocks per depth (mirrors the preview pane) so Solid + // mounts every branch once and toggles children on depth change — the + // known-good opentui disposal pattern. A ternary returning different + // roots leaves subtree orphaned on swap; the trick is a STABLE fragment + // root whose inner children swap instead. + const parentContent = () => ( + <> - - - {(section, index) => ( - - )} - - + {/* previous depth = sections list (read-only) */} + + {(section, index) => ( + + )} + - - - {(it, index) => ( - - )} - - + {/* previous depth = items list (read-only) */} + + {(it, index) => ( + + )} + - + ); - // center = current depth - const CenterCol = () => ( - - - - - {sectionForDepth1()?.label ?? "Items"} - - } - > - Settings - - - - - - - {(section, index) => ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - /> - )} - - - + // ── current pane: current-depth list (or editor at depth 2) ─────────────── + const currentContent = () => ( + <> + + + {(section, index) => ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + /> + )} + + + + {(it, index) => ( { nav.setActivePane(DEPTH_CENTER_PANE); @@ -372,50 +335,37 @@ export function SettingsPage() { (No items.) + + + + {/* depth 2: editor */} + } + > + {editorItem()!.renderEditor!()} - - } - > - {editorItem()!.renderEditor!()} - - - - + + ); - // right = preview / help - const RightCol = () => ( - - - Preview - - - - - - + // ── preview pane ────────────────────────────────────────────────────────── + const previewContent = () => ( + + ); return ( - - {LeftCol()} - {CenterCol()} - {RightCol()} - + ); } diff --git a/tests/yazi-pages-depth.test.ts b/tests/yazi-pages-depth.test.ts new file mode 100644 index 0000000..84e84c9 --- /dev/null +++ b/tests/yazi-pages-depth.test.ts @@ -0,0 +1,105 @@ +/** + * yazi-pages-depth.test.ts — task 03 page contract tests. + * + * The four depth-stack list tabs (Feed / MyShows / Discover / Settings) all + * render through `` with the parent pane reading the + * previous-depth frame's list (blank placeholder at depth 0). Their `open()` + * action calls `nav.pushDepth(frame)` to drill and the Shell calls + * `nav.popDepth()` on `h`. This file exercises the nav-store contract those + * pages depend on for every depth-tab, asserting the parent-slot data model: + * + * • depth 0 → stack has exactly the root frame (parent pane is blank) + * • drill(l)→ push a child frame; stack length 2, parent = previous list + * • drill(l)→ push again; stack length 3 (Settings sections→items→editor) + * • pop(h) → stack shrinks; parent returns to the previous list + * • pop(h) → back at root; parent is blank again + * + * The visual "blank → list → list → blank" transition is the union of this + * data model (which list each depth renders) with ``'s null + * placeholder (covered by yazi-pane-row.test.tsx). Tested at the store level + * because the page `open()` closures are not exported and the nav store is + * the shared contract all four pages route through. + */ + +import { test, expect } from "bun:test"; +import { createRoot } from "solid-js"; +import { + createNavigation, + DEPTH_CENTER_PANE, +} from "../src/context/navigation-store"; +import { TABS, DEPTH_TABS } from "../src/utils/navigation"; +import type { DepthFrame } from "../src/context/NavigationContext"; + +function withNav(fn: (nav: ReturnType) => void) { + createRoot((dispose) => { + fn(createNavigation()); + dispose(); + }); +} + +/** The depth-tabs that must render via (task 03 conversion). */ +const CONVERTED_TABS = [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS]; + +for (const tab of CONVERTED_TABS) { + const name = TABS[tab]; + + test(`${name}: depth 0 → 1 → 2 push/pop keeps the parent-slot contract`, () => { + withNav((nav) => { + nav.setActiveTab(tab); + expect(nav.isDepthTab()).toBe(true); + + // depth 0: exactly the root frame → parent pane renders blank. + expect(nav.currentDepth()).toBe(0); + expect(nav.depthStack()).toHaveLength(1); + + // drill (l): page open() pushes a child frame — parent becomes + // the previous-depth list. + const child: DepthFrame = { kind: `${name.toLowerCase()}:child`, ctx: "c1", focus: 0 }; + nav.pushDepth(child); + nav.setActivePane(DEPTH_CENTER_PANE); + expect(nav.currentDepth()).toBe(1); + expect(nav.depthStack()).toHaveLength(2); + // the parent (depth 0) frame is still the root; the top is the child. + expect(nav.depthStack()[0]).toBe(nav.depthStack()[0]); + expect(nav.topFrame()).toEqual(child); + + // drill again (l): push a second child — parent shows the first + // child's list (the chain Settings exercises: sections→items→editor). + const grandchild: DepthFrame = { kind: `${name.toLowerCase()}:grand`, ctx: "g1", focus: 0 }; + nav.pushDepth(grandchild); + expect(nav.currentDepth()).toBe(2); + expect(nav.depthStack()).toHaveLength(3); + expect(nav.topFrame()).toEqual(grandchild); + + // pop (h): back to depth 1 — parent frame is the root, top is child. + expect(nav.popDepth()).toBe(true); + expect(nav.currentDepth()).toBe(1); + expect(nav.depthStack()).toHaveLength(2); + expect(nav.topFrame()).toEqual(child); + + // pop (h): back to depth 0 — parent pane is blank again. + expect(nav.popDepth()).toBe(true); + expect(nav.currentDepth()).toBe(0); + expect(nav.depthStack()).toHaveLength(1); + }); + }); + + test(`${name}: pop (h) at depth 0 is a noop (returns false, root kept)`, () => { + withNav((nav) => { + nav.setActiveTab(tab); + expect(nav.currentDepth()).toBe(0); + expect(nav.popDepth()).toBe(false); + expect(nav.currentDepth()).toBe(0); + // the root frame is preserved (parent stays blank, not undefined). + expect(nav.depthStack()).toHaveLength(1); + expect(nav.topFrame()).toBeDefined(); + }); + }); +} + +// ── DEPTH_TABS covers exactly the four converted pages ─────────────────────── +test("DEPTH_TABS is exactly the four converted list tabs", () => { + expect([...DEPTH_TABS].sort()).toEqual( + [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS].sort(), + ); +});