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

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
.harness/ .harness/
.ralpi

1
notes.md Normal file
View File

@@ -0,0 +1 @@
- [ ] Audio play can survive quit out

View File

@@ -15,10 +15,7 @@ import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid"; import { useKeyboard } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { import { useNavigation, NavMode } from "@/context/NavigationContext";
useNavigation,
NavMode,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
@@ -28,6 +25,8 @@ import { emit } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph"; import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation"; import { TABS, TabPaneCount } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch"; import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { YaziPaneRow } from "@/components/YaziPaneRow";
const TAB_LABEL: Record<TABS, string> = { const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed", [TABS.FEED]: "Feed",
@@ -94,7 +93,7 @@ export function Shell() {
case "q": case "q":
case "quit": case "quit":
case "exit": case "exit":
process.exit(0); return process.exit(0);
case "refresh": case "refresh":
case "r": case "r":
emit("nav.action", { emit("nav.action", {
@@ -190,7 +189,10 @@ export function Shell() {
// ── Unified router (normal + visual) ─────────────────────────────────────── // ── Unified router (normal + visual) ───────────────────────────────────────
const { dispatch } = createDispatcher({ const { dispatch } = createDispatcher({
nav, nav,
audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative }, audio: {
togglePlayback: audio.togglePlayback,
seekRelative: audio.seekRelative,
},
k, k,
setShowHelp, setShowHelp,
advanceEpisode, advanceEpisode,
@@ -232,11 +234,36 @@ export function Shell() {
height="100%" height="100%"
backgroundColor={t.surface} backgroundColor={t.surface}
> >
{/* ── Middle row: full-width active page ──────────────────────────────── */} {/* ── 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%"> <box flexGrow={1} width="100%">
{LayerGraph[nav.activeTab()]()} {LayerGraph[nav.activeTab()]()}
</box> </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 ─────────────────────────────────────── */} {/* ── Bottom status / command bar ─────────────────────────────────────── */}
<box <box
flexDirection="row" flexDirection="row"
@@ -252,10 +279,13 @@ export function Shell() {
{modeLabel()} {modeLabel()}
</text> </text>
<text fg={t.textMuted} paddingLeft={1}> <text fg={t.textMuted} paddingLeft={1}>
{TAB_LABEL[nav.activeTab()]} ·{" "} {nav.atRootTab()
{nav.isDepthTab() ? "Tabs · root"
: `${TAB_LABEL[nav.activeTab()]} · ${
nav.isDepthTab()
? `depth ${nav.currentDepth()}` ? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`} : `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
}`}
</text> </text>
<Show when={nav.selectedIds().length > 0}> <Show when={nav.selectedIds().length > 0}>
<text fg={t.warning} paddingLeft={1}> <text fg={t.warning} paddingLeft={1}>
@@ -271,27 +301,6 @@ export function Shell() {
<text fg={t.textMuted} paddingRight={1}> <text fg={t.textMuted} paddingRight={1}>
{pendingLabel()} {pendingLabel()}
</text> </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 fg={t.textMuted} paddingRight={1}>
~ ~
</text> </text>
@@ -310,7 +319,6 @@ export function Shell() {
</Show> </Show>
</Show> </Show>
</box> </box>
{/* ── Help overlay ─────────────────────────────────────────────────────── */} {/* ── Help overlay ─────────────────────────────────────────────────────── */}
<Show when={showHelp()}> <Show when={showHelp()}>
<HelpOverlay <HelpOverlay
@@ -338,7 +346,9 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
{ {
group: "Panes", group: "Panes",
items: [ 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"], ["1-6 / [ ]", "switch tabs"],
[":", "command"], [":", "command"],
["~", "help"], ["~", "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 * nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused. * 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 * • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
* focusable pane — the current column (DEPTH_CENTER_PANE = 0). The parent * focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The
* column renders the previous depth's list (blank at depth 0); the preview * parent column renders the previous depth's list (blank at depth 0); the
* column renders the hovered item. `l`/Enter drills in (push a frame); * preview column renders the hovered item. `l`/Enter drills in (push a
* `h` pops a depth (a noop at depth 0). Depth is unbounded — each page * frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus
* decides per-item whether an item is drillable and what child list to * to the tab list (TAB_PANE).
* push. Drill/pop is dispatched by the Shell, never via swipe.
* *
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the * • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between 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 * Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
* strip. There is NO sidebar pane: `activePane` is plain tab pane state and is * Focus on the tab list persists across a tab switch; from there `l`/Enter
* never a chrome/tab-list pane. * drops into the active tab's content (panes 1..N).
*/ */
import { createSignal, batch } from "solid-js"; import { createSignal, batch } from "solid-js";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
@@ -46,19 +54,28 @@ export enum NavMode {
INPUT = "INPUT", INPUT = "INPUT",
} }
/** The current pane. For depth-tabs this is the single focusable pane (the /** The current content pane of the active tab, i.e. the focusable column
* center column, index 0); for fixed-pane tabs it's the default landing pane * (index 1) for depth-tabs, and the default landing pane for fixed-pane
* on tab-enter. Every tab-enter resets `activePane` to this value. */ * tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
export const DEPTH_CENTER_PANE = 0 as PaneId; * 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 { export enum PaneSlot {
PARENT = 0, // depth-tabs: center/current; Search: input PARENT = 1, // Search: input
CURRENT = 1, // Search: results CURRENT = 2, // Search: results
PREVIEW = 2, // Search: detail 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 ────────────────────────────────────────────────────────────── // ── Depth stack ──────────────────────────────────────────────────────────────
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined, /** 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() { export function createNavigation() {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED); const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
// App focus starts on the current pane (center, idx 0); every tab // The root tab panel's cursor — which tab j/k is currently hovering. It is
// switch also resets here. There is no sidebar pane. // independent of `activeTab` until l/Enter activates it (activateTabCursor)
const [activePane, setActivePane] = // or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
createSignal<PaneId>(DEPTH_CENTER_PANE); // 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 [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null); const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false);
// per-tab depth stack. Depth-tabs get a root frame on first visit. // per-tab depth stack. Depth-tabs get a root frame on first visit.
const [stacks, setStacks] = createSignal< const [stacks, setStacks] = createSignal<Partial<Record<TABS, DepthFrame[]>>>(
Partial<Record<TABS, DepthFrame[]>> { [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
>({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] }); );
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed // 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` // by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
// for pane 0 (DEPTH_CENTER_PANE) instead. // for pane 0 (DEPTH_CENTER_PANE) instead.
const [paneIndices, setPaneIndices] = createSignal< const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
Record<string, number> {},
>({}); );
const [selections, setSelections] = createSignal<SelectionMap>({}); const [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{ const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string; paneKey: string;
@@ -132,13 +159,23 @@ export function createNavigation() {
* no-op (server build). Routing every tab change through this helper * no-op (server build). Routing every tab change through this helper
* keeps the behavior identical under both runtimes. * keeps the behavior identical under both runtimes.
* *
* - seed a root frame for fresh depth-tabs * - when switching to a special (fixed-pane) tab from the tab root, leave the
* - reset focus to the current/center pane (no sidebar pane) * 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 */ * - clear mode/command/visual/count state */
const applyTabSwitch = (tab: TABS) => { const applyTabSwitch = (tab: TABS) => {
ensureStack(tab); ensureStack(tab);
batch(() => { batch(() => {
// 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); setActivePane(DEPTH_CENTER_PANE);
}
setMode(NavMode.NORMAL); setMode(NavMode.NORMAL);
setCount(null); setCount(null);
setCommandBuffer(""); setCommandBuffer("");
@@ -154,8 +191,7 @@ export function createNavigation() {
// (used by unit tests) createMemo is a no-op that freezes at creation, // (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. // so a plain function is the only option that stays correct in tests.
const depthStack = (): DepthFrame[] => depthStackFor(activeTab()); const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
const currentDepth = (): number => const currentDepth = (): number => Math.max(0, depthStack().length - 1);
Math.max(0, depthStack().length - 1);
const topFrame = (): DepthFrame | undefined => const topFrame = (): DepthFrame | undefined =>
depthStack()[depthStack().length - 1]; depthStack()[depthStack().length - 1];
const isDepthTab = () => DEPTH_TABS.has(activeTab()); const isDepthTab = () => DEPTH_TABS.has(activeTab());
@@ -199,6 +235,9 @@ export function createNavigation() {
* tab change — programmatic or key-driven — goes through one path. */ * tab change — programmatic or key-driven — goes through one path. */
const switchTab = (tab: TABS) => { const switchTab = (tab: TABS) => {
setActiveTab(tab); 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); applyTabSwitch(tab);
}; };
const gotoTab = (tab: TABS) => { const gotoTab = (tab: TABS) => {
@@ -217,16 +256,53 @@ export function createNavigation() {
// ── pane focus ────────────────────────────────────────────────────────── // ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane); 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 * -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) => { const swipe = (dir: -1 | 1, paneCount: number) => {
setActivePane((p) => { 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; 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 ──────────────────────────────────────────────── // ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
@@ -283,8 +359,7 @@ export function createNavigation() {
}; };
// ── selection ─────────────────────────────────────────────────────────── // ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> => const selSet = (key: string): Set<string> => selections()[key] ?? new Set();
selections()[key] ?? new Set();
const toggleSelected = (id: string) => { const toggleSelected = (id: string) => {
const key = paneKey(); const key = paneKey();
@@ -413,6 +488,13 @@ export function createNavigation() {
setActiveTab: gotoTab, setActiveTab: gotoTab,
nextTab, nextTab,
prevTab, prevTab,
// tab root (app's outermost pane)
atRootTab,
enterTabContent,
backToTabRoot,
tabCursor,
moveTabCursor,
activateTabCursor,
// pane focus // pane focus
setActivePane: setPane, setActivePane: setPane,
swipe, swipe,

View File

@@ -29,6 +29,7 @@ import {
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 { YaziPaneRow } from "@/components/YaziPaneRow"; import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const DiscoverPaneCount = 1; export const DiscoverPaneCount = 1;
@@ -151,7 +152,7 @@ function DiscoverPage() {
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1}> <Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => ( {(cat, index) => (
<box <box
@@ -316,8 +317,7 @@ function DiscoverPage() {
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ?? {pod().description?.slice(0, 400) ?? "No description available."}
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""} {(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<Show when={(pod().categories ?? []).length > 0}> <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 type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
@@ -237,7 +238,7 @@ function FeedPage() {
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list // Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
// mounts/unmounts cleanly on depth change instead of swapping roots. // mounts/unmounts cleanly on depth change instead of swapping roots.
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1}> <Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={feedList()}> <For each={feedList()}>
{(item, index) => { {(item, index) => {
const lf = nav.depthFocus(0); const lf = nav.depthFocus(0);
@@ -439,12 +440,8 @@ function FeedPage() {
</strong> </strong>
</text> </text>
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
<text fg={theme.info}> <text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
{formatDate(it.episode.pubDate)} <text fg={muted()}>{formatDuration(it.episode.duration)}</text>
</text>
<text fg={muted()}>
{formatDuration(it.episode.duration)}
</text>
<Show when={downloadLabel(it.episode.id)}> <Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(it.episode.id)}> <text fg={downloadColor(it.episode.id)}>
{downloadLabel(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 type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { YaziPaneRow } from "@/components/YaziPaneRow"; import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const MyShowsPaneCount = 1; export const MyShowsPaneCount = 1;
@@ -200,7 +201,7 @@ export function MyShowsPage() {
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (
<Show when={depth() >= 1}> <Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={shows()}> <For each={shows()}>
{(feed, index) => { {(feed, index) => {
const lf = nav.depthFocus(0); const lf = nav.depthFocus(0);
@@ -357,8 +358,7 @@ export function MyShowsPage() {
{show().episodes.length} episodes {show().episodes.length} episodes
</text> </text>
<text fg={muted()}> <text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? {show().podcast.description?.slice(0, 400) ?? "No description."}
"No description."}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter/l: open · h: back</text> <text fg={muted()}>enter/l: open · h: back</text>
@@ -397,8 +397,7 @@ export function MyShowsPage() {
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ?? {ep().description?.slice(0, 400) ?? "No description available."}
"No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""} {(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<box height={1} /> <box height={1} />

View File

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

View File

@@ -33,6 +33,7 @@ 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"; import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const SettingsPaneCount = 1; export const SettingsPaneCount = 1;
@@ -268,6 +269,10 @@ export function SettingsPage() {
// root whose inner <Show> children swap instead. // root whose inner <Show> children swap instead.
const parentContent = () => ( 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}> <Show when={depth() === 1}>
{/* previous depth = sections list (read-only) */} {/* previous depth = sections list (read-only) */}
<For each={SECTIONS}> <For each={SECTIONS}>

View File

@@ -9,23 +9,29 @@
* *
* The Shell builds a `DispatcherDeps` from its live hooks + the audio-side * The Shell builds a `DispatcherDeps` from its live hooks + the audio-side
* `advanceEpisode` helper, then forwards every matched keystroke action to * `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 tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is
* the SOLE tab switchers; focus always lands on `DEPTH_CENTER_PANE`. * the CURRENT pane with nothing above it:
* `h`/`l` are `swipe-prev`/`swipe-next`: * `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 * - 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) * when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
* - fixed-pane tabs: `swipe(∓1, count)` clamped to [0, paneCount-1] * 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 * • 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 * • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
* are unchanged from the pre-rewrite Shell. * 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`.
*/
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { NavigationState, DepthFrame } from "@/context/navigation-store"; import type { NavigationState, DepthFrame } from "@/context/navigation-store";
@@ -48,7 +54,8 @@ export type NavActionEvent = {
/** Actions the active page is responsible for (pane/list-local). These flow /** Actions the active page is responsible for (pane/list-local). These flow
* to the current pane only via `emit("nav.action", …)`. There is no * to the current pane only via `emit("nav.action", …)`. There is no
* SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */ * SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */
export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>([ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
new Set<KeybindActionName>([
"move-down", "move-down",
"move-up", "move-up",
"page-down", "page-down",
@@ -100,7 +107,10 @@ export type DispatcherDeps = {
export function createDispatcher(deps: DispatcherDeps) { export function createDispatcher(deps: DispatcherDeps) {
const { nav, audio, k, setShowHelp, advanceEpisode } = deps; 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 tab = nav.activeTab();
const pane = nav.activePane(); const pane = nav.activePane();
switch (action) { switch (action) {
@@ -147,22 +157,44 @@ export function createDispatcher(deps: DispatcherDeps) {
nav.setActiveTab(dt); nav.setActiveTab(dt);
break; 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 ── // ── pane swipe / depth nav ──
// h/l swipe() on fixed-pane tabs (clamped to [0, paneCount-1] — // Depth-tabs: l at the center drills in (emits `open`); h at the
// there is no sidebar pane). Depth-tabs: l at the center drills // center pops a depth; at depth 0 h returns to the tab root (the tab
// in (emits `open`); h at the center pops a depth, noop at depth 0 // becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
// (inert — like yazi at root: no pane change, no error). // h/l swipe across [1, paneCount]; h on the first pane stays.
if (action === "swipe-prev") { if (action === "swipe-prev") {
evt.preventDefault(); evt.preventDefault();
if ( if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
nav.isDepthTab() &&
nav.activePane() === DEPTH_CENTER_PANE
) {
if (nav.currentDepth() > 0) nav.popDepth(); if (nav.currentDepth() > 0) nav.popDepth();
// else: noop at depth 0 (inert) else nav.backToTabRoot(); // depth 0 → tab root
} else { } else if (nav.activePane() > DEPTH_CENTER_PANE) {
nav.swipe(-1, TabPaneCount[tab]); nav.swipe(-1, TabPaneCount[tab]); // content 1..n
} }
// fixed first content pane (1): no-op, stays (special tabs)
break; break;
} }
if (action === "swipe-next") { if (action === "swipe-next") {

View File

@@ -6,13 +6,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done
## Tasks ## Tasks
- [ ] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md` - [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
- [ ] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md` - [x] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
- [ ] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md` - [x] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
- [ ] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md` - [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
- [ ] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md` - [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
- [ ] 06 — rewire-keybinds → `06-rewire-keybinds.md` - [x] 06 — rewire-keybinds → `06-rewire-keybinds.md`
- [ ] 07 — verify-remake → `07-verify-remake.md` - [x] 07 — verify-remake → `07-verify-remake.md`
## Dependencies ## Dependencies

View File

@@ -1,27 +1,33 @@
/** /**
* dispatch-keybinds.test.ts — yazi remake task 06 unit + integration tests. * dispatch-keybinds.test.ts — yazi remake task 06/07 unit + integration tests.
* *
* Exercises the rewired unified keybind router (`createDispatcher`) directly, * Exercises the rewired unified keybind router (`createDispatcher`) directly,
* without the OpenTUI render tree, mirroring how task 01 made the nav store a * without the OpenTUI render tree, mirroring how task 01 made the nav store a
* plain factory for `bun test`. Covers the task 06 acceptance + integration * plain factory for `bun test`. Covers the task 06 acceptance + integration
* cases: * cases, plus the tab-root routing:
* *
* • Unit: `dispatch("move-down")` on a depth-tab current pane emits * • Tab root: j/k (`move-down`/`move-up`) move a tab cursor without touching
* `nav.action { action: "move-down" }` pointing at the current pane only. * the active tab; `open`/`swipe-next` (l/Enter) open the hovered tab and
* • Integration: `swipe-next` (l) on a depth-tab at depth 0 emits `open` * enter its content; `swipe-prev` (h) stays inert (out of the panes).
* (drill); `swipe-prev` (h) at depth 1 pops to depth 0; `swipe-prev` at * • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
* depth 0 is an inert noop (no emit, no pane/depth change, no error). * `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
* • Acceptance: digit keys (`tab-goto-N`) switch tabs and focus lands on * • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
* `DEPTH_CENTER_PANE`; `tab-next`/`tab-prev` cycle; `h` at depth 0 is * `h` on the first pane stays — never overflows to the tab root.
* inert; `j`/`k` (move-down/up) flow to `nav.action` for the current pane. * • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
* tabs and preserve focus context (root stays root for depth-tabs, content
* stays content).
* • `j`/`k` (move-down/up) in content flow to `nav.action` for the current
* pane.
* *
* The dispatcher is built with fake audio/k/help deps (the drills/moves under * The dispatcher is built with fake audio/k/help deps (the paths under test
* test never reach the audio or advanceEpisode paths) and a real nav store * never reach the audio or advanceEpisode branches) and a real nav store.
* the shared contract depth/l movement routes through.
*/ */
import { test, expect, mock } from "bun:test"; import { test, expect, mock } from "bun:test";
import { createRoot } from "solid-js"; import { createRoot } from "solid-js";
import { createNavigation, DEPTH_CENTER_PANE } from "../src/context/navigation-store"; import {
createNavigation,
DEPTH_CENTER_PANE,
} from "../src/context/navigation-store";
import { TABS } from "../src/utils/navigation"; import { TABS } from "../src/utils/navigation";
import { createDispatcher, type DispatcherDeps } from "../src/utils/dispatch"; import { createDispatcher, type DispatcherDeps } from "../src/utils/dispatch";
import { on } from "../src/utils/event-bus"; import { on } from "../src/utils/event-bus";
@@ -30,16 +36,17 @@ import type { KeybindActionName } from "../src/context/KeybindContext";
/** Build a real nav store + a dispatcher wired to fake audio/k/help deps, all /** Build a real nav store + a dispatcher wired to fake audio/k/help deps, all
* inside a reactive root (disposed after). Returns both so a test can read * inside a reactive root (disposed after). Returns both so a test can read
* nav state and call dispatch. */ * nav state and call dispatch. */
function withHarness(fn: (api: { function withHarness(
fn: (api: {
nav: ReturnType<typeof createNavigation>; nav: ReturnType<typeof createNavigation>;
dispatch: (action: KeybindActionName) => void; dispatch: (action: KeybindActionName) => void;
toggleHelp: () => boolean; toggleHelp: () => boolean;
helpOpen: () => boolean; helpOpen: () => boolean;
}) => void) { }) => void,
) {
createRoot((dispose) => { createRoot((dispose) => {
const nav = createNavigation(); const nav = createNavigation();
let help = false; let help = false;
const toggleHelp = () => (help = !help);
const deps: DispatcherDeps = { const deps: DispatcherDeps = {
nav, nav,
audio: { audio: {
@@ -57,7 +64,7 @@ function withHarness(fn: (api: {
fn({ fn({
nav, nav,
dispatch: (action) => dispatch(action, evt() as any), dispatch: (action) => dispatch(action, evt() as any),
toggleHelp, toggleHelp: () => (help = !help),
helpOpen: () => help, helpOpen: () => help,
}); });
dispose(); dispose();
@@ -78,10 +85,66 @@ function captureNavActions(fn: () => void) {
return captured; return captured;
} }
// ── Unit: move-down emits nav.action on the current pane only ───────────────── // ── Tab root: j/k move the cursor; l/Enter open the hovered tab ──────────────
test("dispatch('move-down') on the tab root moves the cursor (no emit, active tab untouched)", () => {
withHarness(({ nav, dispatch }) => {
expect(nav.atRootTab()).toBe(true);
expect(nav.tabCursor()).toBe(TABS.FEED);
const events = captureNavActions(() => dispatch("move-down"));
expect(events).toHaveLength(0); // j on the root moves the cursor only
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
expect(nav.activeTab()).toBe(TABS.FEED); // active tab untouched until opened
expect(nav.atRootTab()).toBe(true);
});
});
test("dispatch('move-up') on the tab root moves the cursor up (clamped, no wrap)", () => {
withHarness(({ nav, dispatch }) => {
expect(nav.tabCursor()).toBe(TABS.FEED);
dispatch("move-up");
expect(nav.tabCursor()).toBe(TABS.FEED); // clamped at the top
expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
});
});
test("dispatch('open') on the tab root opens the hovered tab and enters its content", () => {
withHarness(({ nav, dispatch }) => {
dispatch("move-down"); // cursor -> MYSHOWS
dispatch("move-down"); // cursor -> DISCOVER
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
dispatch("open");
expect(nav.activeTab()).toBe(TABS.DISCOVER); // the hovered tab is opened
expect(nav.atRootTab()).toBe(false);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
test("dispatch('swipe-next') on the tab root opens the hovered tab and enters its content (no emit)", () => {
withHarness(({ nav, dispatch }) => {
dispatch("move-down"); // cursor -> MYSHOWS
const events = captureNavActions(() => dispatch("swipe-next"));
expect(events).toHaveLength(0);
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(false);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
test("dispatch('swipe-prev') on the tab root is inert (stays, no emit)", () => {
withHarness(({ nav, dispatch }) => {
const events = captureNavActions(() => dispatch("swipe-prev"));
expect(events).toHaveLength(0);
expect(nav.atRootTab()).toBe(true);
});
});
// ── Unit: move-down emits nav.action on the current pane only ────────────────
test("dispatch('move-down') on a depth-tab current pane emits nav.action {action:'move-down'} on the current pane", () => { test("dispatch('move-down') on a depth-tab current pane emits nav.action {action:'move-down'} on the current pane", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.FEED); // depth-tab → focus is the center pane nav.setActiveTab(TABS.FEED); // depth-tab → enter content to test the list move
nav.enterTabContent();
expect(nav.isDepthTab()).toBe(true); expect(nav.isDepthTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
@@ -96,21 +159,22 @@ test("dispatch('move-down') on a depth-tab current pane emits nav.action {action
test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => { test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.MYSHOWS); nav.setActiveTab(TABS.MYSHOWS);
nav.enterTabContent();
const beforeDepth = nav.currentDepth(); const beforeDepth = nav.currentDepth();
const beforePane = nav.activePane();
const events = captureNavActions(() => dispatch("move-up")); const events = captureNavActions(() => dispatch("move-up"));
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0].action).toBe("move-up"); expect(events[0].action).toBe("move-up");
expect(events[0].pane).toBe(beforePane); expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
// depth is untouched by j/k (only h/l and the page's open() touch it). // depth is untouched by j/k (only h/l and the page's open() touch it).
expect(nav.currentDepth()).toBe(beforeDepth); expect(nav.currentDepth()).toBe(beforeDepth);
}); });
}); });
// ── Integration: l drills (open emit), h pops, h@0 noop ────────────────────── // ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => { test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.DISCOVER); nav.setActiveTab(TABS.DISCOVER);
nav.enterTabContent();
expect(nav.isDepthTab()).toBe(true); expect(nav.isDepthTab()).toBe(true);
expect(nav.currentDepth()).toBe(0); expect(nav.currentDepth()).toBe(0);
@@ -126,6 +190,7 @@ test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", ()
test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => { test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.FEED); nav.setActiveTab(TABS.FEED);
nav.enterTabContent();
// simulate the page's open() having drilled one level. // simulate the page's open() having drilled one level.
nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 }); nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 });
expect(nav.currentDepth()).toBe(1); expect(nav.currentDepth()).toBe(1);
@@ -133,54 +198,105 @@ test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
const events = captureNavActions(() => dispatch("swipe-prev")); const events = captureNavActions(() => dispatch("swipe-prev"));
expect(events).toHaveLength(0); // a pop emits nothing — it just pops expect(events).toHaveLength(0); // a pop emits nothing — it just pops
expect(nav.currentDepth()).toBe(0); expect(nav.currentDepth()).toBe(0);
// focus stays in content (depth > 0 pop does not return to the root).
expect(nav.atRootTab()).toBe(false);
}); });
}); });
test("dispatch('swipe-prev') at depth 0 is an inert noop (no emit, no pane/depth change, no error)", () => { test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SETTINGS); nav.setActiveTab(TABS.SETTINGS);
nav.enterTabContent();
expect(nav.currentDepth()).toBe(0); expect(nav.currentDepth()).toBe(0);
const paneBefore = nav.activePane(); expect(nav.atRootTab()).toBe(false);
const events = captureNavActions(() => dispatch("swipe-prev")); const events = captureNavActions(() => dispatch("swipe-prev"));
expect(events).toHaveLength(0); expect(events).toHaveLength(0);
expect(nav.currentDepth()).toBe(0); expect(nav.currentDepth()).toBe(0);
expect(nav.activePane()).toBe(paneBefore); expect(nav.atRootTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
}); });
}); });
// ── Acceptance: digit keys switch tabs and land focus on the current pane ── test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
test("tab-goto-N switches tabs; focus always lands on DEPTH_CENTER_PANE", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
// start on FEED (depth-tab, depth 0). nav.setActiveTab(TABS.SEARCH); // fixed-pane
expect(nav.activeTab()).toBe(TABS.FEED); nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
dispatch("swipe-prev");
// special tab: h on the first content pane does not return to the root.
expect(nav.atRootTab()).toBe(false);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // 3 panes
nav.enterTabContent();
nav.swipe(1, 3);
nav.swipe(1, 3);
expect(nav.activePane()).toBe(3);
dispatch("swipe-prev");
expect(nav.activePane()).toBe(2);
});
});
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => {
withHarness(({ nav, dispatch }) => {
// focus starts on the tab root.
expect(nav.atRootTab()).toBe(true);
dispatch("tab-goto-3"); // → Discover
expect(nav.activeTab()).toBe(TABS.DISCOVER);
expect(nav.tabCursor()).toBe(TABS.DISCOVER); // cursor re-synced
expect(nav.atRootTab()).toBe(true);
dispatch("tab-goto-2"); // → MyShows
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(true);
// fixed-pane tab is special: switching from the root opens its content.
dispatch("tab-goto-4"); // → Search
expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.tabCursor()).toBe(TABS.SEARCH);
expect(nav.atRootTab()).toBe(false);
});
});
test("tab-goto-N from content keeps focus in the active tab's content", () => {
withHarness(({ nav, dispatch }) => {
nav.enterTabContent();
expect(nav.atRootTab()).toBe(false);
dispatch("tab-goto-3"); // → Discover dispatch("tab-goto-3"); // → Discover
expect(nav.activeTab()).toBe(TABS.DISCOVER); expect(nav.activeTab()).toBe(TABS.DISCOVER);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
dispatch("tab-goto-2"); // → MyShows dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// fixed-pane tab also lands on pane 0.
dispatch("tab-goto-4"); // → Search
expect(nav.activeTab()).toBe(TABS.SEARCH); expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
}); });
}); });
test("tab-next (]) / tab-prev ([) cycle tabs and reset focus to the current pane", () => { test("tab-next (]) / tab-prev ([) cycle tabs and keep focus context", () => {
withHarness(({ nav, dispatch }) => { withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.FEED); expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
dispatch("tab-next"); dispatch("tab-next");
expect(nav.activeTab()).toBe(TABS.MYSHOWS); expect(nav.activeTab()).toBe(TABS.MYSHOWS);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(true);
dispatch("tab-prev"); dispatch("tab-prev");
expect(nav.activeTab()).toBe(TABS.FEED); expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.tabCursor()).toBe(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
}); });
}); });

View File

@@ -1,12 +1,20 @@
/** /**
* nav-model.test.ts — yazi remake task 01 unit/integration tests. * nav-model.test.ts — yazi remake task 01/07 unit/integration tests.
* *
* Covers the removal of the SIDEBAR_PANE concept: * Covers the tab-list-as-root navigation model:
* • createNavigation() exposes the nav factory directly (no Solid render * • createNavigation() exposes the nav factory directly (no Solid render
* needed), wrapped in a createRoot so effects register/dispose. * needed), wrapped in a createRoot so effects register/dispose.
* • focus starts on the tab list — the app root. `atRootTab()` is true while
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
* for the fixed-pane Search/Player tabs).
* • the root tab list is a normal list: `tabCursor` is independent of
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
* the hovered tab + enters content, and direct tab switches re-sync it.
* • depth-tab focusedIndex depth-current read/writes the top frame's focus. * • depth-tab focusedIndex depth-current read/writes the top frame's focus.
* • the tab-switch effect resets activePane to DEPTH_CENTER_PANE (0), not -1. * • swipe() is clamped to [1, paneCount] (content panes only; there is no
* • swipe() clamps to [0, paneCount-1] (no -1 sidebar slot). * pane-0 tab slot — the tab root is a flag, not a pane).
*/ */
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
import { createRoot } from "solid-js"; import { createRoot } from "solid-js";
@@ -27,17 +35,18 @@ function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
}); });
} }
test("createNavigation initial activePane is DEPTH_CENTER_PANE (0), not -1", () => { test("createNavigation starts on the tab root (atRootTab true)", () => {
withNav((nav) => { withNav((nav) => {
expect(nav.atRootTab()).toBe(true);
expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.activePane()).toBe(0);
}); });
}); });
// ── depth-tab focus: reads/writes the top frame's focus ─────────────────────── // ── depth-tab focus: reads/writes the top frame's focus ───────────────────────
test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => { test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => {
withNav((nav) => { withNav((nav) => {
// FEED is a depth-tab; its root frame is { kind: "feeds", focus: 0 }. // FeeD is a depth-tab; its root frame is a the top frame on the stack.
nav.setActiveTab(TABS.FEED); nav.setActiveTab(TABS.FEED);
expect(nav.isDepthTab()).toBe(true); expect(nav.isDepthTab()).toBe(true);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0); expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0);
@@ -52,6 +61,7 @@ test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () =
test("setFocusedIndex on a 2-frame stack writes only the top frame", () => { test("setFocusedIndex on a 2-frame stack writes only the top frame", () => {
withNav((nav) => { withNav((nav) => {
nav.setActiveTab(TABS.FEED); nav.setActiveTab(TABS.FEED);
nav.enterTabContent();
// root frame focus 3, then push a child frame whose focus is 5. // root frame focus 3, then push a child frame whose focus is 5.
nav.setFocusedIndex(DEPTH_CENTER_PANE, 3); nav.setFocusedIndex(DEPTH_CENTER_PANE, 3);
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 5 }); nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 5 });
@@ -79,19 +89,120 @@ test("popDepth at depth 0 is a noop (returns false, no frame lost)", () => {
}); });
}); });
// ── tab-switch resets focus to DEPTH_CENTER_PANE, not a sidebar ────────────── // ── tab switching keeps focus context ────────────────────────────────────────
test("tab-switch effect resets activePane to DEPTH_CENTER_PANE", () => { test("tab switch keeps focus context: at the root it stays at the root", () => {
withNav((nav) => { withNav((nav) => {
// start on a depth-tab, land on the current pane. // focus starts on the tab root.
expect(nav.atRootTab()).toBe(true);
// switching depth-tabs from the root must not drop focus into content.
nav.setActiveTab(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
expect(nav.activeTab()).toBe(TABS.FEED);
nav.setActiveTab(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(true);
});
});
test("tab switch keeps focus context: in content it stays in content", () => {
withNav((nav) => {
nav.enterTabContent();
expect(nav.atRootTab()).toBe(false);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// move pane focus away (swipe is a noop for depth-tabs count=1, so // switching tabs from content keeps the content context.
// instead prove the effect resets on tab change).
nav.setActiveTab(TABS.SEARCH);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// switch to another tab; the effect must reset to 0, never -1.
nav.setActiveTab(TABS.SETTINGS); nav.setActiveTab(TABS.SETTINGS);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.activePane()).toBeGreaterThanOrEqual(0); expect(nav.atRootTab()).toBe(false);
});
});
test("switching to a Search/Player tab leaves the root (special content)", () => {
withNav((nav) => {
// at root, opening Search is special: atRootTab() reports false because
// Search has its own content and never renders the tab-list root view.
nav.setActiveTab(TABS.SEARCH);
expect(nav.atRootTab()).toBe(false);
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
});
});
// ── tab root <-> content transitions ─────────────────────────────────────────
test("enterTabContent/backToTabRoot round-trip between root and content", () => {
withNav((nav) => {
nav.setActiveTab(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
nav.backToTabRoot();
expect(nav.atRootTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
test("enterTabContent preserves the active tab's depth", () => {
withNav((nav) => {
nav.setActiveTab(TABS.FEED);
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 0 });
expect(nav.currentDepth()).toBe(1);
// moving between the root and content never touches the depth stack.
nav.backToTabRoot();
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.currentDepth()).toBe(1);
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(0);
});
});
test("tabCursor starts on the active tab", () => {
withNav((nav) => {
expect(nav.tabCursor()).toBe(TABS.FEED);
expect(nav.activeTab()).toBe(TABS.FEED);
});
});
test("moveTabCursor moves the cursor without changing the active tab, clamped at the ends", () => {
withNav((nav) => {
nav.setActiveTab(TABS.MYSHOWS); // cursor syncs to the active tab
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
nav.moveTabCursor(1);
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
expect(nav.activeTab()).toBe(TABS.MYSHOWS); // active tab untouched
nav.moveTabCursor(-1);
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
// clamp: from FEED, up stays FEED; from SETTINGS, down stays SETTINGS.
nav.moveTabCursor(-1); // MYSHOWS -> FEED
nav.moveTabCursor(-1); // FEED -> FEED (clamped)
expect(nav.tabCursor()).toBe(TABS.FEED);
nav.setActiveTab(TABS.SETTINGS);
nav.moveTabCursor(1); // SETTINGS -> SETTINGS (clamped)
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
expect(nav.activeTab()).toBe(TABS.SETTINGS);
});
});
test("activateTabCursor switches to the hovered tab and enters its content", () => {
withNav((nav) => {
nav.moveTabCursor(1); // cursor -> MYSHOWS
nav.moveTabCursor(1); // cursor -> DISCOVER
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
expect(nav.activeTab()).toBe(TABS.FEED);
nav.activateTabCursor();
expect(nav.activeTab()).toBe(TABS.DISCOVER); // hovered tab opened
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
expect(nav.atRootTab()).toBe(false);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
test("direct tab switches re-sync the tab cursor", () => {
withNav((nav) => {
nav.moveTabCursor(1); // cursor -> MYSHOWS
expect(nav.activeTab()).toBe(TABS.FEED);
nav.setActiveTab(TABS.SETTINGS);
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
}); });
}); });
@@ -105,37 +216,44 @@ test("tab-switch resets mode/visual/command state", () => {
}); });
}); });
// ── swipe clamps to [0, paneCount-1] (no sidebar slot) ──────────────────────── // ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
test("swipe(-1, 3) on a fixed-pane tab clamps to 0, not -1", () => { test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
withNav((nav) => { withNav((nav) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3 nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
expect(TabPaneCount[TABS.SEARCH]).toBe(3); expect(TabPaneCount[TABS.SEARCH]).toBe(3);
// tab-switch effect lands us on pane 0 (DEPTH_CENTER_PANE). nav.enterTabContent();
expect(nav.activePane()).toBe(0); expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]); // swipe left stays at 1 (no pane 0).
expect(nav.activePane()).toBe(0); // lower bound, never -1 nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
// swipe right twice then back: clamps to [0, 2]. expect(nav.activePane()).toBe(1);
nav.swipe(1, 3); nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
nav.swipe(1, 3); expect(nav.activePane()).toBe(1);
expect(nav.activePane()).toBe(2); // upper bound // swipe right up through the columns, then hold the upper bound.
nav.swipe(1, 3); nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2); // never exceeds count-1 expect(nav.activePane()).toBe(2);
nav.swipe(-1, 3); nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(3);
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(3); // never exceeds paneCount
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1); expect(nav.activePane()).toBe(1);
nav.swipe(-1, 3);
expect(nav.activePane()).toBe(0);
}); });
}); });
test("swipe on a single-pane fixed tab stays at 0", () => { test("swipe on a single-pane fixed tab stays at its one content pane", () => {
withNav((nav) => { withNav((nav) => {
nav.setActiveTab(TABS.PLAYER); // single-pane nav.setActiveTab(TABS.PLAYER); // single-pane
expect(TabPaneCount[TABS.PLAYER]).toBe(1); expect(TabPaneCount[TABS.PLAYER]).toBe(1);
expect(nav.activePane()).toBe(0); nav.enterTabContent(); // lands on its one content pane (1)
expect(nav.activePane()).toBe(1);
nav.swipe(1, TabPaneCount[TABS.PLAYER]); nav.swipe(1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(0); expect(nav.activePane()).toBe(1); // upper bound
nav.swipe(-1, TabPaneCount[TABS.PLAYER]); nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(0); expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0
}); });
}); });