Merge branch 'rewire-keybinds'
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Shell — yazi-style application chrome.
|
* Shell — yazi-style application chrome.
|
||||||
*
|
*
|
||||||
* Renders the tabs as a vertical sidebar on the left (the root pane), the
|
* Renders the active page (which owns its own three-column parent | current |
|
||||||
* active page (which owns its own panes) to the right of it, and a bottom
|
* preview panes) full-width, with a bottom status/command bar that also
|
||||||
* status/command bar spanning the full width. A single `useKeyboard` router
|
* carries the tab strip. A single `useKeyboard` router translates keystrokes
|
||||||
* translates keystrokes (via the sequence-aware keybind matcher) into actions:
|
* (via the sequence-aware keybind matcher) into actions: the unified router
|
||||||
* global ones (tabs, modes, audio, quit, help, command) are handled here;
|
* 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`
|
* 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 { createSignal, Show, For } from "solid-js";
|
||||||
@@ -17,7 +18,6 @@ import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
|||||||
import {
|
import {
|
||||||
useNavigation,
|
useNavigation,
|
||||||
NavMode,
|
NavMode,
|
||||||
DEPTH_CENTER_PANE,
|
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
@@ -26,7 +26,8 @@ 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 { LayerGraph } from "@/utils/layer-graph";
|
import { LayerGraph } from "@/utils/layer-graph";
|
||||||
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
|
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||||
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[TABS.FEED]: "Feed",
|
||||||
@@ -37,57 +38,6 @@ const TAB_LABEL: Record<TABS, string> = {
|
|||||||
[TABS.SETTINGS]: "Settings",
|
[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 handled itself when the tab-list pane
|
|
||||||
* 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([
|
|
||||||
"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() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const t = theme.theme;
|
const t = theme.theme;
|
||||||
@@ -238,129 +188,13 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Unified router (normal + visual) ───────────────────────────────────────
|
// ── Unified router (normal + visual) ───────────────────────────────────────
|
||||||
function dispatch(action: KeybindActionName, evt: any) {
|
const { dispatch } = createDispatcher({
|
||||||
const tab = nav.activeTab();
|
nav,
|
||||||
const pane = nav.activePane();
|
audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative },
|
||||||
switch (action) {
|
k,
|
||||||
// ── modes ──
|
setShowHelp,
|
||||||
case "escape":
|
advanceEpisode,
|
||||||
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;
|
|
||||||
}
|
|
||||||
// ── pane swipe / depth nav ──
|
|
||||||
// h/l use swipe() on fixed-pane tabs (clamped to [0,
|
|
||||||
// paneCount-1] — there is no sidebar pane). Depth-tabs: l
|
|
||||||
// at the center drills in (open); h at the center pops a
|
|
||||||
// depth (noop at depth 0).
|
|
||||||
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(),
|
|
||||||
});
|
});
|
||||||
} 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(
|
useKeyboard(
|
||||||
(evt: any) => {
|
(evt: any) => {
|
||||||
|
|||||||
231
src/utils/dispatch.ts
Normal file
231
src/utils/dispatch.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 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 (task 06):
|
||||||
|
*
|
||||||
|
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`) are
|
||||||
|
* the SOLE tab switchers; focus always lands on `DEPTH_CENTER_PANE`.
|
||||||
|
* • `h`/`l` are `swipe-prev`/`swipe-next`:
|
||||||
|
* - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth
|
||||||
|
* (noop + inert at depth 0 — no error, no pane change, like yazi at root)
|
||||||
|
* - fixed-pane tabs: `swipe(∓1, count)` clamped to [0, paneCount-1]
|
||||||
|
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
|
||||||
|
* `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only.
|
||||||
|
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
|
||||||
|
* are unchanged from the pre-rewrite Shell.
|
||||||
|
*
|
||||||
|
* There is NO sidebar pane and NO `SIDEBAR_ACTIONS` set here (removed in task
|
||||||
|
* 06): the sidebar's special-cased j/k branch is gone; every list movement
|
||||||
|
* goes straight to the active page via `nav.action`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// ── pane swipe / depth nav ──
|
||||||
|
// h/l swipe() on fixed-pane tabs (clamped to [0, paneCount-1] —
|
||||||
|
// there is no sidebar pane). Depth-tabs: l at the center drills
|
||||||
|
// in (emits `open`); h at the center pops a depth, noop at depth 0
|
||||||
|
// (inert — like yazi at root: no pane change, no error).
|
||||||
|
if (action === "swipe-prev") {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (
|
||||||
|
nav.isDepthTab() &&
|
||||||
|
nav.activePane() === DEPTH_CENTER_PANE
|
||||||
|
) {
|
||||||
|
if (nav.currentDepth() > 0) nav.popDepth();
|
||||||
|
// else: noop at depth 0 (inert)
|
||||||
|
} else {
|
||||||
|
nav.swipe(-1, TabPaneCount[tab]);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
195
tests/dispatch-keybinds.test.ts
Normal file
195
tests/dispatch-keybinds.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* dispatch-keybinds.test.ts — yazi remake task 06 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:
|
||||||
|
*
|
||||||
|
* • Unit: `dispatch("move-down")` on a depth-tab current pane emits
|
||||||
|
* `nav.action { action: "move-down" }` pointing at the current pane only.
|
||||||
|
* • Integration: `swipe-next` (l) on a depth-tab at depth 0 emits `open`
|
||||||
|
* (drill); `swipe-prev` (h) at depth 1 pops to depth 0; `swipe-prev` at
|
||||||
|
* depth 0 is an inert noop (no emit, no pane/depth change, no error).
|
||||||
|
* • Acceptance: digit keys (`tab-goto-N`) switch tabs and focus lands on
|
||||||
|
* `DEPTH_CENTER_PANE`; `tab-next`/`tab-prev` cycle; `h` at depth 0 is
|
||||||
|
* inert; `j`/`k` (move-down/up) flow to `nav.action` for the current pane.
|
||||||
|
*
|
||||||
|
* The dispatcher is built with fake audio/k/help deps (the drills/moves under
|
||||||
|
* test never reach the audio or advanceEpisode paths) and a real nav store —
|
||||||
|
* the shared contract depth/l movement routes through.
|
||||||
|
*/
|
||||||
|
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 toggleHelp = () => (help = !help);
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 → focus is the center pane
|
||||||
|
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);
|
||||||
|
const beforeDepth = nav.currentDepth();
|
||||||
|
const beforePane = nav.activePane();
|
||||||
|
const events = captureNavActions(() => dispatch("move-up"));
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].action).toBe("move-up");
|
||||||
|
expect(events[0].pane).toBe(beforePane);
|
||||||
|
// 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 noop ──────────────────────
|
||||||
|
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.DISCOVER);
|
||||||
|
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);
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch('swipe-prev') at depth 0 is an inert noop (no emit, no pane/depth change, no error)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.SETTINGS);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
const paneBefore = nav.activePane();
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(nav.currentDepth()).toBe(0);
|
||||||
|
expect(nav.activePane()).toBe(paneBefore);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Acceptance: digit keys switch tabs and land focus on the current pane ──
|
||||||
|
test("tab-goto-N switches tabs; focus always lands on DEPTH_CENTER_PANE", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
// start on FEED (depth-tab, depth 0).
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
|
||||||
|
dispatch("tab-goto-3"); // → Discover
|
||||||
|
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
|
||||||
|
dispatch("tab-goto-2"); // → MyShows
|
||||||
|
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
|
||||||
|
// fixed-pane tab also lands on pane 0.
|
||||||
|
dispatch("tab-goto-4"); // → Search
|
||||||
|
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab-next (]) / tab-prev ([) cycle tabs and reset focus to the current pane", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.FEED);
|
||||||
|
dispatch("tab-next");
|
||||||
|
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
|
||||||
|
dispatch("tab-prev");
|
||||||
|
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user