fix navigation flow

This commit is contained in:
2026-08-07 00:28:24 -04:00
parent 9e7a44309a
commit c8d29ed59d
14 changed files with 924 additions and 484 deletions

View File

@@ -15,10 +15,7 @@ import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import {
useNavigation,
NavMode,
} from "@/context/NavigationContext";
import { useNavigation, NavMode } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed";
@@ -28,6 +25,8 @@ import { emit } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { YaziPaneRow } from "@/components/YaziPaneRow";
const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed",
@@ -94,7 +93,7 @@ export function Shell() {
case "q":
case "quit":
case "exit":
process.exit(0);
return process.exit(0);
case "refresh":
case "r":
emit("nav.action", {
@@ -190,7 +189,10 @@ export function Shell() {
// ── Unified router (normal + visual) ───────────────────────────────────────
const { dispatch } = createDispatcher({
nav,
audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative },
audio: {
togglePlayback: audio.togglePlayback,
seekRelative: audio.seekRelative,
},
k,
setShowHelp,
advanceEpisode,
@@ -232,11 +234,36 @@ export function Shell() {
height="100%"
backgroundColor={t.surface}
>
{/* ── Middle row: full-width active page ──────────────────────────────── */}
<box flexGrow={1} width="100%">
{LayerGraph[nav.activeTab()]()}
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%">
<Show
when={nav.atRootTab()}
fallback={
<box flexGrow={1} width="100%">
{LayerGraph[nav.activeTab()]()}
</box>
}
>
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
<YaziPaneRow
parent={
<box padding={1}>
<text fg={t.textMuted}></text>
</box>
}
current={<TabListPane />}
preview={
<box padding={1}>
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
</box>
}
parentLabel="Up"
currentLabel="Tabs"
previewLabel=""
focused
/>
</Show>
</box>
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
<box
flexDirection="row"
@@ -252,10 +279,13 @@ export function Shell() {
{modeLabel()}
</text>
<text fg={t.textMuted} paddingLeft={1}>
{TAB_LABEL[nav.activeTab()]} ·{" "}
{nav.isDepthTab()
? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
{nav.atRootTab()
? "Tabs · root"
: `${TAB_LABEL[nav.activeTab()]} · ${
nav.isDepthTab()
? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
}`}
</text>
<Show when={nav.selectedIds().length > 0}>
<text fg={t.warning} paddingLeft={1}>
@@ -271,27 +301,6 @@ export function Shell() {
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>
{/* ── Tab strip ─────────────────────────────────────────────────── */}
<For
each={Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
)}
>
{(tab) => {
const active = () => nav.activeTab() === tab;
return (
<text
fg={active() ? t.surface : t.textMuted}
backgroundColor={
active() ? t.primary : undefined
}
>
{active() ? "≡" : " "}[{tab}]{" "}
{TAB_LABEL[tab]}{" "}
</text>
);
}}
</For>
<text fg={t.textMuted} paddingRight={1}>
~
</text>
@@ -310,7 +319,6 @@ export function Shell() {
</Show>
</Show>
</box>
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
<Show when={showHelp()}>
<HelpOverlay
@@ -338,7 +346,9 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
{
group: "Panes",
items: [
["h/l", "swipe pane"],
["j/k", "switch tab (tab panel)"],
["l/enter", "enter tab content"],
["h", "back to tab panel"],
["1-6 / [ ]", "switch tabs"],
[":", "command"],
["~", "help"],

View File

@@ -0,0 +1,78 @@
/**
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
* PREVIEW flow (replaces the old fixed chrome tab column).
*
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
* `focused` only matters to the surrounding frame (the CURRENT column draws
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
* column (`muted`), the cursor highlight is suppressed and only the active ●
* shows, so it reads as the read-only parent listing.
*/
import { For } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { TABS } from "@/utils/navigation";
const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed",
[TABS.MYSHOWS]: "My Shows",
[TABS.DISCOVER]: "Discover",
[TABS.SEARCH]: "Search",
[TABS.PLAYER]: "Player",
[TABS.SETTINGS]: "Settings",
};
/** Numeric TABS values, in declaration order (1..TabsCount). */
const TAB_ORDER = Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
) as TABS[];
export function TabListPane(props: { muted?: boolean }) {
const { theme } = useTheme();
const nav = useNavigation();
const cursor = () => nav.tabCursor();
const active = () => nav.activeTab();
const muted = () => props.muted ?? false;
return (
<For each={TAB_ORDER}>
{(tab) => {
const isCursor = () => cursor() === tab && !muted();
const isActive = () => active() === tab;
const fg = () =>
isCursor()
? theme.textSelectedPrimary
: isActive()
? theme.accent
: theme.text;
return (
<box
width="100%"
height={1}
flexDirection="row"
backgroundColor={isCursor() ? theme.primary : "transparent"}
>
<text
width={2}
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
>
{isActive() ? "●" : " "}
</text>
<text
width={2}
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
>
{tab}
</text>
<text fg={fg()} paddingLeft={1}>
{TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
);
}

View File

@@ -18,23 +18,31 @@
* nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused.
*
* Two pane models coexist:
* Two pane models coexist under a single TAB list:
*
* • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal,
* focusable pane at the left of every tab's content, just like in yazi.
* Starting focus lives here; tab switches made from here keep focus here.
* When it is focused, j/k moves the tab cursor (`tabCursor`) and
* `l`/Enter opens the hovered tab into its content. Swiping left past
* it goes out of the panes (inert — there is no pane beyond it).
*
* • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
* focusable pane — the current column (DEPTH_CENTER_PANE = 0). The parent
* column renders the previous depth's list (blank at depth 0); the preview
* column renders the hovered item. `l`/Enter drills in (push a frame);
* `h` pops a depth (a noop at depth 0). Depth is unbounded — each page
* decides per-item whether an item is drillable and what child list to
* push. Drill/pop is dispatched by the Shell, never via swipe.
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The
* parent column renders the previous depth's list (blank at depth 0); the
* preview column renders the hovered item. `l`/Enter drills in (push a
* frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus
* to the tab list (TAB_PANE).
*
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the
* parent/current/preview columns with `h`/`l`, clamped to [0, paneCount-1].
* parent/current/preview columns with `h`/`l`, clamped to
* [1, paneCount]; `h` on the first content pane (1) moves focus to the tab
* list; `h` on the tab list stays out-of-panear (inert).
*
* Tabs switch only via digit keys `1`-`6`, `[`/`]`, or (later) a bottom tab
* strip. There is NO sidebar pane: `activePane` is plain tab pane state and is
* never a chrome/tab-list pane.
* Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
* Focus on the tab list persists across a tab switch; from there `l`/Enter
* drops into the active tab's content (panes 1..N).
*/
import { createSignal, batch } from "solid-js";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
@@ -46,19 +54,28 @@ export enum NavMode {
INPUT = "INPUT",
}
/** The current pane. For depth-tabs this is the single focusable pane (the
* center column, index 0); for fixed-pane tabs it's the default landing pane
* on tab-enter. Every tab-enter resets `activePane` to this value. */
export const DEPTH_CENTER_PANE = 0 as PaneId;
/** The current content pane of the active tab, i.e. the focusable column
* (index 1) for depth-tabs, and the default landing pane for fixed-pane
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
* while focused on content resets `activePane` to this pane (unless already
* on the tab list). */
export const DEPTH_CENTER_PANE = 1 as PaneId;
/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
* left of the active tab's content (1..n). It is the app's outermost pane:
* starting focus lives here, tab switches made from it keep focus on it, and
* swiping left past the first content pane returns to it. Swiping left again
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
/** Content pane slots for fixed-pane tabs (Search). Values are the global
* pane indices (content starts at 1). */
export enum PaneSlot {
PARENT = 0, // depth-tabs: center/current; Search: input
CURRENT = 1, // Search: results
PREVIEW = 2, // Search: detail
PARENT = 1, // Search: input
CURRENT = 2, // Search: results
PREVIEW = 3, // Search: detail
}
export type PaneId = number; // 0-based index into the active tab's pane list
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
// ── Depth stack ──────────────────────────────────────────────────────────────
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
@@ -88,25 +105,35 @@ const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
*/
export function createNavigation() {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
// App focus starts on the current pane (center, idx 0); every tab
// switch also resets here. There is no sidebar pane.
const [activePane, setActivePane] =
createSignal<PaneId>(DEPTH_CENTER_PANE);
// The root tab panel's cursor — which tab j/k is currently hovering. It is
// independent of `activeTab` until l/Enter activates it (activateTabCursor)
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
// just like any other yazi list: j/k move the cursor, Enter/l open.
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
// App focus starts on the tab list (the app root). `activePane` drives the
// fixed-pane pages (Search/Player) and each page's content focus ring;
// depth-tab focus is instead described by the per-tab depth stack plus the
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and
// slides into the UP/parent pane once content is opened).
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
const [atRootTabSignal, setAtTabRoot] = createSignal(true);
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false);
// per-tab depth stack. Depth-tabs get a root frame on first visit.
const [stacks, setStacks] = createSignal<
Partial<Record<TABS, DepthFrame[]>>
>({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] });
const [stacks, setStacks] = createSignal<Partial<Record<TABS, DepthFrame[]>>>(
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
);
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
// for pane 0 (DEPTH_CENTER_PANE) instead.
const [paneIndices, setPaneIndices] = createSignal<
Record<string, number>
>({});
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
{},
);
const [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
@@ -132,13 +159,23 @@ export function createNavigation() {
* no-op (server build). Routing every tab change through this helper
* keeps the behavior identical under both runtimes.
*
* - seed a root frame for fresh depth-tabs
* - reset focus to the current/center pane (no sidebar pane)
* - when switching to a special (fixed-pane) tab from the tab root, leave the
* root — those tabs render only their content, never the tab-list view.
* - keep focus on the tab root if it is focused (depth-tab switch),
* otherwise recenter on the active tab's current/center pane
* - clear mode/command/visual/count state */
const applyTabSwitch = (tab: TABS) => {
ensureStack(tab);
batch(() => {
setActivePane(DEPTH_CENTER_PANE);
// A depth-tab switch from the root keeps the root; switching to a
// special (fixed-pane) tab always leaves it. Switches made from
// inside content drop into the new tab's content pane.
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) {
setAtTabRoot(false);
}
if (!atRootTabSignal()) {
setActivePane(DEPTH_CENTER_PANE);
}
setMode(NavMode.NORMAL);
setCount(null);
setCommandBuffer("");
@@ -154,8 +191,7 @@ export function createNavigation() {
// (used by unit tests) createMemo is a no-op that freezes at creation,
// so a plain function is the only option that stays correct in tests.
const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
const currentDepth = (): number =>
Math.max(0, depthStack().length - 1);
const currentDepth = (): number => Math.max(0, depthStack().length - 1);
const topFrame = (): DepthFrame | undefined =>
depthStack()[depthStack().length - 1];
const isDepthTab = () => DEPTH_TABS.has(activeTab());
@@ -199,6 +235,9 @@ export function createNavigation() {
* tab change — programmatic or key-driven — goes through one path. */
const switchTab = (tab: TABS) => {
setActiveTab(tab);
// a direct tab switch re-syncs the root panel's cursor so the panel
// reflects what is actually active.
setTabCursor(tab);
applyTabSwitch(tab);
};
const gotoTab = (tab: TABS) => {
@@ -217,16 +256,53 @@ export function createNavigation() {
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
/** Move focus to the adjacent pane (fixed-pane tabs only). `dir` =
/** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` =
* -1 (left, toward parent) or +1 (right, toward preview). Clamped to
* [0, paneCount-1] — there is no sidebar pane to land on. */
* [0, paneCount-1]. The root panel transition (from content pane 0 to
* (1..TabPaneCount) is handled by the dispatcher, not here. */
const swipe = (dir: -1 | 1, paneCount: number) => {
setActivePane((p) => {
const n = Math.max(0, Math.min(paneCount - 1, p + dir));
const n = Math.max(1, Math.min(paneCount, p + dir));
return n;
});
};
// ── tab root (the app's outermost pane) ──────────────────────────────────
/** True while focus is on the tab list as the CURRENT pane — the app root,
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings)
* participate; Search & Player are special and always show their content. */
const atRootTab = (): boolean =>
atRootTabSignal() && DEPTH_TABS.has(activeTab());
/** Open the active tab's content: the tab slides from CURRENT into the
* UP/parent pane and focus lands on the content's current pane. */
const enterTabContent = () => {
setAtTabRoot(false);
setActivePane(DEPTH_CENTER_PANE);
};
/** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping
* out of content at depth 0. */
const backToTabRoot = () => {
setAtTabRoot(true);
setActivePane(DEPTH_CENTER_PANE);
};
/** The tab the root's cursor is hovering (independent of activeTab). */
const tabCursor = (): TABS => tabCursorSignal();
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
const moveTabCursor = (dir: -1 | 1) => {
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
};
/** Open the hovered tab (switch to it and enter its content) from the root.
* The yazi "open" of a tab row. */
const activateTabCursor = () => {
switchTab(tabCursorSignal());
enterTabContent();
};
// ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
@@ -283,8 +359,7 @@ export function createNavigation() {
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> =>
selections()[key] ?? new Set();
const selSet = (key: string): Set<string> => selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
@@ -413,6 +488,13 @@ export function createNavigation() {
setActiveTab: gotoTab,
nextTab,
prevTab,
// tab root (app's outermost pane)
atRootTab,
enterTabContent,
backToTabRoot,
tabCursor,
moveTabCursor,
activateTabCursor,
// pane focus
setActivePane: setPane,
swipe,

View File

@@ -29,6 +29,7 @@ import {
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const DiscoverPaneCount = 1;
@@ -151,7 +152,7 @@ function DiscoverPage() {
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1}>
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}>
{(cat, index) => (
<box
@@ -179,80 +180,29 @@ function DiscoverPage() {
{/* depth 0: categories */}
<Show when={depth() === 0}>
<For each={categories()}>
{(cat, index) => {
const lf = focusedCatIdx();
const selected = () => cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
*
</text>
</Show>
</box>
);
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = focusedPodIdx();
{(cat, index) => {
const lf = focusedCatIdx();
const selected = () => cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="column"
gap={0}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text fg={index() === lf ? theme.surface : theme.success}>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
*
</text>
</Show>
</box>
@@ -260,6 +210,57 @@ function DiscoverPage() {
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = focusedPodIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text fg={index() === lf ? theme.surface : theme.success}>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
);
}}
</For>
</Show>
</Show>
</>
);
@@ -316,8 +317,7 @@ function DiscoverPage() {
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{pod().description?.slice(0, 400) ?? "No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>

View File

@@ -36,6 +36,7 @@ import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const FeedPaneCount = 1;
@@ -237,7 +238,7 @@ function FeedPage() {
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
// mounts/unmounts cleanly on depth change instead of swapping roots.
const parentContent = () => (
<Show when={depth() >= 1}>
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={feedList()}>
{(item, index) => {
const lf = nav.depthFocus(0);
@@ -268,110 +269,110 @@ function FeedPage() {
<Show when={depth() === 0}>
<Show
when={feedList().length > 1}
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const fi = focusedFeedIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const fi = focusedFeedIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
</Show>
<Show when={depth() >= 1}>
{/* depth ≥1: episodes */}
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
</Show>
<text fg={focusFg(index(), fi, isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
</Show>
</>
);
@@ -439,12 +440,8 @@ function FeedPage() {
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>
{formatDate(it.episode.pubDate)}
</text>
<text fg={muted()}>
{formatDuration(it.episode.duration)}
</text>
<text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(it.episode.duration)}</text>
<Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(it.episode.id)}>
{downloadLabel(it.episode.id)}

View File

@@ -32,6 +32,7 @@ import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const MyShowsPaneCount = 1;
@@ -200,7 +201,7 @@ export function MyShowsPage() {
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1}>
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={shows()}>
{(feed, index) => {
const lf = nav.depthFocus(0);
@@ -231,105 +232,105 @@ export function MyShowsPage() {
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => {
const lf = focusedShowIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show>
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => {
const lf = focusedShowIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
</Show>
<text fg={focusFg(index(), lf, isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
</Show>
</>
);
@@ -357,8 +358,7 @@ export function MyShowsPage() {
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ??
"No description."}
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back</text>
@@ -397,8 +397,7 @@ export function MyShowsPage() {
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ??
"No description available."}
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />

View File

@@ -1,12 +1,13 @@
/**
* SearchPage — yazi-style 3-pane view.
*
* pane 0 (parent) — query input with recent-search history (clickable)
* pane 1 (current) — search results list (navigate j/k)
* pane 2 (preview) — detail of the focused search result
* pane 1 (parent) — query input with recent-search history (clickable)
* pane 2 (current) — search results list (navigate j/k)
* pane 3 (preview) — detail of the focused search result
*
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
* the results pane. Swipe left (h) to pane 0 to type a query — the Shell
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
* on tab enter so the user lands on the results pane. Swipe left (h) to pane
* 1 to type a query — the Shell
* router skips keys while `nav.inputFocused()` is true so the `<input>`
* element captures typing natively. Press Enter (onSubmit) to search and
* auto-swipe to the results pane.
@@ -44,9 +45,9 @@ function SearchPage() {
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const INPUT = PaneSlot.PARENT; // 0
const RESULTS = PaneSlot.CURRENT; // 1
const DETAIL = PaneSlot.PREVIEW; // 2
const INPUT = PaneSlot.PARENT; // 1 (input row)
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
const results = () => searchStore.results();

View File

@@ -33,6 +33,7 @@ import { useVisualizerItems } from "./VisualizerSettings";
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
import { useSourceItems } from "./SourceManager";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const SettingsPaneCount = 1;
@@ -268,6 +269,10 @@ export function SettingsPage() {
// root whose inner <Show> children swap instead.
const parentContent = () => (
<>
<Show when={depth() === 0}>
{/* app root: the tab list as the parent (muted) at the lowest depth */}
<TabListPane muted />
</Show>
<Show when={depth() === 1}>
{/* previous depth = sections list (read-only) */}
<For each={SECTIONS}>

View File

@@ -9,23 +9,29 @@
*
* The Shell builds a `DispatcherDeps` from its live hooks + the audio-side
* `advanceEpisode` helper, then forwards every matched keystroke action to
* `dispatch`. Behavioural rules (task 06):
* `dispatch`. Behavioural rules (tab root + task 06):
*
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`) are
* the SOLE tab switchers; focus always lands on `DEPTH_CENTER_PANE`.
* `h`/`l` are `swipe-prev`/`swipe-next`:
* • The tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is
* the CURRENT pane with nothing above it:
* `k`/`j` (`move-down`/`move-up`) move a cursor through the tabs
* (highlight follows `tabCursor`; the active tab is untouched),
* `l`/Enter (`swipe-next`/`open`) open the hovered tab (`activateTabCursor`)
* — the tab slides into UP and its content becomes CURRENT; `h`
* (`swipe-prev`) at the root stays (out of the panes); `1-6` / `[`/`]`
* switch tabs directly (re-syncing the cursor).
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
* switch tabs; focus keeps its context (root iff already at the root,
* otherwise the content `DEPTH_CENTER_PANE`).
* • `h`/`l` are `swipe-prev`/`swipe-next` in content:
* - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth
* (noop + inert at depth 0 — no error, no pane change, like yazi at root)
* - fixed-pane tabs: `swipe(∓1, count)` clamped to [0, paneCount-1]
* when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
* where the tab becomes CURRENT again.
* - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to
* [1, paneCount]; `h` on the first content pane stays (no tab overflow).
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
* `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only.
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
* are unchanged from the pre-rewrite Shell.
*
* There is NO sidebar pane and NO `SIDEBAR_ACTIONS` set here (removed in task
* 06): the sidebar's special-cased j/k branch is gone; every list movement
* goes straight to the active page via `nav.action`.
*/
* are unchanged from the pre-rewrite Shell. */
import type { KeybindActionName } from "@/context/KeybindContext";
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
@@ -48,29 +54,30 @@ export type NavActionEvent = {
/** Actions the active page is responsible for (pane/list-local). These flow
* to the current pane only via `emit("nav.action", …)`. There is no
* SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */
export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>([
"move-down",
"move-up",
"page-down",
"page-up",
"full-down",
"full-up",
"jump-down",
"jump-up",
"goto-top",
"goto-bottom",
"toggle-select",
"visual-mode",
"toggle-all",
"invert-all",
"open",
"open-interactive",
"search",
"filter",
"sort",
"toggle-hidden",
"refresh",
]);
export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
new Set<KeybindActionName>([
"move-down",
"move-up",
"page-down",
"page-up",
"full-down",
"full-up",
"jump-down",
"jump-up",
"goto-top",
"goto-bottom",
"toggle-select",
"visual-mode",
"toggle-all",
"invert-all",
"open",
"open-interactive",
"search",
"filter",
"sort",
"toggle-hidden",
"refresh",
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
export function tabByDigit(action: KeybindActionName): TABS | null {
@@ -100,7 +107,10 @@ export type DispatcherDeps = {
export function createDispatcher(deps: DispatcherDeps) {
const { nav, audio, k, setShowHelp, advanceEpisode } = deps;
function dispatch(action: KeybindActionName, evt: { preventDefault: () => void }) {
function dispatch(
action: KeybindActionName,
evt: { preventDefault: () => void },
) {
const tab = nav.activeTab();
const pane = nav.activePane();
switch (action) {
@@ -147,22 +157,44 @@ export function createDispatcher(deps: DispatcherDeps) {
nav.setActiveTab(dt);
break;
}
// ── tab root focus ──
// At the app root the tab list is the CURRENT pane: j/k move the tab
// cursor (active tab untouched), l/Enter open the hovered tab (switch
// to it + enter its content), h stays inert (out of the panes).
if (nav.atRootTab()) {
evt.preventDefault();
if (action === "move-down") {
nav.moveTabCursor(1);
break;
}
if (action === "move-up") {
nav.moveTabCursor(-1);
break;
}
if (action === "open") {
nav.activateTabCursor();
break;
}
if (action === "swipe-next") {
nav.activateTabCursor();
break;
}
if (action === "swipe-prev") break;
}
// ── pane swipe / depth nav ──
// h/l swipe() on fixed-pane tabs (clamped to [0, paneCount-1] —
// there is no sidebar pane). Depth-tabs: l at the center drills
// in (emits `open`); h at the center pops a depth, noop at depth 0
// (inert — like yazi at root: no pane change, no error).
// Depth-tabs: l at the center drills in (emits `open`); h at the
// center pops a depth; at depth 0 h returns to the tab root (the tab
// becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
// h/l swipe across [1, paneCount]; h on the first pane stays.
if (action === "swipe-prev") {
evt.preventDefault();
if (
nav.isDepthTab() &&
nav.activePane() === DEPTH_CENTER_PANE
) {
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
if (nav.currentDepth() > 0) nav.popDepth();
// else: noop at depth 0 (inert)
} else {
nav.swipe(-1, TabPaneCount[tab]);
else nav.backToTabRoot(); // depth 0 → tab root
} else if (nav.activePane() > DEPTH_CENTER_PANE) {
nav.swipe(-1, TabPaneCount[tab]); // content 1..n
}
// fixed first content pane (1): no-op, stays (special tabs)
break;
}
if (action === "swipe-next") {