Merge branch 'convert-list-tabs-to-primitive'

This commit is contained in:
2026-07-31 17:55:37 -04:00
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,36 +135,23 @@ 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;
return ( const currentLabel = () =>
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> depth() === 0
{/* ── left: previous depth (empty at root) ──────────────────────────── */} ? "Categories"
<box : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().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}> <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()}> <For each={categories()}>
{(cat, index) => ( {(cat, index) => (
<box <box
@@ -180,56 +170,35 @@ function DiscoverPage() {
</box> </box>
)} )}
</For> </For>
</scrollbox>
</Show> </Show>
</box> );
{/* ── center: current depth ─────────────────────────────────────────── */} // ── current pane ───────────────────────────────────────────────────────────
<box const currentContent = () => (
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 */} {/* depth 0: categories */}
<Show when={depth() === 0}> <Show when={depth() === 0}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => { {(cat, index) => {
const lf = focusedCatIdx(); const lf = focusedCatIdx();
const selected = () => const selected = () => cat.id === discoverStore.selectedCategory();
cat.id === discoverStore.selectedCategory();
return ( return (
<box <box
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)} backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id); discoverStore.setSelectedCategory(cat.id);
}} }}
> >
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text> <text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
<Show when={selected()}> <Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}> <text fg={index() === lf ? theme.surface : theme.accent}>
* *
@@ -240,7 +209,6 @@ function DiscoverPage() {
}} }}
</For> </For>
</Show> </Show>
{/* depth ≥1: results */} {/* depth ≥1: results */}
<Show when={depth() >= 1}> <Show when={depth() >= 1}>
<Show <Show
@@ -260,23 +228,21 @@ function DiscoverPage() {
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)} backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{podcast.title} {podcast.title}
</text> </text>
<Show when={podcast.isSubscribed}> <Show when={podcast.isSubscribed}>
<text <text fg={index() === lf ? theme.surface : theme.success}>
fg={index() === lf ? theme.surface : theme.success}
>
[+] [+]
</text> </text>
</Show> </Show>
@@ -295,28 +261,13 @@ function DiscoverPage() {
</For> </For>
</Show> </Show>
</Show> </Show>
</scrollbox> </>
</box> );
{/* ── right: preview ────────────────────────────────────────────────── */} // ── preview pane ───────────────────────────────────────────────────────────
<box const previewContent = () =>
flexDirection="column" depth() === 0 ? (
flexGrow={PANE_RATIO.preview} // depth 0 preview: hovered category
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 <Show
when={focusedCategory()} when={focusedCategory()}
fallback={ fallback={
@@ -339,10 +290,8 @@ function DiscoverPage() {
</box> </box>
)} )}
</Show> </Show>
</Show> ) : (
// depth ≥1 preview: hovered podcast + subscribe
{/* depth ≥1 preview: hovered podcast + subscribe */}
<Show when={depth() >= 1}>
<Show <Show
when={focusedPodcast()} when={focusedPodcast()}
fallback={ fallback={
@@ -381,20 +330,24 @@ function DiscoverPage() {
<Show when={pod().feedUrl}> <Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text> <text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show> </Show>
<text fg={muted()}> <text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} /> <box height={1} />
<text fg={muted()}> <text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
enter: subscribe · h: back · r: refresh
</text>
</box> </box>
)} )}
</Show> </Show>
</Show> );
</scrollbox>
</box> return (
</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. * 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,81 +223,48 @@ 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) ──────────────────────────── */}
<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}
</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) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{feedLabel(item)}
</text>
<text fg={muted()}>({feedCount(item)})</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
? `Feeds · ${feedList().length - 1}` ? `Feeds · ${feedList().length - 1}`
: `${(() => { : `${(() => {
const fi = focusedFeedItem(); const fi = focusedFeedItem();
return fi?.kind === "feed" return fi?.kind === "feed"
? fi.feed.customName || fi.feed.podcast.title ? fi.feed.customName || fi.feed.podcast.title
: "All Episodes"; : "All Episodes";
})()} · ${episodes().length}`} })()} · ${episodes().length}`;
</text>
</box> // ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
<scrollbox // Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
height="100%" // mounts/unmounts cleanly on depth change instead of swapping roots.
focused={isActive} const parentContent = () => (
border <Show when={depth() >= 1}>
borderColor={border(isActive)} <For each={feedList()}>
backgroundColor={theme.background} {(item, index) => {
const lf = nav.depthFocus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
> >
{/* depth 0: feeds */} <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={depth() === 0}>
<Show <Show
when={feedList().length > 1} when={feedList().length > 1}
@@ -316,16 +285,16 @@ function FeedPage() {
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)} backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
}} }}
> >
<text fg={focusFg(index(), fi, isActive)}> <text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "} {index() === fi ? "" : " "}
</text> </text>
<text fg={focusFg(index(), fi, isActive)}> <text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)} {feedLabel(item)}
</text> </text>
<text fg={index() === fi ? theme.surface : muted()}> <text fg={index() === fi ? theme.surface : muted()}>
@@ -337,9 +306,8 @@ function FeedPage() {
</For> </For>
</Show> </Show>
</Show> </Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}> <Show when={depth() >= 1}>
{/* depth ≥1: episodes */}
<Show <Show
when={episodes().length > 0} when={episodes().length > 0}
fallback={ fallback={
@@ -357,17 +325,17 @@ function FeedPage() {
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)} backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive)}> <text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "} {index() === fi ? "" : " "}
</text> </text>
<text fg={focusFg(index(), fi, isActive)}> <text fg={focusFg(index(), fi, isActive())}>
{item.episode.episodeNumber {item.episode.episodeNumber
? `#${item.episode.episodeNumber} ` ? `#${item.episode.episodeNumber} `
: ""} : ""}
@@ -404,28 +372,13 @@ function FeedPage() {
</Show> </Show>
</Show> </Show>
</Show> </Show>
</scrollbox> </>
</box> );
{/* ── right: preview of hovered item ───────────────────────────────── */} // ── preview pane: hovered-item detail ──────────────────────────────────────
<box const previewContent = () =>
flexDirection="column" depth() === 0 ? (
flexGrow={PANE_RATIO.preview} // depth 0 preview: hovered feed
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 <Show
when={focusedFeedItem()} when={focusedFeedItem()}
fallback={ fallback={
@@ -463,10 +416,8 @@ function FeedPage() {
); );
}} }}
</Show> </Show>
</Show> ) : (
// depth ≥1 preview: hovered episode
{/* depth ≥1 preview: hovered episode */}
<Show when={depth() >= 1}>
<Show <Show
when={focusedItem()} when={focusedItem()}
fallback={ fallback={
@@ -513,17 +464,23 @@ function FeedPage() {
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""} {(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}> <text fg={muted()}>enter: play · space: select · h: back</text>
enter: play · space: select · h: back
</text>
</box> </box>
); );
}} }}
</Show> </Show>
</Show> );
</scrollbox>
</box> return (
</box> <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. * 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,37 +183,24 @@ 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}> <Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={shows()}> <For each={shows()}>
{(feed, index) => { {(feed, index) => {
const lf = nav.depthFocus(0); const lf = nav.depthFocus(0);
@@ -226,41 +215,19 @@ export function MyShowsPage() {
<text fg={focusFg(index(), lf, false)}> <text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, false)}> <text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
{showTitle(feed)}
</text>
<text fg={muted()}>({feed.episodes.length})</text> <text fg={muted()}>({feed.episodes.length})</text>
</box> </box>
); );
}} }}
</For> </For>
</scrollbox>
</Show> </Show>
</box> );
{/* ── center: current depth ─────────────────────────────────────────── */} // ── current pane: the current-depth list ───────────────────────────────────
<box const currentContent = () => (
flexDirection="column" <>
flexGrow={PANE_RATIO.current} {/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
flexShrink={1}
flexBasis={0}
height="100%"
>
<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={depth() === 0}>
<Show <Show
when={shows().length > 0} when={shows().length > 0}
@@ -281,16 +248,16 @@ export function MyShowsPage() {
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)} backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
}} }}
> >
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{showTitle(feed)} {showTitle(feed)}
</text> </text>
<text fg={index() === lf ? theme.surface : muted()}> <text fg={index() === lf ? theme.surface : muted()}>
@@ -302,7 +269,6 @@ export function MyShowsPage() {
</For> </For>
</Show> </Show>
</Show> </Show>
{/* depth ≥1: episodes */} {/* depth ≥1: episodes */}
<Show when={depth() >= 1}> <Show when={depth() >= 1}>
<Show <Show
@@ -322,17 +288,17 @@ export function MyShowsPage() {
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)} backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), lf, isActive)}> <text fg={focusFg(index(), lf, isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title} {ep.title}
</text> </text>
@@ -364,28 +330,13 @@ export function MyShowsPage() {
</Show> </Show>
</Show> </Show>
</Show> </Show>
</scrollbox> </>
</box> );
{/* ── right: preview ────────────────────────────────────────────────── */} // ── preview pane ───────────────────────────────────────────────────────────
<box const previewContent = () =>
flexDirection="column" depth() === 0 ? (
flexGrow={PANE_RATIO.preview} // depth 0 preview: hovered show
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 <Show
when={selectedShow()} when={selectedShow()}
fallback={ fallback={
@@ -414,10 +365,8 @@ export function MyShowsPage() {
</box> </box>
)} )}
</Show> </Show>
</Show> ) : (
// depth ≥1 preview: hovered episode
{/* depth ≥1 preview: hovered episode */}
<Show when={depth() >= 1}>
<Show <Show
when={focusedEpisode()} when={focusedEpisode()}
fallback={ fallback={
@@ -444,9 +393,7 @@ export function MyShowsPage() {
</Show> </Show>
</box> </box>
<Show when={selectedShow()?.podcast.author}> <Show when={selectedShow()?.podcast.author}>
<text fg={muted()}> <text fg={muted()}>by {selectedShow()!.podcast.author}</text>
by {selectedShow()!.podcast.author}
</text>
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
@@ -455,15 +402,21 @@ export function MyShowsPage() {
{(ep().description?.length ?? 0) > 400 ? "…" : ""} {(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}> <text fg={muted()}>enter: play · space: select · h: back</text>
enter: play · space: select · h: back
</text>
</box> </box>
)} )}
</Show> </Show>
</Show> );
</scrollbox>
</box> return (
</box> <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 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,32 +246,30 @@ 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%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={SECTIONS}> <For each={SECTIONS}>
{(section, index) => ( {(section, index) => (
<Row <Row
@@ -280,15 +279,9 @@ export function SettingsPage() {
/> />
)} )}
</For> </For>
</scrollbox>
</Show> </Show>
<Show when={depth() === 2}> <Show when={depth() === 2}>
<scrollbox {/* previous depth = items list (read-only) */}
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={items()}> <For each={items()}>
{(it, index) => ( {(it, index) => (
<Row <Row
@@ -298,51 +291,20 @@ export function SettingsPage() {
/> />
)} )}
</For> </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"
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}> <Show when={depth() === 0}>
<For each={SECTIONS}> <For each={SECTIONS}>
{(section, index) => ( {(section, index) => (
<Row <Row
label={`${section.id + 1}. ${section.label}`} label={`${section.id + 1}. ${section.label}`}
focused={index() === focusedSectionIdx()} focused={index() === focusedSectionIdx()}
active={isActive} active={isActive()}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(index(), 0);
@@ -352,13 +314,14 @@ export function SettingsPage() {
</For> </For>
</Show> </Show>
<Show when={depth() === 1}> <Show when={depth() === 1}>
<box flexDirection="column">
<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,8 +335,10 @@ 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>
<Show when={depth() === 2}> <Show when={depth() === 2}>
{/* depth 2: editor */}
<Show <Show
when={editorItem()?.renderEditor} when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />} fallback={<GenericEditor item={editorItem()!} />}
@@ -381,41 +346,26 @@ export function SettingsPage() {
{editorItem()!.renderEditor!()} {editorItem()!.renderEditor!()}
</Show> </Show>
</Show> </Show>
</scrollbox> </>
</box>
); );
// right = preview / help // ── preview pane ──────────────────────────────────────────────────────────
const RightCol = () => ( const previewContent = () => (
<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}> <box padding={1}>
<MultiLine text={previewText()} /> <MultiLine text={previewText()} />
</box> </box>
</scrollbox>
</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(),
);
});