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.
This commit is contained in:
2026-07-31 19:19:20 -04:00
parent 866fcd7574
commit b24c83711d
3 changed files with 442 additions and 182 deletions

View File

@@ -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, string> = {
[TABS.FEED]: "Feed",
@@ -37,57 +38,6 @@ const TAB_LABEL: Record<TABS, string> = {
[TABS.SETTINGS]: "Settings",
};
/** Actions the active page is responsible for (pane/list-local). */
const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>(
[
"move-down",
"move-up",
"page-down",
"page-up",
"full-down",
"full-up",
"jump-down",
"jump-up",
"goto-top",
"goto-bottom",
"toggle-select",
"visual-mode",
"toggle-all",
"invert-all",
"open",
"open-interactive",
"search",
"filter",
"sort",
"toggle-hidden",
"refresh",
],
);
/** Movement actions the sidebar pane 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() {
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) => {

231
src/utils/dispatch.ts Normal file
View 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 };