Compare commits

...

13 Commits

Author SHA1 Message Date
c8d29ed59d fix navigation flow 2026-08-07 00:28:24 -04:00
9e7a44309a Merge branch 'verify-remake' 2026-07-31 19:31:11 -04:00
6cd90ad6c0 docs(yazi-remake): mark task 07 blocked, file task 04 blocker for player/search panes
The verification gate found that Task 04's core deliverable was never
implemented: Player and Search pages are not routed through <YaziPaneRow>,
so Player renders a single full-width column and Search's three columns
sit at equal thirds (31/32/31) instead of the target 1:3:3 ratio
(~14/43/43).

- Mark task 07 status BLOCKED with a pointer to the blocker evidence
- Add 07-blocker-task-04-player-search.md documenting the missing
  implementation, evidence (player/search harness frames), failing exit
  criteria, and a fix plan for the task 04 do-over
2026-07-31 19:30:40 -04:00
6c0affc77b Merge branch 'rewire-keybinds' 2026-07-31 19:20:42 -04:00
b24c83711d refactor(keybinds): extract unified action dispatcher
Move the keybind router out of Shell into a reusable createDispatcher
in @/utils/dispatch, covering tabs (digits 1-6, [/]), h/l depth
drill/pop + fixed-pane swipe, modes, audio transport, quit/help, and
command, delegating pane/list actions over the nav.action event bus.

Shell now renders the active page full-width with a bottom status bar
carrying the tab strip; the old sidebar pane and its leftover sidebar
action handling are removed. Adds tests covering the dispatcher's
routing behavior.
2026-07-31 19:19:20 -04:00
866fcd7574 Merge branch 'rebuild-shell-chrome' 2026-07-31 19:06:18 -04:00
7c7d487ca6 refactor: move tab strip from left sidebar to bottom status bar
- Remove the left tab sidebar containing tab labels and now-playing
- Render tabs as compact labels in the command bar row instead
- Move now-playing display next to the selection count in the status bar
- All tabs remain functional; clicking a tab label switches the active tab
2026-07-31 19:05:43 -04:00
798178f21f Merge branch 'convert-list-tabs-to-primitive' 2026-07-31 17:55:37 -04:00
3f61303756 refactor(yazi): render depth-stack list tabs through YaziPaneRow primitive
Convert Discover, Feed, MyShows, and Settings pages from bespoke 3-column
flexbox JSX to render entirely via the shared <YaziPaneRow> parent|current|
preview primitive. Each page now supplies parent/current/preview content
accessors plus labels; the pane row handles layout, borders, focus styling,
and the muted placeholder for the blank parent slot at depth 0.

Replace Solid's children() helper in YaziPaneRow with a local normalizeContent
that hands the raw accessor to a reactive
expression. children() flattens to a stable resolved-nodes array and does not
re-resolve on truthy<->truthy root swaps (e.g. depth switching a pane's root
between a list fragment and an editor), which would freeze the previous
subtree. The insert effect from the reactive expression disposes and
remounts on element-identity change instead.

Add tests/yazi-pages-depth.test.ts covering the nav-store depth-stack
contract the pages depend on (push/pop stack growth, parent-slot data model
across root→child→grandchild→pop transitions).
2026-07-31 17:54:22 -04:00
139a258987 Merge branch 'rearchitect-nav-model'
# Conflicts:
#	src/utils/navigation.ts
2026-07-31 17:12:14 -04:00
cfa4ef0c47 refactor(nav): route tab switches through single switchTab path
Replace reactive / usage in the navigation
store with imperative functions so behavior is identical under both the
client JSX runtime and the server build used by unit tests (where
/ are no-ops).

- Centralize tab-switch side effects (root-frame seed, pane/mode/
  command/visual reset) in , invoked by a single
   helper used by gotoTab/nextTab/prevTab.
- Convert depthStack/currentDepth/topFrame/selectedIds from
  to plain functions that recompute on every read while still tracking
  underlying signals in reactive contexts.
2026-07-31 16:59:20 -04:00
5b667367a5 feat(layout): add YaziPaneRow 3-pane layout primitive
Add the shared parent | current | preview row primitive that implements
yazi's mgr.ratio = [1, 3, 3] contract via Yoga flexGrow, so every list
tab renders an identical, layout-stable shell regardless of terminal
size.

- New YaziPaneRow component: three bordered columns grow at 1/7 : 3/7
  : 3/7 with flexBasis=0 (content can never stretch its slot). The
  current column carries the accent focus ring when `focused` is
  truthy; parent and preview stay muted. The blank parent keeps its
  1/7 slot with a muted placeholder rather than collapsing.
- PANE_RATIO.current 4 -> 3 to match yazi's [1, 3, 3].
- Add render-based tests covering the 1:3:3 ratios (incl. null
  parent/preview) and the focused/unfocused accent ring behavior.
- Configure the bun test preload for the solid JSX transform.
2026-07-31 16:58:33 -04:00
b1bb9d9a1e refactor: rearchitect navigation model with pure store and no sidebar pane
- Extract navigation state and logic into navigation-store.ts for
  testability and separation of concerns
- NavigationContext now only wraps the store as a Solid provider
- Remove SIDEBAR_PANE from the model; depth-tabs have a single
  focusable content pane (center/current column)
- Split LayerGraph and page imports into layer-graph.ts so the
  navigation utils stay free of JSX (unit-testable)
- Update Shell keybind dispatch: remove sidebar pane handling,
  simplify swipe to fixed-pane tabs only, clarify depth-tab h/l
- Add nav-model.test.ts covering depth stack, tab/pane focus, and
  selection semantics
2026-07-31 10:24:45 -04:00
25 changed files with 3037 additions and 1696 deletions

1
.gitignore vendored
View File

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

BIN
bun.lockb

Binary file not shown.

2
bunfig.test.toml Normal file
View File

@@ -0,0 +1,2 @@
[test]
preload = ["./tests/preload/solid-test-plugin.ts"]

View File

@@ -1 +1,4 @@
preload = ["@opentui/solid/preload"]
[test]
preload = "@opentui/solid/preload"

1
notes.md Normal file
View File

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

View File

@@ -1,32 +1,32 @@
/**
* Shell — yazi-style application chrome.
*
* Renders the tabs as a vertical sidebar on the left (the root pane), the
* active page (which owns its own panes) to the right of it, and a bottom
* status/command bar spanning the full width. A single `useKeyboard` router
* translates keystrokes (via the sequence-aware keybind matcher) into actions:
* global ones (tabs, modes, audio, quit, help, command) are handled here;
* Renders the active page (which owns its own three-column parent | current |
* preview panes) full-width, with a bottom status/command bar that also
* carries the tab strip. A single `useKeyboard` router translates keystrokes
* (via the sequence-aware keybind matcher) into actions: the unified router
* in `@/utils/dispatch` handles tabs (digits `1`-`6`, `[`/`]`), h/l depth
* drill/pop + fixed-pane swipe, modes, audio, quit, help, and command; the
* pane/list ones are dispatched to the active page over the `nav.action`
* event bus.
* event bus. There is no sidebar pane.
*/
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,
SIDEBAR_PANE,
DEPTH_CENTER_PANE,
} 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";
import type { Episode } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus";
import { TABS, TabsCount, TabPaneCount, LayerGraph } from "@/utils/navigation";
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",
@@ -37,54 +37,6 @@ const TAB_LABEL: Record<TABS, string> = {
[TABS.SETTINGS]: "Settings",
};
/** Actions the active page is responsible for (pane/list-local). */
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",
],
);
/** Movement actions the sidebar pane handles itself (its list = the tabs,
* length TabsCount). Routed through the standard move/gotoIndex API. */
const SIDEBAR_ACTIONS: ReadonlySet<KeybindActionName> = new Set([
"move-down",
"move-up",
"jump-down",
"jump-up",
"page-down",
"page-up",
"goto-top",
"goto-bottom",
]);
function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
}
return null;
}
export function Shell() {
const theme = useTheme();
const t = theme.theme;
@@ -141,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", {
@@ -235,148 +187,16 @@ export function Shell() {
}
// ── Unified router (normal + visual) ───────────────────────────────────────
function dispatch(action: KeybindActionName, evt: any) {
const tab = nav.activeTab();
const pane = nav.activePane();
switch (action) {
// ── modes ──
case "escape":
evt.preventDefault();
if (nav.mode() === NavMode.VISUAL) {
nav.toNormal();
break;
}
k.clearPending();
break;
case "command":
evt.preventDefault();
nav.enterCommand();
break;
case "visual-mode":
evt.preventDefault();
nav.enterVisual();
break;
case "toggle-select":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
case "toggle-all":
case "invert-all":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
// ── tabs ──
case "tab-next":
evt.preventDefault();
nav.nextTab();
break;
case "tab-prev":
evt.preventDefault();
nav.prevTab();
break;
default: {
const dt = tabByDigit(action);
if (dt) {
evt.preventDefault();
nav.setActiveTab(dt);
break;
}
// ── sidebar pane: j/k/jump/goto move through the tab list via the
// standard move/gotoIndex API (list length = TabsCount). No
// special-cased nextTab/prevTab — the sidebar is a normal pane.
if (nav.activePane() === SIDEBAR_PANE && SIDEBAR_ACTIONS.has(action)) {
evt.preventDefault();
if (action === "goto-top") nav.gotoIndex(0, TabsCount);
else if (action === "goto-bottom")
nav.gotoIndex(TabsCount - 1, TabsCount);
else {
const dir = action.endsWith("down") ? 1 : -1;
const step = action.startsWith("jump")
? 5
: action.startsWith("page")
? 10
: 1;
nav.move(dir, TabsCount, step);
}
break;
}
// ── pane swipe / depth nav ──
// h/l always uses the unified swipe() (clamped to the sidebar on
// the left). Depth-tabs additionally: l at the center drills in
// (open), h at the center pops a depth (or swipes to sidebar at
// root). Fixed-pane tabs just swipe between their panes.
if (action === "swipe-prev") {
evt.preventDefault();
if (
nav.isDepthTab() &&
nav.activePane() === DEPTH_CENTER_PANE &&
nav.currentDepth() > 0
) {
nav.popDepth();
} else {
nav.swipe(-1, TabPaneCount[tab]);
}
break;
}
if (action === "swipe-next") {
evt.preventDefault();
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
emit("nav.action", {
action: "open",
tab,
pane: DEPTH_CENTER_PANE,
mode: nav.mode(),
const { dispatch } = createDispatcher({
nav,
audio: {
togglePlayback: audio.togglePlayback,
seekRelative: audio.seekRelative,
},
k,
setShowHelp,
advanceEpisode,
});
} else {
nav.swipe(1, TabPaneCount[tab]);
}
break;
}
// ── audio transport (global) ──
if (action === "audio-toggle") {
evt.preventDefault();
audio.togglePlayback().catch(() => {});
break;
}
if (action === "audio-seek-forward") {
evt.preventDefault();
audio.seekRelative(10).catch(() => {});
break;
}
if (action === "audio-seek-backward") {
evt.preventDefault();
audio.seekRelative(-10).catch(() => {});
break;
}
if (action === "audio-next") {
evt.preventDefault();
advanceEpisode(1);
break;
}
if (action === "audio-prev") {
evt.preventDefault();
advanceEpisode(-1);
break;
}
// ── global app ──
if (action === "quit") {
evt.preventDefault();
process.exit(0);
}
if (action === "help") {
evt.preventDefault();
setShowHelp((v) => !v);
}
// ── page-local list/pane actions ──
if (PAGE_ACTIONS.has(action)) {
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
}
}
}
}
useKeyboard(
(evt: any) => {
@@ -414,60 +234,36 @@ export function Shell() {
height="100%"
backgroundColor={t.surface}
>
{/* ── Middle row: tab sidebar (root pane) + active page ──────────────── */}
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%">
{/* ── Left tab sidebar ─────────────────────────────────────────────── */}
<box
flexDirection="column"
width={14}
height="100%"
backgroundColor={t.background}
border
borderColor={t.border}
>
<For
each={Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
)}
>
{(tab) => {
const active = () => nav.activeTab() === tab;
const focused = () =>
active() && nav.activePane() === SIDEBAR_PANE;
return (
<box
flexDirection="row"
backgroundColor={
focused() ? t.accent : active() ? t.primary : t.background
}
paddingLeft={1}
onMouseDown={() => {
nav.setActivePane(SIDEBAR_PANE);
nav.setActiveTab(tab);
}}
>
<text fg={focused() || active() ? t.surface : t.textMuted}>
{focused() ? " " : " "}
{tab}. {TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
<box flexGrow={1} backgroundColor={t.background} />
<Show when={nowPlaying()}>
<box paddingLeft={1} backgroundColor={t.background}>
<text fg={t.textMuted}>{nowPlaying()}</text>
</box>
</Show>
</box>
{/* ── Active page (owns its panes) ────────────────────────────────── */}
<box flexDirection="column" flexGrow={1} height="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"
@@ -483,24 +279,30 @@ export function Shell() {
{modeLabel()}
</text>
<text fg={t.textMuted} paddingLeft={1}>
{TAB_LABEL[nav.activeTab()]} ·{" "}
{nav.activePane() === SIDEBAR_PANE
? "tabs"
: nav.isDepthTab()
{nav.atRootTab()
? "Tabs · root"
: `${TAB_LABEL[nav.activeTab()]} · ${
nav.isDepthTab()
? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
}`}
</text>
<Show when={nav.selectedIds().length > 0}>
<text fg={t.warning} paddingLeft={1}>
{nav.selectedIds().length}
</text>
</Show>
<Show when={nowPlaying()}>
<text fg={t.primary} paddingLeft={1}>
{nowPlaying()}
</text>
</Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>
<text fg={t.textMuted} paddingRight={1}>
:cmd ~help q quit
~
</text>
</>
}
@@ -517,7 +319,6 @@ export function Shell() {
</Show>
</Show>
</box>
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
<Show when={showHelp()}>
<HelpOverlay
@@ -545,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

@@ -0,0 +1,190 @@
/**
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
*
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
* so the ratio is exact regardless of content width — a column's content can
* never stretch its slot.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 1/7 slot when blank (never collapses to width 0).
* current — the current-depth list. The only focusable content column; it
* carries the accent focus ring when `focused` is truthy.
* preview — detail of the hovered item in `current`; always muted border.
*
* The primitive is purely structural: callers pass their own JSX per column
* (static elements or accessors) plus header labels. Theme colors are resolved
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
*
* Example:
* <YaziPaneRow
* parent={parentList}
* current={currentList}
* preview={detail}
* parentLabel="Up"
* currentLabel="List · 42"
* previewLabel="Detail"
* focused={isActive}
* />
*/
import { createMemo } from "solid-js";
import type { JSX } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ───────────────────────────────────────────────────────────────────
type PaneContent = JSX.Element | (() => JSX.Element);
type PaneLabel = string | (() => string);
export type YaziPaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted
* placeholder — the 1/7 slot is always preserved). */
parent?: PaneContent;
/** Current column content (the focused list). */
current?: PaneContent;
/** Preview column content (detail of the hovered item). */
preview?: PaneContent;
parentLabel?: PaneLabel;
currentLabel?: PaneLabel;
previewLabel?: PaneLabel;
/** Whether the current column carries the accent focus ring. Defaults to
* true; pass `false` (or a signal) when the row is inactive. Parent and
* preview columns always render muted borders. */
focused?: boolean | (() => boolean);
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function resolveLabel(v: PaneLabel | undefined): string {
if (v == null) return "";
return typeof v === "function" ? v() : v;
}
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately do NOT use Solid's `children()` helper here: that helper
* flattens accessor children into a stable resolved-nodes array and is the
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
* switching between a depth-1 list fragment and a depth-2 editor — both
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
* root swap, freezing the previous subtree in place. Instead we hand the
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
* which Solid compiles into a tracked `insert` effect that disposes the old
* subtree and mounts the new whenever the accessor returns a different
* element identity. */
function normalizeContent(
v: PaneContent | undefined,
): () => JSX.Element | undefined {
if (v == null) return () => undefined;
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
}
function Placeholder(props: { color: () => RGBA }) {
return (
<box padding={1}>
<text fg={props.color()}></text>
</box>
);
}
// ── Pane column ─────────────────────────────────────────────────────────────
function YaziPane(props: {
grow: number;
label: () => string;
content: () => JSX.Element | undefined;
borderColor: () => RGBA;
scrollFocused: () => boolean;
}) {
const { theme } = useTheme();
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// Memoize accessor results so the prop expressions below stay reactive
// when the underlying signals (e.g. `focused`) change.
const borderColor = createMemo(() => props.borderColor());
const scrollFocused = createMemo(() => props.scrollFocused());
return (
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
{/* ── slim header label row ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>{props.label()}</text>
</box>
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
<scrollbox
height="100%"
focused={scrollFocused()}
border
borderColor={borderColor()}
backgroundColor={theme.background}
>
{/*
* Render the content accessor directly via a reactive expression.
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
* effect that re-runs whenever the accessor's tracked signals
* change (e.g. `depth()` swapping the root from a list fragment to
* an editor). Solid disposes the previously-rendered subtree and
* mounts the new element identity. `null`/`undefined` falls back
* to the muted placeholder so the parent pane keeps its 1/7 slot
* visibly blank at depth 0. This is the correct tool for root
* swapping — unlike Solid's `children()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox>
</box>
);
}
// ── Row primitive ───────────────────────────────────────────────────────────
export function YaziPaneRow(props: YaziPaneRowProps) {
const { theme } = useTheme();
/** true → the current column gets the accent focus ring. */
const focused = createMemo(() => {
const f = props.focused;
return typeof f === "function" ? f() : f ?? true;
});
// Normalize static JSX and accessor children into reactive accessors
// (see normalizeContent for why we avoid Solid's `children()` helper).
const parentContent = normalizeContent(props.parent);
const currentContent = normalizeContent(props.current);
const previewContent = normalizeContent(props.preview);
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
<YaziPane
grow={PANE_RATIO.parent}
label={parentLabel}
content={parentContent}
borderColor={() => theme.border}
scrollFocused={() => false}
/>
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
<YaziPane
grow={PANE_RATIO.current}
label={currentLabel}
content={currentContent}
borderColor={() => (focused() ? theme.accent : theme.border)}
scrollFocused={() => focused()}
/>
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
<YaziPane
grow={PANE_RATIO.preview}
label={previewLabel}
content={previewContent}
borderColor={() => theme.border}
scrollFocused={() => false}
/>
</box>
);
}

View File

@@ -1,426 +1,22 @@
import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
/**
* NavigationContext — Solid provider wrapper around the pure nav store in
* `./navigation-store`. Re-exports the nav model (`createNavigation`, the
* enums/types, `DEPTH_CENTER_PANE`, etc.) so the rest of the app keeps
* importing everything from `@/context/NavigationContext`, and binds the
* store into a Solid context (`useNavigation` / `NavigationProvider`).
*
* See `./navigation-store` for the model documentation (parent | current |
* preview, depth-stack vs fixed-pane tabs, no sidebar pane).
*/
import { createSimpleContext } from "./helper";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
import { createNavigation } from "./navigation-store";
// ── Yazi-style navigation state ──────────────────────────────────────────────
// Two pane models coexist:
//
// • Depth-stack tabs (Feed, MyShows, Discover, Settings) use a yazi-style
// depth stack. The three content columns render as:
// left = the previous depth's list (empty at depth 0)
// center = the current depth's list (always where focus lives)
// right = preview of the hovered item in center
// `l`/Enter drills in (push); `h` pops back (or yields to the sidebar at
// depth 0). Depth is unbounded — each page decides per-item whether an
// item is drillable and what child list kind to push.
//
// • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
// old indexed pane model (`focusedIndex(pane)` + `swipe`).
//
// The Shell's left tab sidebar is a special pane that sits *before* the
// content area. It uses SIDEBAR_PANE (-1) so the h/l chain naturally lands on
// it as the leftmost/root pane.
export enum NavMode {
NORMAL = "NORMAL",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** The tab sidebar (chrome) pane. Always the leftmost focus target. */
export const SIDEBAR_PANE = -1 as PaneId;
/** For depth-tabs, the current-depth (center) pane is the only focusable
* content pane — index 0. The prev/preview columns are derived, not focused. */
export const DEPTH_CENTER_PANE = 0 as PaneId;
/** The sidebar pane's "list" is the tab list itself: its focus cursor is the
* active tab (1-based) minus 1, and moving/setting it switches tabs via the
* standard focusedIndex/move/gotoIndex API — no special-cased nextTab. */
/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */
export enum PaneSlot {
PARENT = 0, // depth-tabs: center/current; Search: input
CURRENT = 1, // Search: results
PREVIEW = 2, // Search: detail
}
export type PaneId = number; // 0-based index into the active tab's pane list
// ── Depth stack ──────────────────────────────────────────────────────────────
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
* focused row index within that list. `ctx` optionally carries an id or
* payload the page needs to derive the list (e.g. a feed id). */
export type DepthFrame = {
kind: string;
ctx?: string;
focus: number;
};
// ── Selection store ───────────────────────────────────────────────────────────
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
// selection anchored at the focused index.
type SelectionMap = Record<string, Set<string>>;
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
// Re-export the entire nav model surface so existing imports from
// `@/context/NavigationContext` keep resolving.
export * from "./navigation-store";
export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({
name: "Navigation",
init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
// App focus starts on the left tab sidebar (root pane); tab switches
// also return focus there.
const [activePane, setActivePane] = createSignal<PaneId>(SIDEBAR_PANE);
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)] });
// 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 [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null);
/** Depth stack for a tab (empty for fixed-pane tabs). */
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
const ensureStack = (tab: TABS) => {
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
}
};
// On tab change: ensure a root frame exists (depth-tabs) + reset
// focus to the sidebar, clear modes/command/visual state.
createEffect(
on(activeTab, (tab) => {
ensureStack(tab);
batch(() => {
setActivePane(SIDEBAR_PANE);
setMode(NavMode.NORMAL);
setCount(null);
setCommandBuffer("");
setCommandError(null);
setVisualAnchor(null);
});
}),
);
// ── depth stack accessors ──────────────────────────────────────────────
const depthStack = createMemo<DepthFrame[]>(() =>
depthStackFor(activeTab()),
);
const currentDepth = createMemo(() =>
Math.max(0, depthStack().length - 1),
);
const topFrame = createMemo<DepthFrame | undefined>(
() => depthStack()[depthStack().length - 1],
);
const isDepthTab = () => DEPTH_TABS.has(activeTab());
/** Focus within a given depth's frame (default = current/top). */
const depthFocus = (d: number = currentDepth()) =>
depthStack()[d]?.focus ?? 0;
const setDepthFocus = (i: number, d: number = currentDepth()) =>
setStacks((s) => {
const st = s[activeTab()];
if (!st || d < 0 || d >= st.length) return s;
const next = st.slice();
next[d] = { ...next[d], focus: i };
return { ...s, [activeTab()]: next };
});
/** Push a child frame (drill in). */
const pushDepth = (frame: DepthFrame) =>
setStacks((s) => {
const st = s[activeTab()] ?? [];
return { ...s, [activeTab()]: [...st, frame] };
});
/** Pop the top frame (go back up a depth). No-op at root. Returns
* true if a frame was popped. */
const popDepth = (): boolean => {
let popped = false;
setStacks((s) => {
const st = s[activeTab()] ?? [];
if (st.length <= 1) return s;
popped = true;
return { ...s, [activeTab()]: st.slice(0, -1) };
});
return popped;
};
// ── tab switching ──────────────────────────────────────────────────────
const gotoTab = (tab: TABS) => {
if (tab < 1 || tab > TabsCount) return;
setActiveTab(tab);
};
const nextTab = () =>
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
const prevTab = () =>
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
/** Move focus to the adjacent pane (fixed-pane tabs only). `dir` =
* -1 (left, toward sidebar) or +1 (right, toward preview). Clamped to
* [SIDEBAR_PANE, paneCount-1]. */
const swipe = (dir: -1 | 1, paneCount: number) => {
setActivePane((p) => {
const n = Math.max(SIDEBAR_PANE, Math.min(paneCount - 1, p + dir));
return n;
});
};
// ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
/** For depth-tabs, pane 0 (center) reads/writes the top frame's
* focus. The sidebar pane's focus IS the active tab. Other panes
* (and fixed-pane tabs) use the per-pane map. */
const focusedIndex = (pane: PaneId = activePane()): number => {
if (pane === SIDEBAR_PANE) return activeTab() - 1;
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
return topFrame()?.focus ?? 0;
}
return paneIndices()[paneKey(pane)] ?? 0;
};
const setFocusedIndex = (pane: PaneId, index: number) => {
if (pane === SIDEBAR_PANE) {
gotoTab(((index + TabsCount) % TabsCount) + 1);
return;
}
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
setDepthFocus(index);
return;
}
setPaneIndices((m) => ({
...m,
[`${activeTab()}:${pane}`]: index,
}));
};
/** Apply a clamped relative motion to the active pane's focus. Returns
* the new index so callers can update their own scroll state. */
const move = (
delta: number,
listLen: number,
countOverride?: number,
): number => {
if (listLen <= 0) return 0;
const steps = countOverride ?? count() ?? 1;
const pane = activePane();
const cur = focusedIndex(pane);
let next = cur + delta * steps;
// wrap-around like yazi (arrow wraps top<->bottom)
next = ((next % listLen) + listLen) % listLen;
setFocusedIndex(pane, next);
// visual-mode range selection: add newly-traversed items to selection
if (HAS_VISUAL(mode()) && visualAnchor()) {
growVisualSelection(next);
}
return next;
};
const gotoIndex = (index: number, listLen: number): number => {
if (listLen <= 0) return 0;
const pane = activePane();
const next = Math.max(0, Math.min(listLen - 1, index));
setFocusedIndex(pane, next);
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
return next;
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> =>
selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
setSelections((m) => {
const set = new Set(m[key] ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
return { ...m, [key]: set };
});
};
const isSelected = (id: string) => selSet(paneKey()).has(id);
const clearSelection = (key?: string) => {
const k = key ?? paneKey();
setSelections((m) => {
if (!(k in m)) return m;
const next = { ...m };
delete next[k];
return next;
});
};
const selectedIds = createMemo(() => [...selSet(paneKey())]);
/** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => {
const pane = activePane();
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
setMode(NavMode.VISUAL);
};
/** Grow selection between the visual anchor and `index` for the active
* pane. Callers pass item ids aligned to indices; we store ids via the
* resolve callback registered per-pane (see registerResolver). */
let resolvers: Record<string, (index: number) => string | undefined> = {};
const registerResolver = (
key: string,
fn: (i: number) => string | undefined,
) => {
resolvers[key] = fn;
};
const growVisualSelection = (index: number) => {
const anchor = visualAnchor();
if (!anchor) return;
const resolve = resolvers[anchor.paneKey];
if (!resolve) return;
const lo = Math.min(anchor.index, index);
const hi = Math.max(anchor.index, index);
const ids: string[] = [];
for (let i = lo; i <= hi; i++) {
const id = resolve(i);
if (id) ids.push(id);
}
const key = anchor.paneKey;
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
};
// ── modes ────────────────────────────────────────────────────────────────
const enterCommand = () => {
setMode(NavMode.COMMAND);
setCommandBuffer("");
setCommandError(null);
};
const enterInput = () => setMode(NavMode.INPUT);
const exitCommand = () => {
batch(() => {
setMode(NavMode.NORMAL);
setCommandBuffer("");
setCommandError(null);
});
};
const exitVisual = () => {
batch(() => {
setMode(NavMode.NORMAL);
setVisualAnchor(null);
});
};
const toNormal = () => {
if (mode() === NavMode.VISUAL) {
clearSelection();
exitVisual();
} else {
setMode(NavMode.NORMAL);
}
};
// ── command buffer ───────────────────────────────────────────────────────
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
const submitCommand = (): string => {
const cmd = commandBuffer().trim();
exitCommand();
return cmd;
};
// ── count register ───────────────────────────────────────────────────────
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
const consumeCount = (): number => {
const c = count();
setCount(null);
return c ?? 1;
};
return {
activeTab,
activePane,
mode,
count,
inputFocused,
commandBuffer,
commandError,
visualAnchor,
selections,
selectedIds,
// depth stack
depthStack,
currentDepth,
topFrame,
depthFocus,
setDepthFocus,
pushDepth,
popDepth,
isDepthTab,
// tab
setActiveTab: gotoTab,
nextTab,
prevTab,
// pane focus
setActivePane: setPane,
swipe,
// focus index
focusedIndex,
setFocusedIndex,
move,
gotoIndex,
// selection
isSelected,
toggleSelected,
clearSelection,
selectedIdsFor: (key: string) => [...selSet(key)],
registerResolver,
enterVisual,
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode,
enterCommand,
enterInput,
exitCommand,
toNormal,
// command buffer
setCommandBuffer,
appendCommand,
backspaceCommand,
submitCommand,
setCommandError,
// count
pushCountDigit,
consumeCount,
};
},
init: () => createNavigation(),
});

View File

@@ -0,0 +1,538 @@
/**
* navigation-store — the yazi-style navigation model, as a plain Solid store.
*
* This module is deliberately free of JSX and of any `.tsx` page imports so it
* can be exercised directly by unit tests (`bun test`) without the OpenTUI JSX
* runtime (which is supplied only by the build-time @opentui/solid bun-plugin).
* The Solid provider wrapper (`useNavigation` / `NavigationProvider`) and the
* simple-context plumbing live in `NavigationContext.tsx`; the app imports the
* provider from there, tests import `createNavigation` directly from here.
*
* ── Model ────────────────────────────────────────────────────────────────
* The app horizontally lays out three columns per tab:
*
* parent | current | preview
*
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* 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 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 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
* [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 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";
export enum NavMode {
NORMAL = "NORMAL",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** 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;
/** 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 = 1, // Search: input
CURRENT = 2, // Search: results
PREVIEW = 3, // Search: detail
}
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,
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
* focused row index within that list. `ctx` optionally carries an id or
* payload the page needs to derive the list (e.g. a feed id). */
export type DepthFrame = {
kind: string;
ctx?: string;
focus: number;
};
// ── Selection store ───────────────────────────────────────────────────────────
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
// selection anchored at the focused index.
type SelectionMap = Record<string, Set<string>>;
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
/**
* Construct a fresh, self-contained navigation state graph.
*
* Exported (not just inlined into the Solid provider) so unit tests can build
* a nav graph inside a `createRoot` without rendering any provider tree.
*/
export function createNavigation() {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
// 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)] },
);
// 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 [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null);
/** Depth stack for a tab (empty for fixed-pane tabs). */
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
const ensureStack = (tab: TABS) => {
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
}
};
/** Apply all tab-switch side effects synchronously. Done here in the
* imperative `switchTab` path rather than a reactive `createEffect`
* because this module is exercised by unit tests without the OpenTUI
* JSX runtime, and in that environment Solid's `createEffect` is a
* no-op (server build). Routing every tab change through this helper
* keeps the behavior identical under both runtimes.
*
* - 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(() => {
// 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("");
setCommandError(null);
setVisualAnchor(null);
});
};
// ── depth stack accessors ──────────────────────────────────────────────
// Plain functions (not createMemo) so they recompute on every read.
// On the client build these are read inside reactive JSX contexts so
// their underlying signal reads are still tracked; on the server build
// (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 topFrame = (): DepthFrame | undefined =>
depthStack()[depthStack().length - 1];
const isDepthTab = () => DEPTH_TABS.has(activeTab());
/** Focus within a given depth's frame (default = current/top). */
const depthFocus = (d: number = currentDepth()) =>
depthStack()[d]?.focus ?? 0;
const setDepthFocus = (i: number, d: number = currentDepth()) =>
setStacks((s) => {
const st = s[activeTab()];
if (!st || d < 0 || d >= st.length) return s;
const next = st.slice();
next[d] = { ...next[d], focus: i };
return { ...s, [activeTab()]: next };
});
/** Push a child frame (drill in). */
const pushDepth = (frame: DepthFrame) =>
setStacks((s) => {
const st = s[activeTab()] ?? [];
return { ...s, [activeTab()]: [...st, frame] };
});
/** Pop the top frame (go back up a depth). No-op at root. Returns
* true if a frame was popped. */
const popDepth = (): boolean => {
let popped = false;
setStacks((s) => {
const st = s[activeTab()] ?? [];
if (st.length <= 1) return s;
popped = true;
return { ...s, [activeTab()]: st.slice(0, -1) };
});
return popped;
};
// ── tab switching ──────────────────────────────────────────────────────
/** Internal: set activeTab + run all side effects (root-frame seed,
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
* 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) => {
if (tab < 1 || tab > TabsCount) return;
switchTab(tab);
};
const nextTab = () => {
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
switchTab(t);
};
const prevTab = () => {
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
switchTab(t);
};
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
/** 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]. 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(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}`;
/** For depth-tabs, pane 0 (the center/current pane) reads/writes
* the top frame's focus. Other panes and fixed-pane tabs use the
* per-pane index map. */
const focusedIndex = (pane: PaneId = activePane()): number => {
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
return topFrame()?.focus ?? 0;
}
return paneIndices()[paneKey(pane)] ?? 0;
};
const setFocusedIndex = (pane: PaneId, index: number) => {
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
setDepthFocus(index);
return;
}
setPaneIndices((m) => ({
...m,
[`${activeTab()}:${pane}`]: index,
}));
};
/** Apply a clamped relative motion to the active pane's focus. Returns
* the new index so callers can update their own scroll state. */
const move = (
delta: number,
listLen: number,
countOverride?: number,
): number => {
if (listLen <= 0) return 0;
const steps = countOverride ?? count() ?? 1;
const pane = activePane();
const cur = focusedIndex(pane);
let next = cur + delta * steps;
// wrap-around like yazi (arrow wraps top<->bottom)
next = ((next % listLen) + listLen) % listLen;
setFocusedIndex(pane, next);
// visual-mode range selection: add newly-traversed items to selection
if (HAS_VISUAL(mode()) && visualAnchor()) {
growVisualSelection(next);
}
return next;
};
const gotoIndex = (index: number, listLen: number): number => {
if (listLen <= 0) return 0;
const pane = activePane();
const next = Math.max(0, Math.min(listLen - 1, index));
setFocusedIndex(pane, next);
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
return next;
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> => selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
setSelections((m) => {
const set = new Set(m[key] ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
return { ...m, [key]: set };
});
};
const isSelected = (id: string) => selSet(paneKey()).has(id);
const clearSelection = (key?: string) => {
const k = key ?? paneKey();
setSelections((m) => {
if (!(k in m)) return m;
const next = { ...m };
delete next[k];
return next;
});
};
const selectedIds = () => [...selSet(paneKey())];
/** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => {
const pane = activePane();
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
setMode(NavMode.VISUAL);
};
/** Grow selection between the visual anchor and `index` for the active
* pane. Callers pass item ids aligned to indices; we store ids via the
* resolve callback registered per-pane (see registerResolver). */
let resolvers: Record<string, (index: number) => string | undefined> = {};
const registerResolver = (
key: string,
fn: (i: number) => string | undefined,
) => {
resolvers[key] = fn;
};
const growVisualSelection = (index: number) => {
const anchor = visualAnchor();
if (!anchor) return;
const resolve = resolvers[anchor.paneKey];
if (!resolve) return;
const lo = Math.min(anchor.index, index);
const hi = Math.max(anchor.index, index);
const ids: string[] = [];
for (let i = lo; i <= hi; i++) {
const id = resolve(i);
if (id) ids.push(id);
}
const key = anchor.paneKey;
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
};
// ── modes ────────────────────────────────────────────────────────────────
const enterCommand = () => {
setMode(NavMode.COMMAND);
setCommandBuffer("");
setCommandError(null);
};
const enterInput = () => setMode(NavMode.INPUT);
const exitCommand = () => {
batch(() => {
setMode(NavMode.NORMAL);
setCommandBuffer("");
setCommandError(null);
});
};
const exitVisual = () => {
batch(() => {
setMode(NavMode.NORMAL);
setVisualAnchor(null);
});
};
const toNormal = () => {
if (mode() === NavMode.VISUAL) {
clearSelection();
exitVisual();
} else {
setMode(NavMode.NORMAL);
}
};
// ── command buffer ───────────────────────────────────────────────────────
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
const submitCommand = (): string => {
const cmd = commandBuffer().trim();
exitCommand();
return cmd;
};
// ── count register ───────────────────────────────────────────────────────
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
const consumeCount = (): number => {
const c = count();
setCount(null);
return c ?? 1;
};
return {
activeTab,
activePane,
mode,
count,
inputFocused,
commandBuffer,
commandError,
visualAnchor,
selections,
selectedIds,
// depth stack
depthStack,
currentDepth,
topFrame,
depthFocus,
setDepthFocus,
pushDepth,
popDepth,
isDepthTab,
// tab
setActiveTab: gotoTab,
nextTab,
prevTab,
// tab root (app's outermost pane)
atRootTab,
enterTabContent,
backToTabRoot,
tabCursor,
moveTabCursor,
activateTabCursor,
// pane focus
setActivePane: setPane,
swipe,
// focus index
focusedIndex,
setFocusedIndex,
move,
gotoIndex,
// selection
isSelected,
toggleSelected,
clearSelection,
selectedIdsFor: (key: string) => [...selSet(key)],
registerResolver,
enterVisual,
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode,
enterCommand,
enterInput,
exitCommand,
toNormal,
// command buffer
setCommandBuffer,
appendCommand,
backspaceCommand,
submitCommand,
setCommandError,
// count
pushCountDigit,
consumeCount,
};
}
export type NavigationState = ReturnType<typeof createNavigation>;

View File

@@ -1,15 +1,18 @@
/**
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
*
* depth 0 (current) — category list. Left pane empty at root.
* depth 1 (current) — podcast results for the drilled category.
* right (preview) — detail of the hovered item (category summary, or
* depth 0 (current) — category list. Parent pane shows the muted
* placeholder (1/7 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list.
* preview — detail of the hovered item (category summary, or
* podcast detail + subscribe action).
*
* `l`/Enter drills in (category → results) or subscribes (on a podcast);
* `h` pops back (or yields to the sidebar at depth 0). j/k move within the
* current column. Moving through categories at depth 0 updates the store's
* selected category so the preview follows.
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
* column. Moving through categories at depth 0 updates the store's selected
* category so the preview follows.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -25,7 +28,8 @@ import {
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const DiscoverPaneCount = 1;
@@ -132,36 +136,23 @@ function DiscoverPage() {
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text;
const headerBg = theme.background;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
const currentLabel = () =>
depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}>
{(cat, index) => (
<box
@@ -180,56 +171,35 @@ function DiscoverPage() {
</box>
)}
</For>
</scrollbox>
</Show>
</box>
);
{/* ── center: current depth ─────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
{depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
// ── current pane ───────────────────────────────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: categories */}
<Show when={depth() === 0}>
<For each={categories()}>
{(cat, index) => {
const lf = focusedCatIdx();
const selected = () =>
cat.id === discoverStore.selectedCategory();
const selected = () => cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
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)}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
*
@@ -240,7 +210,6 @@ function DiscoverPage() {
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
@@ -260,23 +229,21 @@ function DiscoverPage() {
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
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)}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
<text fg={focusFg(index(), lf, isActive())}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text
fg={index() === lf ? theme.surface : theme.success}
>
<text fg={index() === lf ? theme.surface : theme.success}>
[+]
</text>
</Show>
@@ -295,28 +262,13 @@ function DiscoverPage() {
</For>
</Show>
</Show>
</scrollbox>
</box>
</>
);
{/* ── right: preview ────────────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.preview}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* depth 0 preview: hovered category */}
<Show when={depth() === 0}>
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered category
<Show
when={focusedCategory()}
fallback={
@@ -339,10 +291,8 @@ function DiscoverPage() {
</box>
)}
</Show>
</Show>
{/* depth ≥1 preview: hovered podcast + subscribe */}
<Show when={depth() >= 1}>
) : (
// depth ≥1 preview: hovered podcast + subscribe
<Show
when={focusedPodcast()}
fallback={
@@ -367,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}>
@@ -381,20 +330,24 @@ function DiscoverPage() {
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} />
<text fg={muted()}>
enter: subscribe · h: back · r: refresh
</text>
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
</box>
)}
</Show>
</Show>
</scrollbox>
</box>
</box>
);
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -2,14 +2,17 @@
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
*
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
* virtual "All Feeds". Left pane empty at root.
* virtual "All Feeds". Parent pane shows the muted
* placeholder (1/7 slot kept).
* depth 1 (current) — flat episodes list for the drilled feed (reverse
* chronological). Left pane = the feeds list (prev).
* right (preview) — detail of the hovered item in the current column.
* chronological). Parent pane = the feeds list (prev).
* preview — detail of the hovered item in the current column.
*
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
* sidebar at depth 0). j/k move within the current column. The Shell router
* drives everything over nav.action; this page only handles list/preview data.
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
* (push); `h` pops a depth (noop at 0). j/k move only within the current
* column. The Shell router drives everything over `nav.action`; this page
* only handles list/preview data.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -32,7 +35,8 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PANE_RATIO } from "@/utils/navigation";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const FeedPaneCount = 1;
@@ -200,8 +204,8 @@ function FeedPage() {
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within a list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
@@ -210,7 +214,6 @@ function FeedPage() {
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
const headerBg = theme.background;
const feedLabel = (item: FeedListItem) =>
item.kind === "all"
@@ -221,81 +224,48 @@ function FeedPage() {
? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
Feeds · {feedList().length - 1}
</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={feedList()}>
{(item, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{feedLabel(item)}
</text>
<text fg={muted()}>({feedCount(item)})</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
{/* ── center: current depth ─────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
{depth() === 0
const currentLabel = () =>
depth() === 0
? `Feeds · ${feedList().length - 1}`
: `${(() => {
const fi = focusedFeedItem();
return fi?.kind === "feed"
? fi.feed.customName || fi.feed.podcast.title
: "All Episodes";
})()} · ${episodes().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
})()} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
// mounts/unmounts cleanly on depth change instead of swapping roots.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={feedList()}>
{(item, index) => {
const lf = nav.depthFocus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
>
{/* depth 0: feeds */}
<text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
<text fg={muted()}>({feedCount(item)})</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane: the current-depth list (the only focusable column) ──────
const currentContent = () => (
<>
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={feedList().length > 1}
@@ -316,16 +286,16 @@ function FeedPage() {
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)}
backgroundColor={focusBg(index(), fi, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive)}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive)}>
<text fg={focusFg(index(), fi, isActive())}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
@@ -337,9 +307,8 @@ function FeedPage() {
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
{/* depth ≥1: episodes */}
<Show
when={episodes().length > 0}
fallback={
@@ -357,17 +326,17 @@ function FeedPage() {
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)}
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)}>
<text fg={focusFg(index(), fi, isActive())}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive)}>
<text fg={focusFg(index(), fi, isActive())}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
@@ -404,28 +373,13 @@ function FeedPage() {
</Show>
</Show>
</Show>
</scrollbox>
</box>
</>
);
{/* ── right: preview of hovered item ───────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.preview}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* depth 0 preview: hovered feed */}
<Show when={depth() === 0}>
// ── preview pane: hovered-item detail ──────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered feed
<Show
when={focusedFeedItem()}
fallback={
@@ -463,10 +417,8 @@ function FeedPage() {
);
}}
</Show>
</Show>
{/* depth ≥1 preview: hovered episode */}
<Show when={depth() >= 1}>
) : (
// depth ≥1 preview: hovered episode
<Show
when={focusedItem()}
fallback={
@@ -488,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)}
@@ -513,17 +461,23 @@ function FeedPage() {
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · space: select · h: back
</text>
<text fg={muted()}>enter: play · space: select · h: back</text>
</box>
);
}}
</Show>
</Show>
</scrollbox>
</box>
</box>
);
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -1,12 +1,14 @@
/**
* MyShowsPage — yazi depth-stack view of subscribed shows.
*
* depth 0 (current) — subscribed shows. Left pane empty at root.
* depth 1 (current) — episodes of the drilled show. Left pane = shows (prev).
* right (preview) — detail of the hovered item in the current column.
* depth 0 (current) — subscribed shows. Parent pane shows the muted
* placeholder (1/7 slot kept).
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column.
*
* `l`/Enter drills in (show → episodes); `h` pops back (or yields to the
* sidebar at depth 0). j/k move within the current column.
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column.
*/
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -29,7 +31,8 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PANE_RATIO } from "@/utils/navigation";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const MyShowsPaneCount = 1;
@@ -181,37 +184,24 @@ export function MyShowsPage() {
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text;
const headerBg = theme.background;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
const currentLabel = () =>
depth() === 0
? `Shows (${shows().length})`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={shows()}>
{(feed, index) => {
const lf = nav.depthFocus(0);
@@ -226,41 +216,19 @@ export function MyShowsPage() {
<text fg={focusFg(index(), lf, false)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, false)}>
{showTitle(feed)}
</text>
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
</For>
</scrollbox>
</Show>
</box>
);
{/* ── center: current depth ─────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
{depth() === 0
? `Shows (${shows().length})`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: shows */}
// ── current pane: the current-depth list ───────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={shows().length > 0}
@@ -281,16 +249,16 @@ export function MyShowsPage() {
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
backgroundColor={focusBg(index(), lf, isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf, isActive)}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
<text fg={focusFg(index(), lf, isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
@@ -302,7 +270,6 @@ export function MyShowsPage() {
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
@@ -322,17 +289,17 @@ export function MyShowsPage() {
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
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)}>
<text fg={focusFg(index(), lf, isActive())}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
<text fg={focusFg(index(), lf, isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
@@ -364,28 +331,13 @@ export function MyShowsPage() {
</Show>
</Show>
</Show>
</scrollbox>
</box>
</>
);
{/* ── right: preview ────────────────────────────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.preview}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* depth 0 preview: hovered show */}
<Show when={depth() === 0}>
// ── preview pane ───────────────────────────────────────────────────────────
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered show
<Show
when={selectedShow()}
fallback={
@@ -406,18 +358,15 @@ 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>
</box>
)}
</Show>
</Show>
{/* depth ≥1 preview: hovered episode */}
<Show when={depth() >= 1}>
) : (
// depth ≥1 preview: hovered episode
<Show
when={focusedEpisode()}
fallback={
@@ -444,26 +393,29 @@ export function MyShowsPage() {
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>
by {selectedShow()!.podcast.author}
</text>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ??
"No description available."}
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · space: select · h: back
</text>
<text fg={muted()}>enter: play · space: select · h: back</text>
</box>
)}
</Show>
</Show>
</scrollbox>
</box>
</box>
);
return (
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

View File

@@ -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

@@ -5,9 +5,12 @@
* depth 1 — the focused section's items as a navigable list
* depth 2 — per-item editor (for editor-kind items) or value adjuster
*
* Columns render as yazi's prev | current | preview:
* left = previous depth's list (empty at depth 0)
* right = preview/help text for the hovered item in center
* Renders entirely through `<YaziPaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2);
* blank placeholder at depth 0 (1/7 slot kept).
* current = the current-depth list (or editor at depth 2); the only
* focusable column.
* preview = help/preview text for the hovered item in current.
*
* All movement comes from the Shell router over `nav.action` (j/k move,
* Enter/l drill, h back). Panels no longer register their own useKeyboard —
@@ -24,12 +27,13 @@ import {
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel";
import { useVisualizerItems } from "./VisualizerSettings";
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
import { useSourceItems } from "./SourceManager";
import { YaziPaneRow } from "@/components/YaziPaneRow";
import { TabListPane } from "@/components/TabPanel";
export const SettingsPaneCount = 1;
@@ -224,9 +228,7 @@ export function SettingsPage() {
onCleanup(() => closeSyncEditor());
// ── render helpers ───────────────────────────────────────────────────────
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (active: boolean) => (active ? theme.accent : theme.border);
const headerBg = theme.background;
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// preview text for the right column
const previewText = createMemo<string>(() => {
@@ -245,32 +247,34 @@ export function SettingsPage() {
: "No editor.";
});
// ── column content builders ──────────────────────────────────────────────
// left = previous depth (read-only list), or empty at depth 0
const LeftCol = () => (
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
<Show when={depth() >= 1} fallback=" ">
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
// ── column label ───────────────────────────────────────────────────────────
const currentLabel = () => {
const d = depth();
if (d === 0) return "Settings";
if (d === 1) return sectionForDepth1()?.label ?? "Items";
return editorItem()?.label ?? "Editor";
};
const parentLabel = () => {
const d = depth();
if (d === 1) return "Sections";
if (d === 2) return sectionForDepth1()?.label ?? "";
return "Up";
};
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
// mounts every branch once and toggles children on depth change — the
// known-good opentui disposal pattern. A ternary returning different
// roots leaves subtree orphaned on swap; the trick is a STABLE fragment
// root whose inner <Show> children swap instead.
const parentContent = () => (
<>
<Show when={depth() === 0}>
{/* app root: the tab list as the parent (muted) at the lowest depth */}
<TabListPane muted />
</Show>
</text>
</box>
<Show when={depth() === 1}>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* previous depth = sections list (read-only) */}
<For each={SECTIONS}>
{(section, index) => (
<Row
@@ -280,15 +284,9 @@ export function SettingsPage() {
/>
)}
</For>
</scrollbox>
</Show>
<Show when={depth() === 2}>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
{/* previous depth = items list (read-only) */}
<For each={items()}>
{(it, index) => (
<Row
@@ -298,51 +296,20 @@ export function SettingsPage() {
/>
)}
</For>
</scrollbox>
</Show>
</box>
</>
);
// center = current depth
const CenterCol = () => (
<box
flexDirection="column"
flexGrow={PANE_RATIO.current}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>
<Show
when={depth() === 0}
fallback={
<Show
when={depth() === 1}
fallback={editorItem()?.label ?? "Editor"}
>
{sectionForDepth1()?.label ?? "Items"}
</Show>
}
>
Settings
</Show>
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
// ── current pane: current-depth list (or editor at depth 2) ───────────────
const currentContent = () => (
<>
<Show when={depth() === 0}>
<For each={SECTIONS}>
{(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
focused={index() === focusedSectionIdx()}
active={isActive}
active={isActive()}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
@@ -352,13 +319,14 @@ export function SettingsPage() {
</For>
</Show>
<Show when={depth() === 1}>
<box flexDirection="column">
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label}`}
value={it.display()}
focused={index() === focusedItemIdx()}
active={isActive}
active={isActive()}
hint={hintFor(it)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
@@ -372,8 +340,10 @@ export function SettingsPage() {
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
</box>
</Show>
</box>
</Show>
<Show when={depth() === 2}>
{/* depth 2: editor */}
<Show
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
@@ -381,41 +351,26 @@ export function SettingsPage() {
{editorItem()!.renderEditor!()}
</Show>
</Show>
</scrollbox>
</box>
</>
);
// right = preview / help
const RightCol = () => (
<box
flexDirection="column"
flexGrow={PANE_RATIO.preview}
flexShrink={1}
flexBasis={0}
height="100%"
>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
// ── preview pane ──────────────────────────────────────────────────────────
const previewContent = () => (
<box padding={1}>
<MultiLine text={previewText()} />
</box>
</scrollbox>
</box>
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{LeftCol()}
{CenterCol()}
{RightCol()}
</box>
<YaziPaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
parentLabel={parentLabel}
currentLabel={currentLabel}
previewLabel="Detail"
focused={isActive}
/>
);
}

263
src/utils/dispatch.ts Normal file
View File

@@ -0,0 +1,263 @@
/**
* dispatch — the yazi-style unified keybind router.
*
* Extracted from `src/components/Shell.tsx` into this pure (no-JSX, no
* @opentui/solid) module so `dispatch()` is unit-testable with `bun test`
* directly — mirroring how task 01 split `navigation-store` out of the
* NavigationContext so the nav model could be exercised without the OpenTUI
* JSX runtime (supplied only by the build-time bun-plugin).
*
* The Shell builds a `DispatcherDeps` from its live hooks + the audio-side
* `advanceEpisode` helper, then forwards every matched keystroke action to
* `dispatch`. Behavioural rules (tab root + task 06):
*
* • 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
* 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 active content pane.
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
* are unchanged from the pre-rewrite Shell. */
import type { KeybindActionName } from "@/context/KeybindContext";
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
import { emit } from "@/utils/event-bus";
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
export { DEPTH_CENTER_PANE, NavMode };
/** The payload carried on the `nav.action` event bus. Mirrors the typed event
* in utils/event-bus.ts but duplicated here so this module stays dep-light. */
export type NavActionEvent = {
action: KeybindActionName;
tab: TABS;
pane: number;
mode: NavMode;
};
/** 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",
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
export function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
}
return null;
}
/** Dependencies the unified keybind dispatcher closes over. `advanceEpisode`
* is passed in (it lives over the full audio/feed/toast surface in Shell) so
* the dispatcher only needs the subset it touches directly. */
export type DispatcherDeps = {
nav: NavigationState;
audio: {
togglePlayback: () => Promise<void>;
seekRelative: (n: number) => Promise<void>;
};
k: { clearPending: () => void };
setShowHelp: (fn: (v: boolean) => boolean) => void;
advanceEpisode: (offset: number) => void;
};
/** Build the unified router (normal + visual modes). Returns `dispatch` —
* the closure Shell's `useKeyboard` calls with each matched action. */
export function createDispatcher(deps: DispatcherDeps) {
const { nav, audio, k, setShowHelp, advanceEpisode } = deps;
function dispatch(
action: KeybindActionName,
evt: { preventDefault: () => void },
) {
const tab = nav.activeTab();
const pane = nav.activePane();
switch (action) {
// ── modes ──
case "escape":
evt.preventDefault();
if (nav.mode() === NavMode.VISUAL) {
nav.toNormal();
break;
}
k.clearPending();
break;
case "command":
evt.preventDefault();
nav.enterCommand();
break;
case "visual-mode":
evt.preventDefault();
nav.enterVisual();
break;
case "toggle-select":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
case "toggle-all":
case "invert-all":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
// ── tabs (the only tab switchers) ──
case "tab-next":
evt.preventDefault();
nav.nextTab();
break;
case "tab-prev":
evt.preventDefault();
nav.prevTab();
break;
default: {
const dt = tabByDigit(action);
if (dt) {
evt.preventDefault();
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 ──
// 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.currentDepth() > 0) nav.popDepth();
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") {
evt.preventDefault();
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
emit("nav.action", {
action: "open",
tab,
pane: DEPTH_CENTER_PANE,
mode: nav.mode(),
});
} else {
nav.swipe(1, TabPaneCount[tab]);
}
break;
}
// ── audio transport (global) ──
if (action === "audio-toggle") {
evt.preventDefault();
audio.togglePlayback().catch(() => {});
break;
}
if (action === "audio-seek-forward") {
evt.preventDefault();
audio.seekRelative(10).catch(() => {});
break;
}
if (action === "audio-seek-backward") {
evt.preventDefault();
audio.seekRelative(-10).catch(() => {});
break;
}
if (action === "audio-next") {
evt.preventDefault();
advanceEpisode(1);
break;
}
if (action === "audio-prev") {
evt.preventDefault();
advanceEpisode(-1);
break;
}
// ── global app ──
if (action === "quit") {
evt.preventDefault();
process.exit(0);
}
if (action === "help") {
evt.preventDefault();
setShowHelp((v) => !v);
}
// ── page-local list/pane actions ──
if (PAGE_ACTIONS.has(action)) {
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
}
}
}
}
return { dispatch };
}
// Re-export the depth-frame type for convenience.
export type { DepthFrame };

48
src/utils/layer-graph.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* layer-graph — maps each TAB id to its page component + pane count.
*
* Split out of `navigation.ts` so that the nav-model primitives (TABS,
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in
* `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests
* import the pure navigation store without pulling the OpenTUI JSX runtime
* (which is only provided by the build-time @opentui/solid bun-plugin).
*
* The page modules live alongside their pages and export `<count>PaneCount`
* constants describing how many focusable panes each fixed page owns.
*/
import {
DiscoverPage,
DiscoverPaneCount,
} from "@/pages/Discover/DiscoverPage";
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
import {
MyShowsPage,
MyShowsPaneCount,
} from "@/pages/MyShows/MyShowsPage";
import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import {
SettingsPage,
SettingsPaneCount,
} from "@/pages/Settings/SettingsPage";
import { TABS } from "@/utils/navigation";
/** Maps a TAB id to the page component that renders it. */
export const LayerGraph = {
[TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage,
};
/** Per-tab focusable-pane counts (forwarded from each page's `*PaneCount`). */
export const LayerDepths = {
[TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount,
};

View File

@@ -1,10 +1,3 @@
import { DiscoverPage, DiscoverPaneCount } from "@/pages/Discover/DiscoverPage";
import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage";
import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage";
import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage";
import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
export enum DIRECTION {
Increment,
Decrement,
@@ -49,40 +42,31 @@ export function rootFrameFor(
}
}
export const LayerGraph = {
[TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage,
};
export const LayerDepths = {
[TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount,
};
// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`,
// split out so this module stays free of `.tsx`/JSX imports (unit-testable).
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
// of terminal size — more robust than fixed percentages and exactly mirrors
// Yazi-style pane grow ratios (parent : current : preview). Panes use
// flexGrow (Yoga) so columns always sum to the row width regardless of
// terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
//
// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT
// touch these values. Task 02 re-tunes them to the remake target ratios
// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there.
export const PANE_RATIO = {
parent: 1,
current: 4,
current: 3,
preview: 3,
} as const;
// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/
// Settings) now have a single focusable content pane (the center/current
// column at depth 0..N); prev and preview are derived, not focusable. Search
// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch
// routes depth-tabs to push/pop instead of pane swipe. Defined here (after
// TABS) to avoid re-introducing the old NavigationContext top-level-init
// circular deadlock.
// Number of *focusable* content panes per tab. The three visible columns
// (parent | current | preview) are a *render* concern, NOT three panes — for
// depth-tabs only the current column (index 0) is focusable, so this is 1.
// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
// here (after TABS) to avoid re-introducing the old NavigationContext
// top-level-init circular deadlock.
export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 1, // depth: feeds → episodes → preview
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview

View File

@@ -0,0 +1,98 @@
# 07-blocker — Task 04 (Search + Player) never converted to YaziPaneRow
meta:
id: yazi-remake-07-blocker
feature: yazi-remake
priority: P0
blocks: [yazi-remake-07]
blocked_by: [yazi-remake-04]
tags: [blocker, verification, tasks-required]
## Problem
Task 04 (`04-fit-search-and-player-panes.md`) is marked complete, but its
core deliverable was never implemented:
> - `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`
> - `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`
`grep -rn "YaziPaneRow" src/pages/Search src/pages/Player`**0 files**.
## Evidence (from task 07 harness walk-through, 100×30)
### Player ❌
`src/pages/Player/PlayerPage.tsx` renders a single full-width
`<scrollbox>` (now-playing transport + controls). No parent pane, no preview
pane. Frame `.harness/player-current.txt`:
```
│ Now Playing 0:00 / 0:00 (0%) │
...
│ │ │[Prev]│ │[Play]│ │[Next]│ Vol 70% Speed 1x ... │ │
```
Expected (task 04 + feature exit criteria): `blank | transport | notes` at
1/7 : 3/7 : 3/7.
### Search ⚠️ ratio wrong
`src/pages/Search/SearchPage.tsx` renders three custom `<box flexGrow={
PANE_RATIO.* }>` columns but **omits `flexBasis={0}`**, so Yoga distributes
space by natural content width (the input box is `width={28}`). Measured
column widths at 100 cols: **31 / 32 / 31** (equal thirds), NOT the target
**~14 / 43 / 43** (1:3:3).
`<YaziPaneRow>` exists precisely to set `flexBasis={0}` per column and force
the exact 1:3:3 ratio regardless of content (see its header comment). Routing
Search through it fixes the ratio for free.
## Why not patched in task 07
Task 07 is explicitly the verification gate. Its notes:
> - This is the gate for the whole feature — do not mark done if any criterion
> fails; open a blocker task instead
> - If the harness reveals a visual regression (e.g. parent collapses, ratios
> off), file it against the responsible task (02 or 03) rather than
> patching here
This is a missing implementation in task 04, not a regression in 02/03, so the
responsible task is 04. Patches belong there.
## Failing exit criteria
- "All tabs render three stable columns at 1/7 : 3/7 : 3/7" — Player fails
(not 3 columns); Search fails (wrong ratio).
- "`5` → Player: blank|transport|notes at 1:3:3" — fails.
- "`4` → Search: query|results|detail at 1:3:3" — 3 columns yes, ratio wrong.
## Fix plan (task 04 do-over)
1. `src/pages/Player/PlayerPage.tsx` — wrap the existing transport JSX in a
`<YaziPaneRow current={transport} parent={undefined} preview={notes} />`.
Parent should fall through to the primitive's muted `—` placeholder (it
already keeps its 1/7 slot when blank). Preview = episode description /
waveform (currently inline under "Now Playing"). Keep `PlayerPaneCount=1`
(the visible columns are a render concern; only current=0 is focusable).
2. `src/pages/Search/SearchPage.tsx` — replace the three custom `<box
flexGrow={PANE_RATIO.*}>` columns with a single `<YaziPaneRow
parent={queryInput+recent} current={resultsList} preview={detail}
focused={!inputFocused() ? /* results */ : false} />`. Keep the
`inputFocused` effect so the Shell yields keys to the native `<input>`
when the query pane is focused — note `inputFocused` is a Search-owned
signal; YaziPaneRow's `focused` prop only drives the accent ring + scroll
focus, which for Search can stay on the current (results) column.
3. Remove the now-dead custom ratio code from both files after the swap.
4. Re-run: `grep -rn YaziPaneRow src/pages/Search src/pages/Player` → 2 files;
`bun run build`; `bun test`; harness walk-through: Player shows 3 cols,
Search cols measure ~14/43/43.
## Verification gates (re-run task 07 after fix)
- `bun run build` → "Build complete"
- `bun test` → 0 fail
- harness:
- Player frame has 3 bordered columns (parent `—`, current transport,
preview notes/placeholder) at 1:3:3
- Search frame columns measure ~14/43/43

View File

@@ -6,6 +6,7 @@ meta:
priority: P1
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
tags: [verification, tests-required]
status: BLOCKED # see .harness/verification-07.md + tasks/yazi-remake/07-blocker-task-04-player-search.md
objective:

View File

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

View File

@@ -0,0 +1,311 @@
/**
* dispatch-keybinds.test.ts — yazi remake task 06/07 unit + integration tests.
*
* Exercises the rewired unified keybind router (`createDispatcher`) directly,
* 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
* cases, plus the tab-root routing:
*
* • Tab root: j/k (`move-down`/`move-up`) move a tab cursor without touching
* the active tab; `open`/`swipe-next` (l/Enter) open the hovered tab and
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
* • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
* `h` on the first pane stays — never overflows to the tab root.
* • 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 paths under test
* never reach the audio or advanceEpisode branches) and a real nav store.
*/
import { test, expect, mock } from "bun:test";
import { createRoot } from "solid-js";
import {
createNavigation,
DEPTH_CENTER_PANE,
} from "../src/context/navigation-store";
import { TABS } from "../src/utils/navigation";
import { createDispatcher, type DispatcherDeps } from "../src/utils/dispatch";
import { on } from "../src/utils/event-bus";
import type { KeybindActionName } from "../src/context/KeybindContext";
/** 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
* nav state and call dispatch. */
function withHarness(
fn: (api: {
nav: ReturnType<typeof createNavigation>;
dispatch: (action: KeybindActionName) => void;
toggleHelp: () => boolean;
helpOpen: () => boolean;
}) => void,
) {
createRoot((dispose) => {
const nav = createNavigation();
let help = false;
const deps: DispatcherDeps = {
nav,
audio: {
togglePlayback: async () => {},
seekRelative: async () => {},
},
k: { clearPending: () => {} },
setShowHelp: (fn) => {
help = fn(help);
},
advanceEpisode: () => {},
};
const { dispatch } = createDispatcher(deps);
const evt = () => ({ preventDefault: mock(() => {}) });
fn({
nav,
dispatch: (action) => dispatch(action, evt() as any),
toggleHelp: () => (help = !help),
helpOpen: () => help,
});
dispose();
});
}
/** Capture nav.action emits during `fn`. Returns the captured payloads. */
function captureNavActions(fn: () => void) {
const captured: { action: KeybindActionName; tab: TABS; pane: number }[] = [];
const unsub = on("nav.action", (d) => {
captured.push(d as any);
});
try {
fn();
} finally {
unsub();
}
return captured;
}
// ── 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", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.FEED); // depth-tab → enter content to test the list move
nav.enterTabContent();
expect(nav.isDepthTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
const events = captureNavActions(() => dispatch("move-down"));
expect(events).toHaveLength(1);
expect(events[0].action).toBe("move-down");
expect(events[0].tab).toBe(TABS.FEED);
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
});
});
test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.MYSHOWS);
nav.enterTabContent();
const beforeDepth = nav.currentDepth();
const events = captureNavActions(() => dispatch("move-up"));
expect(events).toHaveLength(1);
expect(events[0].action).toBe("move-up");
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
// depth is untouched by j/k (only h/l and the page's open() touch it).
expect(nav.currentDepth()).toBe(beforeDepth);
});
});
// ── 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)", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.DISCOVER);
nav.enterTabContent();
expect(nav.isDepthTab()).toBe(true);
expect(nav.currentDepth()).toBe(0);
const events = captureNavActions(() => dispatch("swipe-next"));
expect(events).toHaveLength(1);
expect(events[0].action).toBe("open");
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
// the drill (pushDepth) is the page's job on `open`; dispatch only emits.
expect(nav.currentDepth()).toBe(0);
});
});
test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.FEED);
nav.enterTabContent();
// simulate the page's open() having drilled one level.
nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 });
expect(nav.currentDepth()).toBe(1);
const events = captureNavActions(() => dispatch("swipe-prev"));
expect(events).toHaveLength(0); // a pop emits nothing — it just pops
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 returns focus to the tab root", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SETTINGS);
nav.enterTabContent();
expect(nav.currentDepth()).toBe(0);
expect(nav.atRootTab()).toBe(false);
const events = captureNavActions(() => dispatch("swipe-prev"));
expect(events).toHaveLength(0);
expect(nav.currentDepth()).toBe(0);
expect(nav.atRootTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane
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
expect(nav.activeTab()).toBe(TABS.DISCOVER);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
});
});
test("tab-next (]) / tab-prev ([) cycle tabs and keep focus context", () => {
withHarness(({ nav, dispatch }) => {
expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
dispatch("tab-next");
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(true);
dispatch("tab-prev");
expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.tabCursor()).toBe(TABS.FEED);
expect(nav.atRootTab()).toBe(true);
});
});
test("help toggles open on the 'help' action", () => {
withHarness(({ dispatch, helpOpen }) => {
expect(helpOpen()).toBe(false);
dispatch("help");
expect(helpOpen()).toBe(true);
dispatch("help");
expect(helpOpen()).toBe(false);
});
});

270
tests/nav-model.test.ts Normal file
View File

@@ -0,0 +1,270 @@
/**
* nav-model.test.ts — yazi remake task 01/07 unit/integration tests.
*
* Covers the tab-list-as-root navigation model:
* • createNavigation() exposes the nav factory directly (no Solid render
* 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.
* • swipe() is clamped to [1, paneCount] (content panes only; there is no
* pane-0 tab slot — the tab root is a flag, not a pane).
*/
import { test, expect } from "bun:test";
import { createRoot } from "solid-js";
import {
createNavigation,
DEPTH_CENTER_PANE,
NavMode,
} from "../src/context/navigation-store";
import { TABS, TabPaneCount } from "../src/utils/navigation";
/** Build a fresh nav graph inside a reactive root and run `fn` against it.
* Disposes the root afterwards so effects/signals don't leak between tests. */
function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
createRoot((dispose) => {
const nav = createNavigation();
fn(nav);
dispose();
});
}
test("createNavigation starts on the tab root (atRootTab true)", () => {
withNav((nav) => {
expect(nav.atRootTab()).toBe(true);
expect(nav.activeTab()).toBe(TABS.FEED);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
});
});
// ── depth-tab focus: reads/writes the top frame's focus ───────────────────────
test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => {
withNav((nav) => {
// FeeD is a depth-tab; its root frame is a the top frame on the stack.
nav.setActiveTab(TABS.FEED);
expect(nav.isDepthTab()).toBe(true);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0);
// setFocusedIndex writes to the *top* frame, not a pane map.
nav.setFocusedIndex(DEPTH_CENTER_PANE, 7);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(7);
// topFrame focus reflects the write.
expect(nav.topFrame()?.focus).toBe(7);
});
});
test("setFocusedIndex on a 2-frame stack writes only the top frame", () => {
withNav((nav) => {
nav.setActiveTab(TABS.FEED);
nav.enterTabContent();
// root frame focus 3, then push a child frame whose focus is 5.
nav.setFocusedIndex(DEPTH_CENTER_PANE, 3);
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 5 });
expect(nav.currentDepth()).toBe(1);
// writing the current pane updates only the top (child) frame.
nav.setFocusedIndex(DEPTH_CENTER_PANE, 9);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(9);
// the previous depth's focus is untouched.
expect(nav.depthFocus(0)).toBe(3);
// popping restores the parent frame's focus.
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(0);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(3);
});
});
// ── popDepth is a noop at depth 0 ────────────────────────────────────────────
test("popDepth at depth 0 is a noop (returns false, no frame lost)", () => {
withNav((nav) => {
nav.setActiveTab(TABS.MYSHOWS);
expect(nav.currentDepth()).toBe(0);
expect(nav.popDepth()).toBe(false);
expect(nav.currentDepth()).toBe(0);
expect(nav.depthStack().length).toBe(1);
});
});
// ── tab switching keeps focus context ────────────────────────────────────────
test("tab switch keeps focus context: at the root it stays at the root", () => {
withNav((nav) => {
// 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);
// switching tabs from content keeps the content context.
nav.setActiveTab(TABS.SETTINGS);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
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);
});
});
test("tab-switch resets mode/visual/command state", () => {
withNav((nav) => {
nav.enterVisual();
expect(nav.mode()).toBe(NavMode.VISUAL);
nav.setActiveTab(TABS.DISCOVER);
expect(nav.mode()).toBe(NavMode.NORMAL);
expect(nav.visualAnchor()).toBeNull();
});
});
// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
withNav((nav) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
expect(TabPaneCount[TABS.SEARCH]).toBe(3);
nav.enterTabContent();
expect(nav.activePane()).toBe(1);
// swipe left stays at 1 (no pane 0).
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
// swipe right up through the columns, then hold the upper bound.
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2);
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);
});
});
test("swipe on a single-pane fixed tab stays at its one content pane", () => {
withNav((nav) => {
nav.setActiveTab(TABS.PLAYER); // single-pane
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
nav.enterTabContent(); // lands on its one content pane (1)
expect(nav.activePane()).toBe(1);
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(1); // upper bound
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0
});
});
// ── ensureStack seeds a root frame on first visit to a depth-tab ─────────────
test("switching to a fresh depth-tab seeds its root frame", () => {
withNav((nav) => {
nav.setActiveTab(TABS.DISCOVER);
expect(nav.isDepthTab()).toBe(true);
expect(nav.depthStack().length).toBe(1);
expect(nav.topFrame()?.kind).toBe("discover:categories");
nav.setActiveTab(TABS.SETTINGS);
expect(nav.topFrame()?.kind).toBe("settings:sections");
});
});

View File

@@ -0,0 +1,105 @@
/**
* yazi-pages-depth.test.ts — task 03 page contract tests.
*
* The four depth-stack list tabs (Feed / MyShows / Discover / Settings) all
* render through `<YaziPaneRow>` with the parent pane reading the
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
* pages depend on for every depth-tab, asserting the parent-slot data model:
*
* • depth 0 → stack has exactly the root frame (parent pane is blank)
* • drill(l)→ push a child frame; stack length 2, parent = previous list
* • drill(l)→ push again; stack length 3 (Settings sections→items→editor)
* • pop(h) → stack shrinks; parent returns to the previous list
* • pop(h) → back at root; parent is blank again
*
* The visual "blank → list → list → blank" transition is the union of this
* data model (which list each depth renders) with `<YaziPaneRow>`'s null
* placeholder (covered by yazi-pane-row.test.tsx). Tested at the store level
* because the page `open()` closures are not exported and the nav store is
* the shared contract all four pages route through.
*/
import { test, expect } from "bun:test";
import { createRoot } from "solid-js";
import {
createNavigation,
DEPTH_CENTER_PANE,
} from "../src/context/navigation-store";
import { TABS, DEPTH_TABS } from "../src/utils/navigation";
import type { DepthFrame } from "../src/context/NavigationContext";
function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
createRoot((dispose) => {
fn(createNavigation());
dispose();
});
}
/** The depth-tabs that must render via <YaziPaneRow> (task 03 conversion). */
const CONVERTED_TABS = [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS];
for (const tab of CONVERTED_TABS) {
const name = TABS[tab];
test(`${name}: depth 0 → 1 → 2 push/pop keeps the parent-slot contract`, () => {
withNav((nav) => {
nav.setActiveTab(tab);
expect(nav.isDepthTab()).toBe(true);
// depth 0: exactly the root frame → parent pane renders blank.
expect(nav.currentDepth()).toBe(0);
expect(nav.depthStack()).toHaveLength(1);
// drill (l): page open() pushes a child frame — parent becomes
// the previous-depth list.
const child: DepthFrame = { kind: `${name.toLowerCase()}:child`, ctx: "c1", focus: 0 };
nav.pushDepth(child);
nav.setActivePane(DEPTH_CENTER_PANE);
expect(nav.currentDepth()).toBe(1);
expect(nav.depthStack()).toHaveLength(2);
// the parent (depth 0) frame is still the root; the top is the child.
expect(nav.depthStack()[0]).toBe(nav.depthStack()[0]);
expect(nav.topFrame()).toEqual(child);
// drill again (l): push a second child — parent shows the first
// child's list (the chain Settings exercises: sections→items→editor).
const grandchild: DepthFrame = { kind: `${name.toLowerCase()}:grand`, ctx: "g1", focus: 0 };
nav.pushDepth(grandchild);
expect(nav.currentDepth()).toBe(2);
expect(nav.depthStack()).toHaveLength(3);
expect(nav.topFrame()).toEqual(grandchild);
// pop (h): back to depth 1 — parent frame is the root, top is child.
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(1);
expect(nav.depthStack()).toHaveLength(2);
expect(nav.topFrame()).toEqual(child);
// pop (h): back to depth 0 — parent pane is blank again.
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(0);
expect(nav.depthStack()).toHaveLength(1);
});
});
test(`${name}: pop (h) at depth 0 is a noop (returns false, root kept)`, () => {
withNav((nav) => {
nav.setActiveTab(tab);
expect(nav.currentDepth()).toBe(0);
expect(nav.popDepth()).toBe(false);
expect(nav.currentDepth()).toBe(0);
// the root frame is preserved (parent stays blank, not undefined).
expect(nav.depthStack()).toHaveLength(1);
expect(nav.topFrame()).toBeDefined();
});
});
}
// ── DEPTH_TABS covers exactly the four converted pages ───────────────────────
test("DEPTH_TABS is exactly the four converted list tabs", () => {
expect([...DEPTH_TABS].sort()).toEqual(
[TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS].sort(),
);
});

View File

@@ -0,0 +1,234 @@
/**
* YaziPaneRow tests — the 1:3:3 parent|current|preview layout primitive.
*
* Verified through the opentui test renderer's captured frames (the same
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
* observable as rendered column widths and border colors.
*
* • Unit: three columns render at 1:3:3 (e.g. 14/43/43 of 100) even when the
* parent and preview children are null, and the blank parent keeps its
* slot with a muted placeholder.
* • Integration: toggling `focused` moves the accent focus ring onto/off the
* current column; parent & preview borders stay muted either way.
*
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
* in bunfig.toml registers the solid JSX transform for the test runner, so
* JSX in this file compiles exactly like app code.
*/
import { describe, test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { YaziPaneRow } from "../src/components/YaziPaneRow";
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
type Frame = { lines: { spans: Span[] }[] };
// ── Frame introspection helpers ─────────────────────────────────────────────
function hexOf(fg: Span["fg"]): string | null {
if (!fg?.buffer) return null;
const b = fg.buffer;
if (b[3] === 0) return null;
return (
"#" +
[0, 1, 2]
.map((i) =>
Math.max(0, Math.min(255, Math.round(b[i] * 255)))
.toString(16)
.padStart(2, "0"),
)
.join("")
);
}
/** Column border colors, scanned from the top border row (`┌───┐…`). */
function columnBorders(spans: Frame): string[] {
const line = spans.lines[1];
if (!line) return [];
const out: string[] = [];
for (const sp of line.spans) {
for (const ch of sp.text) {
if (ch === "┌") out.push(hexOf(sp.fg) ?? "default");
}
}
return out;
}
/** Column widths (including borders), from the top border row. */
function columnWidths(spans: Frame): number[] {
const line = spans.lines[1];
if (!line) return [];
const widths: number[] = [];
for (const sp of line.spans) {
for (const ch of sp.text) {
if (ch === "┌") widths.push(0);
else if (widths.length && ch === "─") widths[widths.length - 1]++;
else if (widths.length && ch === "┐") widths[widths.length - 1] += 2;
}
}
return widths;
}
// Element children must be accessors (`() => JSX`): JSX elements are only
// constructed inside the renderer context (during the test render pass), so
// creating them eagerly in the test body would throw "No renderer found".
type TestPaneProps = {
parent?: unknown;
current?: (() => unknown) | unknown;
preview?: unknown;
focused?: unknown;
width?: number;
height?: number;
};
async function renderPaneRow(props: TestPaneProps): Promise<{
spans: Frame;
destroy: () => Promise<void>;
}> {
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<YaziPaneRow
parent={props.parent as any}
current={props.current as any}
preview={props.preview as any}
parentLabel="Up"
currentLabel="List"
previewLabel="Detail"
focused={props.focused as any}
/>
</ThemeProvider>
),
{ width: props.width ?? 100, height: props.height ?? 8, useThread: false },
);
for (let i = 0; i < 6; i++) {
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 40));
}
const spans = setup.captureSpans() as unknown as Frame;
return { spans, destroy: () => setup.renderer.destroy() };
}
const cleanups: (() => void | Promise<void>)[] = [];
afterAll(async () => {
for (const c of cleanups) {
try {
await c();
} catch {
// renderer already torn down — ignore
}
}
});
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
describe("YaziPaneRow layout", () => {
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
});
cleanups.push(destroy);
const widths = columnWidths(spans);
expect(widths).toHaveLength(3);
const [p, c, v] = widths;
// 100-wide row splits as 14 / 43 / 43 (1/7 : 3/7 : 3/7, borders included).
expect(p).toBe(14);
expect(c).toBe(43);
expect(v).toBe(43);
// Exact 1:3:3 proportion (within 1 col rounding).
expect(c).toBeGreaterThanOrEqual(p * 3 - 1);
expect(c).toBeLessThanOrEqual(p * 3 + 1);
expect(v).toBeGreaterThanOrEqual(p * 3 - 1);
expect(v).toBeLessThanOrEqual(p * 3 + 1);
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
expect(p).toBeGreaterThan(4);
const body = spans.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.join("\n");
expect(body).toContain("—");
expect(body).toContain("ITEM");
});
test("keeps the 1/7 parent slot across widths (ratio stable)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>x</text>,
preview: null,
width: 70,
});
cleanups.push(destroy);
const [p, c, v] = columnWidths(spans);
expect(p).toBe(10); // 70 → 10 / 30 / 30
expect(c).toBe(30);
expect(v).toBe(30);
});
});
// ── Integration: focused toggles the accent ring on the current column ─────
describe("YaziPaneRow focus ring", () => {
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
focused: true,
});
cleanups.push(destroy);
const [parent, current, preview] = columnBorders(spans);
// parent & preview are muted; current is the (different) accent color.
expect(parent).toBe(preview);
expect(current).not.toBe(parent);
expect(current).not.toBe("default");
});
test("focused=false mutes the current column (no accent ring anywhere)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
focused: false,
});
cleanups.push(destroy);
const [parent, current, preview] = columnBorders(spans);
expect(current).toBe(parent);
expect(preview).toBe(parent);
});
test("accepts an accessor for focused (reactive boolean)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
focused: () => true,
});
cleanups.push(destroy);
const [parent, current] = columnBorders(spans);
expect(current).not.toBe(parent); // accessor resolves true → accent ring
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
focused: () => false,
});
cleanups.push(destroy2);
const [p2, c2] = columnBorders(spans2);
expect(c2).toBe(p2); // accessor resolves false → muted
});
test("defaults to focused (current column carries the accent ring)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
});
cleanups.push(destroy);
const [parent, current] = columnBorders(spans);
expect(current).not.toBe(parent);
});
});