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(),
+ );
+});