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

@@ -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 `<YaziPaneRow>`; 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 <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 (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
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>
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -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 `<YaziPaneRow>` (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 (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
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 <Show> (the sibling-Show pattern) so the parent list
// mounts/unmounts cleanly on depth change instead of swapping roots.
const parentContent = () => (
<Show when={depth() >= 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>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={feedList()}>
{(item, 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) ? "" : " "}
}
>
<For each={feedList()}>
{(item, index) => {
const fi = focusedFeedIdx();
return (
<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>
<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 fg={focusFg(index(), nav.depthFocus(0), false)}>
{feedLabel(item)}
<text fg={focusFg(index(), fi, isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
<text fg={muted()}>({feedCount(item)})</text>
</box>
)}
</For>
</scrollbox>
<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>
</box>
</Show>
</Show>
</>
);
{/* ── center: current depth ─────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
// ── preview pane: hovered-item detail ──────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered feed
<Show
when={focusedFeedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No feed focused</text>
</box>
}
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
{depth() === 0
? `Feeds · ${feedList().length - 1}`
: `${(() => {
const fi = focusedFeedItem();
return fi?.kind === "feed"
? fi.feed.customName || fi.feed.podcast.title
: "All Episodes";
})()} · ${episodes().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: feeds */}
<Show when={depth() === 0}>
<Show
when={feedList().length > 1}
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
{(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>
) : (
// 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>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const fi = focusedFeedIdx();
return (
<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>
</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>
</Show>
</Show>
</scrollbox>
</box>
<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>
);
{/* ── right: preview of hovered item ───────────────────────────────── */}
<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 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>
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -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 `<YaziPaneRow>`; 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 (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
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 <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={shows()}>
{(feed, 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)}>{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>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={shows()}>
{(feed, 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 ? "" : " "}
}
>
<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>
{/* 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 fg={focusFg(index(), lf, false)}>
{showTitle(feed)}
</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
</For>
</scrollbox>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</box>
</Show>
</Show>
</>
);
{/* ── center: current depth ─────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered show
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
{depth() === 0
? `Shows (${shows().length})`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`}
</text>
</box>
<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) => (
<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>
</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>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
<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>
) : (
// depth ≥1 preview: hovered episode
<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>
</Show>
</scrollbox>
</box>
<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>
);
{/* ── 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 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>
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -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 `<YaziPaneRow>` (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<string>(() => {
@@ -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 = () => (
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
<Show when={depth() >= 1} fallback=" ">
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
</Show>
</text>
</box>
// ── 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 <Show> 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 <Show> children swap instead.
const parentContent = () => (
<>
<Show when={depth() === 1}>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={SECTIONS}>
{(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
focused={index() === focusedSectionIdx()}
active={false}
/>
)}
</For>
</scrollbox>
{/* previous depth = sections list (read-only) */}
<For each={SECTIONS}>
{(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
focused={index() === focusedSectionIdx()}
active={false}
/>
)}
</For>
</Show>
<Show when={depth() === 2}>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label} ${it.display()}`}
focused={index() === focusedItemIdx()}
active={false}
/>
)}
</For>
</scrollbox>
{/* previous depth = items list (read-only) */}
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label} ${it.display()}`}
focused={index() === focusedItemIdx()}
active={false}
/>
)}
</For>
</Show>
</box>
</>
);
// center = current depth
const CenterCol = () => (
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
<Show
when={depth() === 0}
fallback={
<Show
when={depth() === 1}
fallback={editorItem()?.label ?? "Editor"}
>
{sectionForDepth1()?.label ?? "Items"}
</Show>
}
>
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}>
// ── current pane: current-depth list (or editor at depth 2) ───────────────
const currentContent = () => (
<>
<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}>
<box flexDirection="column">
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label}`}
value={it.display()}
focused={index() === focusedItemIdx()}
active={isActive}
active={isActive()}
hint={hintFor(it)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
@@ -372,50 +335,37 @@ export function SettingsPage() {
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
</box>
</Show>
</box>
</Show>
<Show when={depth() === 2}>
{/* depth 2: editor */}
<Show
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
>
{editorItem()!.renderEditor!()}
</Show>
<Show when={depth() === 2}>
<Show
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
>
{editorItem()!.renderEditor!()}
</Show>
</Show>
</scrollbox>
</box>
</Show>
</>
);
// right = preview / help
const RightCol = () => (
<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}
>
<box padding={1}>
<MultiLine text={previewText()} />
</box>
</scrollbox>
// ── preview pane ──────────────────────────────────────────────────────────
const previewContent = () => (
<box padding={1}>
<MultiLine text={previewText()} />
</box>
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{LeftCol()}
{CenterCol()}
{RightCol()}
</box>
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={parentLabel}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}