Merge branch 'convert-list-tabs-to-primitive'
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
tests/yazi-pages-depth.test.ts
Normal file
105
tests/yazi-pages-depth.test.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user