Merge branch 'rearchitect-nav-model'

# Conflicts:
#	src/utils/navigation.ts
This commit is contained in:
2026-07-31 17:12:14 -04:00
6 changed files with 708 additions and 490 deletions

View File

@@ -17,7 +17,6 @@ import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { import {
useNavigation, useNavigation,
NavMode, NavMode,
SIDEBAR_PANE,
DEPTH_CENTER_PANE, DEPTH_CENTER_PANE,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
@@ -26,7 +25,8 @@ import { useFeedStore } from "@/stores/feed";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus"; import { emit } from "@/utils/event-bus";
import { TABS, TabsCount, TabPaneCount, LayerGraph } from "@/utils/navigation"; import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
const TAB_LABEL: Record<TABS, string> = { const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed", [TABS.FEED]: "Feed",
@@ -64,8 +64,11 @@ const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>(
], ],
); );
/** Movement actions the sidebar pane handles itself (its list = the tabs, /** Movement actions the sidebar pane handled itself when the tab-list pane
* length TabsCount). Routed through the standard move/gotoIndex API. */ * still existed (its list = the tabs, length TabsCount). The sidebar pane was
* removed in the yazi remake nav rework (task 01); these now fall through
* to the active page's PAGE_ACTIONS dispatch. Retained here and fully
* removed in task 06's keybind rewrite. */
const SIDEBAR_ACTIONS: ReadonlySet<KeybindActionName> = new Set([ const SIDEBAR_ACTIONS: ReadonlySet<KeybindActionName> = new Set([
"move-down", "move-down",
"move-up", "move-up",
@@ -282,30 +285,11 @@ export function Shell() {
nav.setActiveTab(dt); nav.setActiveTab(dt);
break; 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 ── // ── pane swipe / depth nav ──
// h/l always uses the unified swipe() (clamped to the sidebar on // h/l use swipe() on fixed-pane tabs (clamped to [0,
// the left). Depth-tabs additionally: l at the center drills in // paneCount-1] — there is no sidebar pane). Depth-tabs: l
// (open), h at the center pops a depth (or swipes to sidebar at // at the center drills in (open); h at the center pops a
// root). Fixed-pane tabs just swipe between their panes. // depth (noop at depth 0).
if (action === "swipe-prev") { if (action === "swipe-prev") {
evt.preventDefault(); evt.preventDefault();
if ( if (
@@ -432,22 +416,22 @@ export function Shell() {
> >
{(tab) => { {(tab) => {
const active = () => nav.activeTab() === tab; const active = () => nav.activeTab() === tab;
const focused = () => // The sidebar pane is gone (task 01); the tab strip stays
active() && nav.activePane() === SIDEBAR_PANE; // rendered for now (removed in task 05) and highlights the
// active tab. Clicking just switches tabs — no pane focus.
return ( return (
<box <box
flexDirection="row" flexDirection="row"
backgroundColor={ backgroundColor={
focused() ? t.accent : active() ? t.primary : t.background active() ? t.primary : t.background
} }
paddingLeft={1} paddingLeft={1}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(SIDEBAR_PANE);
nav.setActiveTab(tab); nav.setActiveTab(tab);
}} }}
> >
<text fg={focused() || active() ? t.surface : t.textMuted}> <text fg={active() ? t.surface : t.textMuted}>
{focused() ? " " : " "} {active() ? " " : " "}
{tab}. {TAB_LABEL[tab]} {tab}. {TAB_LABEL[tab]}
</text> </text>
</box> </box>
@@ -484,9 +468,7 @@ export function Shell() {
</text> </text>
<text fg={t.textMuted} paddingLeft={1}> <text fg={t.textMuted} paddingLeft={1}>
{TAB_LABEL[nav.activeTab()]} ·{" "} {TAB_LABEL[nav.activeTab()]} ·{" "}
{nav.activePane() === SIDEBAR_PANE {nav.isDepthTab()
? "tabs"
: nav.isDepthTab()
? `depth ${nav.currentDepth()}` ? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`} : `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
</text> </text>

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 { createSimpleContext } from "./helper";
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; import { createNavigation } from "./navigation-store";
// ── Yazi-style navigation state ────────────────────────────────────────────── // Re-export the entire nav model surface so existing imports from
// Two pane models coexist: // `@/context/NavigationContext` keep resolving.
// export * from "./navigation-store";
// • 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;
export const { use: useNavigation, provider: NavigationProvider } = export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({ createSimpleContext({
name: "Navigation", name: "Navigation",
init: () => { init: () => createNavigation(),
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,
};
},
}); });

View File

@@ -0,0 +1,456 @@
/**
* 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:
*
* • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
* focusable pane — the current column (DEPTH_CENTER_PANE = 0). The parent
* column renders the previous depth's list (blank at depth 0); the preview
* column renders the hovered item. `l`/Enter drills in (push a frame);
* `h` pops a depth (a noop at depth 0). Depth is unbounded — each page
* decides per-item whether an item is drillable and what child list to
* push. Drill/pop is dispatched by the Shell, never via swipe.
*
* • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
* indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the
* parent/current/preview columns with `h`/`l`, clamped to [0, paneCount-1].
*
* Tabs switch only via digit keys `1`-`6`, `[`/`]`, or (later) a bottom tab
* strip. There is NO sidebar pane: `activePane` is plain tab pane state and is
* never a chrome/tab-list pane.
*/
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 pane. For depth-tabs this is the single focusable pane (the
* center column, index 0); for fixed-pane tabs it's the default landing pane
* on tab-enter. Every tab-enter resets `activePane` to this value. */
export const DEPTH_CENTER_PANE = 0 as PaneId;
/** 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;
/**
* 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);
// App focus starts on the current pane (center, idx 0); every tab
// switch also resets here. There is no sidebar pane.
const [activePane, setActivePane] =
createSignal<PaneId>(DEPTH_CENTER_PANE);
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.
*
* - seed a root frame for fresh depth-tabs
* - reset focus to the current/center pane (no sidebar pane)
* - clear mode/command/visual/count state */
const applyTabSwitch = (tab: TABS) => {
ensureStack(tab);
batch(() => {
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);
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 pane (fixed-pane tabs only). `dir` =
* -1 (left, toward parent) or +1 (right, toward preview). Clamped to
* [0, paneCount-1] — there is no sidebar pane to land on. */
const swipe = (dir: -1 | 1, paneCount: number) => {
setActivePane((p) => {
const n = Math.max(0, Math.min(paneCount - 1, p + dir));
return n;
});
};
// ── 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,
// 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>;

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 { export enum DIRECTION {
Increment, Increment,
Decrement, Decrement,
@@ -49,40 +42,31 @@ export function rootFrameFor(
} }
} }
export const LayerGraph = { // The per-tab page components + pane counts live in `src/utils/layer-graph.ts`,
[TABS.FEED]: FeedPage, // split out so this module stays free of `.tsx`/JSX imports (unit-testable).
[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,
};
// Yazi-style pane grow ratios (parent : current : preview) = [1, 3, 3]. // Yazi-style pane grow ratios (parent : current : preview). Panes use
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless // flexGrow (Yoga) so columns always sum to the row width regardless of
// of terminal size — more robust than fixed percentages and exactly mirrors // 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). // 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 = { export const PANE_RATIO = {
parent: 1, parent: 1,
current: 3, current: 3,
preview: 3, preview: 3,
} as const; } as const;
// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/ // Number of *focusable* content panes per tab. The three visible columns
// Settings) now have a single focusable content pane (the center/current // (parent | current | preview) are a *render* concern, NOT three panes — for
// column at depth 0..N); prev and preview are derived, not focusable. Search // depth-tabs only the current column (index 0) is focusable, so this is 1.
// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch // Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
// routes depth-tabs to push/pop instead of pane swipe. Defined here (after // with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
// TABS) to avoid re-introducing the old NavigationContext top-level-init // Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
// circular deadlock. // here (after TABS) to avoid re-introducing the old NavigationContext
// top-level-init circular deadlock.
export const TabPaneCount: Record<TABS, number> = { export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 1, // depth: feeds → episodes → preview [TABS.FEED]: 1, // depth: feeds → episodes → preview
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview [TABS.MYSHOWS]: 1, // depth: shows → episodes → preview

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

@@ -0,0 +1,152 @@
/**
* nav-model.test.ts — yazi remake task 01 unit/integration tests.
*
* Covers the removal of the SIDEBAR_PANE concept:
* • createNavigation() exposes the nav factory directly (no Solid render
* needed), wrapped in a createRoot so effects register/dispose.
* • depth-tab focusedIndex depth-current read/writes the top frame's focus.
* • the tab-switch effect resets activePane to DEPTH_CENTER_PANE (0), not -1.
* • swipe() clamps to [0, paneCount-1] (no -1 sidebar slot).
*/
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 initial activePane is DEPTH_CENTER_PANE (0), not -1", () => {
withNav((nav) => {
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.activePane()).toBe(0);
});
});
// ── 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 { kind: "feeds", focus: 0 }.
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);
// 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-switch resets focus to DEPTH_CENTER_PANE, not a sidebar ──────────────
test("tab-switch effect resets activePane to DEPTH_CENTER_PANE", () => {
withNav((nav) => {
// start on a depth-tab, land on the current pane.
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// move pane focus away (swipe is a noop for depth-tabs count=1, so
// instead prove the effect resets on tab change).
nav.setActiveTab(TABS.SEARCH);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// switch to another tab; the effect must reset to 0, never -1.
nav.setActiveTab(TABS.SETTINGS);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.activePane()).toBeGreaterThanOrEqual(0);
});
});
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 [0, paneCount-1] (no sidebar slot) ────────────────────────
test("swipe(-1, 3) on a fixed-pane tab clamps to 0, not -1", () => {
withNav((nav) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
expect(TabPaneCount[TABS.SEARCH]).toBe(3);
// tab-switch effect lands us on pane 0 (DEPTH_CENTER_PANE).
expect(nav.activePane()).toBe(0);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(0); // lower bound, never -1
// swipe right twice then back: clamps to [0, 2].
nav.swipe(1, 3);
nav.swipe(1, 3);
expect(nav.activePane()).toBe(2); // upper bound
nav.swipe(1, 3);
expect(nav.activePane()).toBe(2); // never exceeds count-1
nav.swipe(-1, 3);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, 3);
expect(nav.activePane()).toBe(0);
});
});
test("swipe on a single-pane fixed tab stays at 0", () => {
withNav((nav) => {
nav.setActiveTab(TABS.PLAYER); // single-pane
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
expect(nav.activePane()).toBe(0);
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(0);
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(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");
});
});