From b24c83711d4a2e328ef1dfdb8f4c66b305289ab4 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 31 Jul 2026 19:19:20 -0400 Subject: [PATCH] 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. --- src/components/Shell.tsx | 198 +++------------------------ src/utils/dispatch.ts | 231 ++++++++++++++++++++++++++++++++ tests/dispatch-keybinds.test.ts | 195 +++++++++++++++++++++++++++ 3 files changed, 442 insertions(+), 182 deletions(-) create mode 100644 src/utils/dispatch.ts create mode 100644 tests/dispatch-keybinds.test.ts diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 74c9d53..a05db6c 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -1,13 +1,14 @@ /** * 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"; @@ -17,7 +18,6 @@ import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useNavigation, NavMode, - DEPTH_CENTER_PANE, } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; @@ -26,7 +26,8 @@ import type { Episode } from "@/types/episode"; import { useToast } from "@/ui/toast"; import { emit } from "@/utils/event-bus"; 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.FEED]: "Feed", @@ -37,57 +38,6 @@ const TAB_LABEL: Record = { [TABS.SETTINGS]: "Settings", }; -/** Actions the active page is responsible for (pane/list-local). */ -const PAGE_ACTIONS: ReadonlySet = new Set( - [ - "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 = 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; @@ -238,129 +188,13 @@ 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; - } - // ── 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() }); - } - } - } - } + const { dispatch } = createDispatcher({ + nav, + audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative }, + k, + setShowHelp, + advanceEpisode, + }); useKeyboard( (evt: any) => { diff --git a/src/utils/dispatch.ts b/src/utils/dispatch.ts new file mode 100644 index 0000000..0f69b9a --- /dev/null +++ b/src/utils/dispatch.ts @@ -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 = new Set([ + "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; + seekRelative: (n: number) => Promise; + }; + 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 }; diff --git a/tests/dispatch-keybinds.test.ts b/tests/dispatch-keybinds.test.ts new file mode 100644 index 0000000..7e6f5b4 --- /dev/null +++ b/tests/dispatch-keybinds.test.ts @@ -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; + 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); + }); +});