refactor(yazi): render depth-stack list tabs through YaziPaneRow primitive

Convert Discover, Feed, MyShows, and Settings pages from bespoke 3-column
flexbox JSX to render entirely via the shared <YaziPaneRow> 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).
This commit is contained in:
2026-07-31 17:54:22 -04:00
parent 139a258987
commit 3f61303756
6 changed files with 944 additions and 999 deletions

View File

@@ -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 { JSX } from "solid-js";
import type { RGBA } from "@opentui/core"; import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
@@ -64,6 +64,24 @@ function resolveLabel(v: PaneLabel | undefined): string {
return typeof v === "function" ? v() : v; 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 ?? <Placeholder/> }` 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 }) { function Placeholder(props: { color: () => RGBA }) {
return ( return (
<box padding={1}> <box padding={1}>
@@ -102,12 +120,20 @@ function YaziPane(props: {
borderColor={borderColor()} borderColor={borderColor()}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
<Show {/*
when={props.content()} * Render the content accessor directly via a reactive expression.
fallback={<Placeholder color={muted} />} * `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
> * effect that re-runs whenever the accessor's tracked signals
{props.content()} * change (e.g. `depth()` swapping the root from a list fragment to
</Show> * 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()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox> </scrollbox>
</box> </box>
); );
@@ -123,10 +149,11 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
return typeof f === "function" ? f() : f ?? true; return typeof f === "function" ? f() : f ?? true;
}); });
// Normalize static JSX and accessor children into reactive accessors. // Normalize static JSX and accessor children into reactive accessors
const parentContent = solidChildren(() => props.parent); // (see normalizeContent for why we avoid Solid's `children()` helper).
const currentContent = solidChildren(() => props.current); const parentContent = normalizeContent(props.parent);
const previewContent = solidChildren(() => props.preview); const currentContent = normalizeContent(props.current);
const previewContent = normalizeContent(props.preview);
const parentLabel = createMemo(() => resolveLabel(props.parentLabel)); const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
const currentLabel = createMemo(() => resolveLabel(props.currentLabel)); const currentLabel = createMemo(() => resolveLabel(props.currentLabel));

View File

@@ -1,15 +1,18 @@
/** /**
* DiscoverPage — yazi depth-stack view of discoverable podcasts. * DiscoverPage — yazi depth-stack view of discoverable podcasts.
* *
* depth 0 (current) — category list. Left pane empty at root. * depth 0 (current) — category list. Parent pane shows the muted
* depth 1 (current) — podcast results for the drilled category. * placeholder (1/7 slot kept).
* right (preview) — detail of the hovered item (category summary, or * 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). * podcast detail + subscribe action).
* *
* `l`/Enter drills in (category → results) or subscribes (on a podcast); * Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
* `h` pops back (or yields to the sidebar at depth 0). j/k move within the * remains. `l`/Enter drills in (category → results) or subscribes (on a
* current column. Moving through categories at depth 0 updates the store's * podcast); `h` pops a depth (noop at 0). j/k move only within the current
* selected category so the preview follows. * 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"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -25,7 +28,7 @@ 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 { PANE_RATIO } from "@/utils/navigation"; import { YaziPaneRow } from "@/components/YaziPaneRow";
export const DiscoverPaneCount = 1; export const DiscoverPaneCount = 1;
@@ -132,269 +135,219 @@ function DiscoverPage() {
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; 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 <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1}>
<For each={categories()}>
{(cat, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
)}
</For>
</Show>
);
// ── current pane ───────────────────────────────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: categories */}
<Show when={depth() === 0}>
<For each={categories()}>
{(cat, index) => {
const lf = focusedCatIdx();
const selected = () => cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
*
</text>
</Show>
</box>
);
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = focusedPodIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text fg={index() === lf ? theme.surface : theme.success}>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
);
}}
</For>
</Show>
</Show>
</>
);
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered category
<Show
when={focusedCategory()}
fallback={
<box padding={1}>
<text fg={muted()}>No category focused</text>
</box>
}
>
{(cat) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{cat().name}</strong>
</text>
<text fg={theme.textSecondary}>
{(cat() as any).description ??
`Browse top podcasts in ${cat().name}.`}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
</box>
)}
</Show>
) : (
// depth ≥1 preview: hovered podcast + subscribe
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} />
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
</box>
)}
</Show>
);
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <YaziPaneRow
{/* ── left: previous depth (empty at root) ──────────────────────────── */} parent={parentContent}
<box current={currentContent}
flexDirection="column" preview={previewContent}
flexGrow={PANE_RATIO.parent} parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
flexShrink={1} currentLabel={currentLabel}
flexBasis={0} previewLabel="Detail"
height="100%" focused={isActive}
style={{ width: depth() === 0 ? 0 : undefined }} />
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={categories()}>
{(cat, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
{/* ── center: current depth ─────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
{depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: categories */}
<Show when={depth() === 0}>
<For each={categories()}>
{(cat, index) => {
const lf = focusedCatIdx();
const selected = () =>
cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
*
</text>
</Show>
</box>
);
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = focusedPodIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text
fg={index() === lf ? theme.surface : theme.success}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
);
}}
</For>
</Show>
</Show>
</scrollbox>
</box>
{/* ── right: preview ────────────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.preview}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* depth 0 preview: hovered category */}
<Show when={depth() === 0}>
<Show
when={focusedCategory()}
fallback={
<box padding={1}>
<text fg={muted()}>No category focused</text>
</box>
}
>
{(cat) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{cat().name}</strong>
</text>
<text fg={theme.textSecondary}>
{(cat() as any).description ??
`Browse top podcasts in ${cat().name}.`}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
</box>
)}
</Show>
</Show>
{/* depth ≥1 preview: hovered podcast + subscribe */}
<Show when={depth() >= 1}>
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} />
<text fg={muted()}>
enter: subscribe · h: back · r: refresh
</text>
</box>
)}
</Show>
</Show>
</scrollbox>
</box>
</box>
); );
} }

View File

@@ -2,14 +2,17 @@
* FeedPage — yazi depth-stack view of episodes across subscribed shows. * FeedPage — yazi depth-stack view of episodes across subscribed shows.
* *
* depth 0 (current) — subscribed feeds list (containers); index 0 is a * 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 * depth 1 (current) — flat episodes list for the drilled feed (reverse
* chronological). Left pane = the feeds list (prev). * chronological). Parent pane = the feeds list (prev).
* right (preview) — detail of the hovered item in the current column. * preview — detail of the hovered item in the current column.
* *
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the * Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
* sidebar at depth 0). j/k move within the current column. The Shell router * primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
* drives everything over nav.action; this page only handles list/preview data. * (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"; 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 { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PANE_RATIO } from "@/utils/navigation"; import { YaziPaneRow } from "@/components/YaziPaneRow";
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
@@ -200,8 +203,8 @@ function FeedPage() {
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border); // Row highlight within a list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) => const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active i === listFocus && active
? theme.primary ? theme.primary
@@ -210,7 +213,6 @@ function FeedPage() {
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text; i === listFocus && active ? theme.surface : theme.text;
const headerBg = theme.background;
const feedLabel = (item: FeedListItem) => const feedLabel = (item: FeedListItem) =>
item.kind === "all" item.kind === "all"
@@ -221,309 +223,264 @@ function FeedPage() {
? feedStore.getAllEpisodesChronological().length ? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length; : item.feed.episodes.length;
return ( const currentLabel = () =>
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> depth() === 0
{/* ── left: previous depth (empty at root) ──────────────────────────── */} ? `Feeds · ${feedList().length - 1}`
<box : `${(() => {
flexDirection="column" const fi = focusedFeedItem();
flexGrow={PANE_RATIO.parent} return fi?.kind === "feed"
flexShrink={1} ? fi.feed.customName || fi.feed.podcast.title
flexBasis={0} : "All Episodes";
height="100%" })()} · ${episodes().length}`;
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden" // ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
> // Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
<Show when={depth() >= 1}> // mounts/unmounts cleanly on depth change instead of swapping roots.
<box height={1} paddingLeft={1} backgroundColor={headerBg}> const parentContent = () => (
<text fg={theme.textSecondary}> <Show when={depth() >= 1}>
Feeds · {feedList().length - 1} <For each={feedList()}>
{(item, index) => {
const lf = nav.depthFocus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
>
<text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
<text fg={muted()}>({feedCount(item)})</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane: the current-depth list (the only focusable column) ──────
const currentContent = () => (
<>
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={feedList().length > 1}
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text> </text>
</box> </box>
<scrollbox }
height="100%" >
border <For each={feedList()}>
borderColor={theme.border} {(item, index) => {
backgroundColor={theme.background} const fi = focusedFeedIdx();
> return (
<For each={feedList()}> <box
{(item, index) => ( flexDirection="row"
<box gap={1}
flexDirection="row" paddingLeft={1}
gap={1} paddingRight={1}
paddingLeft={1} backgroundColor={focusBg(index(), fi, isActive())}
paddingRight={1} onMouseDown={() => {
backgroundColor={focusBg(index(), nav.depthFocus(0), false)} nav.setActivePane(DEPTH_CENTER_PANE);
> nav.setDepthFocus(index(), 0);
<text fg={focusFg(index(), nav.depthFocus(0), false)}> }}
{index() === nav.depthFocus(0) ? "" : " "} >
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
</Show>
<Show when={depth() >= 1}>
{/* depth ≥1: episodes */}
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text> </text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}> <text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)} {item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text> </text>
<text fg={muted()}>({feedCount(item)})</text>
</box> </box>
)} <box flexDirection="row" gap={2} paddingLeft={2}>
</For> <text fg={index() === fi ? theme.surface : theme.info}>
</scrollbox> {formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show> </Show>
</box> </Show>
</Show>
</>
);
{/* ── center: current depth ─────────────────────────────────────────── */} // ── preview pane: hovered-item detail ──────────────────────────────────────
<box const previewContent = () =>
flexDirection="column" depth() === 0 ? (
flexGrow={PANE_RATIO.current} // depth 0 preview: hovered feed
flexShrink={1} <Show
flexBasis={0} when={focusedFeedItem()}
height="100%" fallback={
<box padding={1}>
<text fg={muted()}>No feed focused</text>
</box>
}
> >
<box height={1} paddingLeft={1} backgroundColor={headerBg}> {(item) => {
<text fg={theme.textSecondary}> const it = item();
{depth() === 0 return (
? `Feeds · ${feedList().length - 1}` <box flexDirection="column" gap={1} padding={1}>
: `${(() => { <text fg={theme.textPrimary ?? theme.text}>
const fi = focusedFeedItem(); <strong>{feedLabel(it)}</strong>
return fi?.kind === "feed" </text>
? fi.feed.customName || fi.feed.podcast.title <text fg={muted()}>
: "All Episodes"; {it.kind === "feed"
})()} · ${episodes().length}`} ? `by ${it.feed.podcast.author ?? "unknown"}`
</text> : ""}
</box> </text>
<scrollbox <text fg={theme.textSecondary}>
height="100%" {it.kind === "all"
focused={isActive} ? `${feedCount(it)} episodes across all feeds`
border : `${feedCount(it)} episodes`}
borderColor={border(isActive)} </text>
backgroundColor={theme.background} <text fg={muted()}>
> {it.kind === "feed"
{/* depth 0: feeds */} ? (it.feed.podcast.description?.slice(0, 400) ??
<Show when={depth() === 0}> "No description.")
<Show : "Drill in to see episodes across every feed."}
when={feedList().length > 1} </text>
fallback={ <box height={1} />
<box padding={1}> <text fg={muted()}>enter/l: open · h: back</text>
<text fg={muted()}> </box>
No feeds. Subscribe from Discover/Search. );
}}
</Show>
) : (
// depth ≥1 preview: hovered episode
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => {
const it = item();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{it.episode.episodeNumber
? `#${it.episode.episodeNumber} `
: ""}
{it.episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>
{formatDate(it.episode.pubDate)}
</text>
<text fg={muted()}>
{formatDuration(it.episode.duration)}
</text>
<Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(it.episode.id)}>
{downloadLabel(it.episode.id)}
</text> </text>
</box> </Show>
} </box>
> <text fg={muted()}>
<For each={feedList()}> {it.feed.customName || it.feed.podcast.title}
{(item, index) => { </text>
const fi = focusedFeedIdx(); <Show when={it.feed.podcast.author}>
return ( <text fg={muted()}>by {it.feed.podcast.author}</text>
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive)}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive)}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive)}>
{index() === fi ? "" : " "}
</text>
<text 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()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show> </Show>
</Show> <box height={1} />
</Show> <text fg={theme.textSecondary}>
</scrollbox> {it.episode.description?.slice(0, 400) ??
</box> "No description available."}
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
</box>
);
}}
</Show>
);
{/* ── right: preview of hovered item ───────────────────────────────── */} return (
<box <YaziPaneRow
flexDirection="column" parent={parentContent}
flexGrow={PANE_RATIO.preview} current={currentContent}
flexShrink={1} preview={previewContent}
flexBasis={0} parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
height="100%" currentLabel={currentLabel}
> previewLabel="Detail"
<box height={1} paddingLeft={1} backgroundColor={headerBg}> focused={isActive}
<text fg={theme.textSecondary}>Preview</text> />
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* depth 0 preview: hovered feed */}
<Show when={depth() === 0}>
<Show
when={focusedFeedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No feed focused</text>
</box>
}
>
{(item) => {
const it = item();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{feedLabel(it)}</strong>
</text>
<text fg={muted()}>
{it.kind === "feed"
? `by ${it.feed.podcast.author ?? "unknown"}`
: ""}
</text>
<text fg={theme.textSecondary}>
{it.kind === "all"
? `${feedCount(it)} episodes across all feeds`
: `${feedCount(it)} episodes`}
</text>
<text fg={muted()}>
{it.kind === "feed"
? (it.feed.podcast.description?.slice(0, 400) ??
"No description.")
: "Drill in to see episodes across every feed."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
</box>
);
}}
</Show>
</Show>
{/* depth ≥1 preview: hovered episode */}
<Show when={depth() >= 1}>
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => {
const it = item();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{it.episode.episodeNumber
? `#${it.episode.episodeNumber} `
: ""}
{it.episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>
{formatDate(it.episode.pubDate)}
</text>
<text fg={muted()}>
{formatDuration(it.episode.duration)}
</text>
<Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(it.episode.id)}>
{downloadLabel(it.episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{it.feed.customName || it.feed.podcast.title}
</text>
<Show when={it.feed.podcast.author}>
<text fg={muted()}>by {it.feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{it.episode.description?.slice(0, 400) ??
"No description available."}
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · space: select · h: back
</text>
</box>
);
}}
</Show>
</Show>
</scrollbox>
</box>
</box>
); );
} }

View File

@@ -1,12 +1,14 @@
/** /**
* MyShowsPage — yazi depth-stack view of subscribed shows. * MyShowsPage — yazi depth-stack view of subscribed shows.
* *
* depth 0 (current) — subscribed shows. Left pane empty at root. * depth 0 (current) — subscribed shows. Parent pane shows the muted
* depth 1 (current) — episodes of the drilled show. Left pane = shows (prev). * placeholder (1/7 slot kept).
* right (preview) — detail of the hovered item in the current column. * 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 * Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
* sidebar at depth 0). j/k move within the current column. * 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"; 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 { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PANE_RATIO } from "@/utils/navigation"; import { YaziPaneRow } from "@/components/YaziPaneRow";
export const MyShowsPaneCount = 1; export const MyShowsPaneCount = 1;
@@ -181,289 +183,240 @@ export function MyShowsPage() {
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; i === lf && active ? theme.surface : theme.text;
const headerBg = theme.background;
const showTitle = (f: Feed) => f.customName || f.podcast.title; const showTitle = (f: Feed) => f.customName || f.podcast.title;
return ( const currentLabel = () =>
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> depth() === 0
{/* ── left: previous depth (empty at root) ──────────────────────────── */} ? `Shows (${shows().length})`
<box : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
flexDirection="column"
flexGrow={PANE_RATIO.parent} // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
flexShrink={1} // ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
flexBasis={0} // Stable <Show> gate (not a ternary root swap) so the parent list
height="100%" // mounts/unmounts cleanly on depth change.
style={{ width: depth() === 0 ? 0 : undefined }} const parentContent = () => (
overflow="hidden" <Show when={depth() >= 1}>
> <For each={shows()}>
<Show when={depth() >= 1}> {(feed, index) => {
<box height={1} paddingLeft={1} backgroundColor={headerBg}> const lf = nav.depthFocus(0);
<text fg={theme.textSecondary}>Shows ({shows().length})</text> return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
>
<text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane: the current-depth list ───────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box> </box>
<scrollbox }
height="100%" >
border <For each={shows()}>
borderColor={theme.border} {(feed, index) => {
backgroundColor={theme.background} const lf = focusedShowIdx();
> return (
<For each={shows()}> <box
{(feed, index) => { flexDirection="row"
const lf = nav.depthFocus(0); gap={1}
return ( paddingLeft={1}
<box paddingRight={1}
flexDirection="row" backgroundColor={focusBg(index(), lf, isActive())}
gap={1} onMouseDown={() => {
paddingLeft={1} nav.setActivePane(DEPTH_CENTER_PANE);
paddingRight={1} nav.setDepthFocus(index(), 0);
backgroundColor={focusBg(index(), lf, false)} }}
> >
<text fg={focusFg(index(), lf, false)}> <text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "} {index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text 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}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text> </text>
<text fg={focusFg(index(), lf, false)}> </Show>
{showTitle(feed)} </box>
</text> </box>
<text fg={muted()}>({feed.episodes.length})</text> );
</box> }}
); </For>
}} <Show when={feedStore.isLoadingMore()}>
</For> <box paddingLeft={2} paddingTop={1}>
</scrollbox> <LoadingIndicator />
</box>
</Show> </Show>
</box> </Show>
</Show>
</>
);
{/* ── center: current depth ─────────────────────────────────────────── */} // ── preview pane ───────────────────────────────────────────────────────────
<box const previewContent = () =>
flexDirection="column" depth() === 0 ? (
flexGrow={PANE_RATIO.current} // depth 0 preview: hovered show
flexShrink={1} <Show
flexBasis={0} when={selectedShow()}
height="100%" fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
> >
<box height={1} paddingLeft={1} backgroundColor={headerBg}> {(show) => (
<text fg={theme.textSecondary}> <box flexDirection="column" gap={1} padding={1}>
{depth() === 0 <text fg={theme.textPrimary ?? theme.text}>
? `Shows (${shows().length})` <strong>{showTitle(show())}</strong>
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`} </text>
</text> <Show when={show().podcast.author}>
</box> <text fg={muted()}>by {show().podcast.author}</text>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: shows */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => {
const lf = focusedShowIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show> </Show>
</Show> <text fg={theme.textSecondary}>
{show().episodes.length} episodes
{/* depth ≥1: episodes */} </text>
<Show when={depth() >= 1}> <text fg={muted()}>
<Show {show().podcast.description?.slice(0, 400) ??
when={episodes().length > 0} "No description."}
fallback={ </text>
<box padding={1}> <box height={1} />
<text fg={muted()}>No episodes. :refresh</text> <text fg={muted()}>enter/l: open · h: back</text>
</box> </box>
} )}
> </Show>
<For each={episodes()}> ) : (
{(ep, index) => { // depth ≥1 preview: hovered episode
const lf = focusedEpIdx(); <Show
return ( when={focusedEpisode()}
<box fallback={
flexDirection="column" <box padding={1}>
gap={0} <text fg={muted()}>No episode focused</text>
paddingLeft={1} </box>
paddingRight={1} }
backgroundColor={focusBg(index(), lf, isActive)} >
onMouseDown={() => { {(ep) => (
nav.setActivePane(DEPTH_CENTER_PANE); <box flexDirection="column" gap={1} padding={1}>
nav.setDepthFocus(index(), 1); <text fg={theme.textPrimary ?? theme.text}>
}} <strong>
> {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
<box flexDirection="row" gap={1}> {ep().title}
<text fg={focusFg(index(), lf, isActive)}> </strong>
{index() === lf ? "" : " "} </text>
</text> <box flexDirection="row" gap={2}>
<text fg={focusFg(index(), lf, isActive)}> <text fg={theme.info}>{formatDate(ep().pubDate)}</text>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} <text fg={muted()}>{formatDuration(ep().duration)}</text>
{ep.title} <Show when={downloadLabel(ep().id)}>
</text> <text fg={downloadColor(ep().id)}>
</box> {downloadLabel(ep().id)}
<box flexDirection="row" gap={2} paddingLeft={2}> </text>
<text fg={index() === lf ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show> </Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show> </Show>
</Show> <box height={1} />
</scrollbox> <text fg={theme.textSecondary}>
</box> {ep().description?.slice(0, 400) ??
"No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
</box>
)}
</Show>
);
{/* ── right: preview ────────────────────────────────────────────────── */} return (
<box <YaziPaneRow
flexDirection="column" parent={parentContent}
flexGrow={PANE_RATIO.preview} current={currentContent}
flexShrink={1} preview={previewContent}
flexBasis={0} parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
height="100%" currentLabel={currentLabel}
> previewLabel="Detail"
<box height={1} paddingLeft={1} backgroundColor={headerBg}> focused={isActive}
<text fg={theme.textSecondary}>Preview</text> />
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* depth 0 preview: hovered show */}
<Show when={depth() === 0}>
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ??
"No description."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
</box>
)}
</Show>
</Show>
{/* depth ≥1 preview: hovered episode */}
<Show when={depth() >= 1}>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>
by {selectedShow()!.podcast.author}
</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ??
"No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · space: select · h: back
</text>
</box>
)}
</Show>
</Show>
</scrollbox>
</box>
</box>
); );
} }

View File

@@ -5,9 +5,12 @@
* depth 1 — the focused section's items as a navigable list * depth 1 — the focused section's items as a navigable list
* depth 2 — per-item editor (for editor-kind items) or value adjuster * depth 2 — per-item editor (for editor-kind items) or value adjuster
* *
* Columns render as yazi's prev | current | preview: * Renders entirely through `<YaziPaneRow>` (parent | current | preview):
* left = previous depth's list (empty at depth 0) * parent = previous depth's list (sections at depth 1, items at depth 2);
* right = preview/help text for the hovered item in center * 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, * 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 — * Enter/l drill, h back). Panels no longer register their own useKeyboard —
@@ -24,12 +27,12 @@ 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 { PANE_RATIO } from "@/utils/navigation";
import type { SettingItem, SettingsSectionDef } from "./types"; import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel"; import { usePreferencesItems } from "./PreferencesPanel";
import { useVisualizerItems } from "./VisualizerSettings"; import { useVisualizerItems } from "./VisualizerSettings";
import { useSyncItems, closeSyncEditor } from "./SyncPanel"; import { useSyncItems, closeSyncEditor } from "./SyncPanel";
import { useSourceItems } from "./SourceManager"; import { useSourceItems } from "./SourceManager";
import { YaziPaneRow } from "@/components/YaziPaneRow";
export const SettingsPaneCount = 1; export const SettingsPaneCount = 1;
@@ -224,9 +227,7 @@ export function SettingsPage() {
onCleanup(() => closeSyncEditor()); onCleanup(() => closeSyncEditor());
// ── render helpers ─────────────────────────────────────────────────────── // ── render helpers ───────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE; const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const headerBg = theme.background;
// preview text for the right column // preview text for the right column
const previewText = createMemo<string>(() => { const previewText = createMemo<string>(() => {
@@ -245,120 +246,82 @@ export function SettingsPage() {
: "No editor."; : "No editor.";
}); });
// ── column content builders ────────────────────────────────────────────── // ── column label ───────────────────────────────────────────────────────────
// left = previous depth (read-only list), or empty at depth 0 const currentLabel = () => {
const LeftCol = () => ( const d = depth();
<box if (d === 0) return "Settings";
flexDirection="column" if (d === 1) return sectionForDepth1()?.label ?? "Items";
flexGrow={PANE_RATIO.parent} return editorItem()?.label ?? "Editor";
flexShrink={1} };
flexBasis={0} const parentLabel = () => {
height="100%" const d = depth();
style={{ width: depth() === 0 ? 0 : undefined }} if (d === 1) return "Sections";
overflow="hidden" if (d === 2) return sectionForDepth1()?.label ?? "";
> return "Up";
<box height={1} paddingLeft={1} backgroundColor={headerBg}> };
<text fg={theme.textSecondary}>
<Show when={depth() >= 1} fallback=" "> // ── parent pane: previous-depth list (blank at depth 0) ────────────────
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")} // Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
</Show> // mounts every branch once and toggles children on depth change — the
</text> // known-good opentui disposal pattern. A ternary returning different
</box> // roots leaves subtree orphaned on swap; the trick is a STABLE fragment
// root whose inner <Show> children swap instead.
const parentContent = () => (
<>
<Show when={depth() === 1}> <Show when={depth() === 1}>
<scrollbox {/* previous depth = sections list (read-only) */}
height="100%" <For each={SECTIONS}>
border {(section, index) => (
borderColor={theme.border} <Row
backgroundColor={theme.background} label={`${section.id + 1}. ${section.label}`}
> focused={index() === focusedSectionIdx()}
<For each={SECTIONS}> active={false}
{(section, index) => ( />
<Row )}
label={`${section.id + 1}. ${section.label}`} </For>
focused={index() === focusedSectionIdx()}
active={false}
/>
)}
</For>
</scrollbox>
</Show> </Show>
<Show when={depth() === 2}> <Show when={depth() === 2}>
<scrollbox {/* previous depth = items list (read-only) */}
height="100%" <For each={items()}>
border {(it, index) => (
borderColor={theme.border} <Row
backgroundColor={theme.background} label={`${it.label} ${it.display()}`}
> focused={index() === focusedItemIdx()}
<For each={items()}> active={false}
{(it, index) => ( />
<Row )}
label={`${it.label} ${it.display()}`} </For>
focused={index() === focusedItemIdx()}
active={false}
/>
)}
</For>
</scrollbox>
</Show> </Show>
</box> </>
); );
// center = current depth // ── current pane: current-depth list (or editor at depth 2) ───────────────
const CenterCol = () => ( const currentContent = () => (
<box <>
flexDirection="column" <Show when={depth() === 0}>
flexGrow={PANE_RATIO.current} <For each={SECTIONS}>
flexShrink={1} {(section, index) => (
flexBasis={0} <Row
height="100%" label={`${section.id + 1}. ${section.label}`}
> focused={index() === focusedSectionIdx()}
<box height={1} paddingLeft={1} backgroundColor={headerBg}> active={isActive()}
<text fg={theme.textSecondary}> onMouseDown={() => {
<Show nav.setActivePane(DEPTH_CENTER_PANE);
when={depth() === 0} nav.setDepthFocus(index(), 0);
fallback={ }}
<Show />
when={depth() === 1} )}
fallback={editorItem()?.label ?? "Editor"} </For>
> </Show>
{sectionForDepth1()?.label ?? "Items"} <Show when={depth() === 1}>
</Show> <box flexDirection="column">
}
>
Settings
</Show>
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
<Show when={depth() === 0}>
<For each={SECTIONS}>
{(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
focused={index() === focusedSectionIdx()}
active={isActive}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
</Show>
<Show when={depth() === 1}>
<For each={items()}> <For each={items()}>
{(it, index) => ( {(it, index) => (
<Row <Row
label={`${it.label}`} label={`${it.label}`}
value={it.display()} value={it.display()}
focused={index() === focusedItemIdx()} focused={index() === focusedItemIdx()}
active={isActive} active={isActive()}
hint={hintFor(it)} hint={hintFor(it)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
@@ -372,50 +335,37 @@ export function SettingsPage() {
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text> <text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
</box> </box>
</Show> </Show>
</box>
</Show>
<Show when={depth() === 2}>
{/* depth 2: editor */}
<Show
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
>
{editorItem()!.renderEditor!()}
</Show> </Show>
<Show when={depth() === 2}> </Show>
<Show </>
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
>
{editorItem()!.renderEditor!()}
</Show>
</Show>
</scrollbox>
</box>
); );
// right = preview / help // ── preview pane ──────────────────────────────────────────────────────────
const RightCol = () => ( const previewContent = () => (
<box <box padding={1}>
flexDirection="column" <MultiLine text={previewText()} />
flexGrow={PANE_RATIO.preview}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<box padding={1}>
<MultiLine text={previewText()} />
</box>
</scrollbox>
</box> </box>
); );
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <YaziPaneRow
{LeftCol()} parent={parentContent}
{CenterCol()} current={currentContent}
{RightCol()} preview={previewContent}
</box> parentLabel={parentLabel}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
); );
} }

View File

@@ -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 `<YaziPaneRow>` 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 `<YaziPaneRow>`'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<typeof createNavigation>) => void) {
createRoot((dispose) => {
fn(createNavigation());
dispose();
});
}
/** The depth-tabs that must render via <YaziPaneRow> (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(),
);
});