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).
This commit is contained in:
2026-07-31 17:54:22 -04:00
parent 139a258987
commit 3f61303756
6 changed files with 944 additions and 999 deletions

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(),
);
});