fix navigation flow
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.harness/
|
||||
.ralpi
|
||||
|
||||
@@ -15,10 +15,7 @@ import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
@@ -28,6 +25,8 @@ import { emit } from "@/utils/event-bus";
|
||||
import { LayerGraph } from "@/utils/layer-graph";
|
||||
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||
import { createDispatcher } from "@/utils/dispatch";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
@@ -94,7 +93,7 @@ export function Shell() {
|
||||
case "q":
|
||||
case "quit":
|
||||
case "exit":
|
||||
process.exit(0);
|
||||
return process.exit(0);
|
||||
case "refresh":
|
||||
case "r":
|
||||
emit("nav.action", {
|
||||
@@ -190,7 +189,10 @@ export function Shell() {
|
||||
// ── Unified router (normal + visual) ───────────────────────────────────────
|
||||
const { dispatch } = createDispatcher({
|
||||
nav,
|
||||
audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative },
|
||||
audio: {
|
||||
togglePlayback: audio.togglePlayback,
|
||||
seekRelative: audio.seekRelative,
|
||||
},
|
||||
k,
|
||||
setShowHelp,
|
||||
advanceEpisode,
|
||||
@@ -232,11 +234,36 @@ export function Shell() {
|
||||
height="100%"
|
||||
backgroundColor={t.surface}
|
||||
>
|
||||
{/* ── Middle row: full-width active page ──────────────────────────────── */}
|
||||
<box flexGrow={1} width="100%">
|
||||
{LayerGraph[nav.activeTab()]()}
|
||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||
<box flexDirection="row" flexGrow={1} width="100%">
|
||||
<Show
|
||||
when={nav.atRootTab()}
|
||||
fallback={
|
||||
<box flexGrow={1} width="100%">
|
||||
{LayerGraph[nav.activeTab()]()}
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
||||
<YaziPaneRow
|
||||
parent={
|
||||
<box padding={1}>
|
||||
<text fg={t.textMuted}>—</text>
|
||||
</box>
|
||||
}
|
||||
current={<TabListPane />}
|
||||
preview={
|
||||
<box padding={1}>
|
||||
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
||||
</box>
|
||||
}
|
||||
parentLabel="Up"
|
||||
currentLabel="Tabs"
|
||||
previewLabel=""
|
||||
focused
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -252,10 +279,13 @@ export function Shell() {
|
||||
{modeLabel()}
|
||||
</text>
|
||||
<text fg={t.textMuted} paddingLeft={1}>
|
||||
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
||||
{nav.isDepthTab()
|
||||
? `depth ${nav.currentDepth()}`
|
||||
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
||||
{nav.atRootTab()
|
||||
? "Tabs · root"
|
||||
: `${TAB_LABEL[nav.activeTab()]} · ${
|
||||
nav.isDepthTab()
|
||||
? `depth ${nav.currentDepth()}`
|
||||
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
||||
}`}
|
||||
</text>
|
||||
<Show when={nav.selectedIds().length > 0}>
|
||||
<text fg={t.warning} paddingLeft={1}>
|
||||
@@ -271,27 +301,6 @@ export function Shell() {
|
||||
<text fg={t.textMuted} paddingRight={1}>
|
||||
{pendingLabel()}
|
||||
</text>
|
||||
{/* ── Tab strip ─────────────────────────────────────────────────── */}
|
||||
<For
|
||||
each={Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
)}
|
||||
>
|
||||
{(tab) => {
|
||||
const active = () => nav.activeTab() === tab;
|
||||
return (
|
||||
<text
|
||||
fg={active() ? t.surface : t.textMuted}
|
||||
backgroundColor={
|
||||
active() ? t.primary : undefined
|
||||
}
|
||||
>
|
||||
{active() ? "≡" : " "}[{tab}]{" "}
|
||||
{TAB_LABEL[tab]}{" "}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<text fg={t.textMuted} paddingRight={1}>
|
||||
~
|
||||
</text>
|
||||
@@ -310,7 +319,6 @@ export function Shell() {
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
|
||||
<Show when={showHelp()}>
|
||||
<HelpOverlay
|
||||
@@ -338,7 +346,9 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
||||
{
|
||||
group: "Panes",
|
||||
items: [
|
||||
["h/l", "swipe pane"],
|
||||
["j/k", "switch tab (tab panel)"],
|
||||
["l/enter", "enter tab content"],
|
||||
["h", "back to tab panel"],
|
||||
["1-6 / [ ]", "switch tabs"],
|
||||
[":", "command"],
|
||||
["~", "help"],
|
||||
|
||||
78
src/components/TabPanel.tsx
Normal file
78
src/components/TabPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
|
||||
* PREVIEW flow (replaces the old fixed chrome tab column).
|
||||
*
|
||||
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
|
||||
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
|
||||
* `focused` only matters to the surrounding frame (the CURRENT column draws
|
||||
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
|
||||
* column (`muted`), the cursor highlight is suppressed and only the active ●
|
||||
* shows, so it reads as the read-only parent listing.
|
||||
*/
|
||||
|
||||
import { For } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||
const TAB_ORDER = Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
) as TABS[];
|
||||
|
||||
export function TabListPane(props: { muted?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
|
||||
const cursor = () => nav.tabCursor();
|
||||
const active = () => nav.activeTab();
|
||||
const muted = () => props.muted ?? false;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab && !muted();
|
||||
const isActive = () => active() === tab;
|
||||
const fg = () =>
|
||||
isCursor()
|
||||
? theme.textSelectedPrimary
|
||||
: isActive()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
backgroundColor={isCursor() ? theme.primary : "transparent"}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
|
||||
>
|
||||
{isActive() ? "●" : " "}
|
||||
</text>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
|
||||
>
|
||||
{tab}
|
||||
</text>
|
||||
<text fg={fg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
@@ -18,23 +18,31 @@
|
||||
* 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:
|
||||
* Two pane models coexist under a single TAB list:
|
||||
*
|
||||
* • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal,
|
||||
* focusable pane at the left of every tab's content, just like in yazi.
|
||||
* Starting focus lives here; tab switches made from here keep focus here.
|
||||
* When it is focused, j/k moves the tab cursor (`tabCursor`) and
|
||||
* `l`/Enter opens the hovered tab into its content. Swiping left past
|
||||
* it goes out of the panes (inert — there is no pane beyond it).
|
||||
*
|
||||
* • 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.
|
||||
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). 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. Depth is unbounded. At depth 0 `h` moves focus
|
||||
* to the tab list (TAB_PANE).
|
||||
*
|
||||
* • 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].
|
||||
* parent/current/preview columns with `h`/`l`, clamped to
|
||||
* [1, paneCount]; `h` on the first content pane (1) moves focus to the tab
|
||||
* list; `h` on the tab list stays out-of-panear (inert).
|
||||
*
|
||||
* 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.
|
||||
* Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
|
||||
* Focus on the tab list persists across a tab switch; from there `l`/Enter
|
||||
* drops into the active tab's content (panes 1..N).
|
||||
*/
|
||||
import { createSignal, batch } from "solid-js";
|
||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||
@@ -46,19 +54,28 @@ export enum NavMode {
|
||||
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;
|
||||
/** The current content pane of the active tab, i.e. the focusable column
|
||||
* (index 1) for depth-tabs, and the default landing pane for fixed-pane
|
||||
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
|
||||
* while focused on content resets `activePane` to this pane (unless already
|
||||
* on the tab list). */
|
||||
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||
|
||||
/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */
|
||||
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
||||
* left of the active tab's content (1..n). It is the app's outermost pane:
|
||||
* starting focus lives here, tab switches made from it keep focus on it, and
|
||||
* swiping left past the first content pane returns to it. Swiping left again
|
||||
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
||||
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
||||
/** Content pane slots for fixed-pane tabs (Search). Values are the global
|
||||
* pane indices (content starts at 1). */
|
||||
export enum PaneSlot {
|
||||
PARENT = 0, // depth-tabs: center/current; Search: input
|
||||
CURRENT = 1, // Search: results
|
||||
PREVIEW = 2, // Search: detail
|
||||
PARENT = 1, // Search: input
|
||||
CURRENT = 2, // Search: results
|
||||
PREVIEW = 3, // Search: detail
|
||||
}
|
||||
|
||||
export type PaneId = number; // 0-based index into the active tab's pane list
|
||||
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
||||
|
||||
// ── Depth stack ──────────────────────────────────────────────────────────────
|
||||
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
|
||||
@@ -88,25 +105,35 @@ const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
|
||||
*/
|
||||
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);
|
||||
// The root tab panel's cursor — which tab j/k is currently hovering. It is
|
||||
// independent of `activeTab` until l/Enter activates it (activateTabCursor)
|
||||
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
||||
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
||||
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
||||
// App focus starts on the tab list (the app root). `activePane` drives the
|
||||
// fixed-pane pages (Search/Player) and each page's content focus ring;
|
||||
// depth-tab focus is instead described by the per-tab depth stack plus the
|
||||
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and
|
||||
// slides into the UP/parent pane once content is opened).
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
||||
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
||||
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
||||
const [atRootTabSignal, setAtTabRoot] = createSignal(true);
|
||||
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)] });
|
||||
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 [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const [selections, setSelections] = createSignal<SelectionMap>({});
|
||||
const [visualAnchor, setVisualAnchor] = createSignal<{
|
||||
paneKey: string;
|
||||
@@ -132,13 +159,23 @@ export function createNavigation() {
|
||||
* 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)
|
||||
* - when switching to a special (fixed-pane) tab from the tab root, leave the
|
||||
* root — those tabs render only their content, never the tab-list view.
|
||||
* - keep focus on the tab root if it is focused (depth-tab switch),
|
||||
* otherwise recenter on the active tab's current/center pane
|
||||
* - clear mode/command/visual/count state */
|
||||
const applyTabSwitch = (tab: TABS) => {
|
||||
ensureStack(tab);
|
||||
batch(() => {
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
// A depth-tab switch from the root keeps the root; switching to a
|
||||
// special (fixed-pane) tab always leaves it. Switches made from
|
||||
// inside content drop into the new tab's content pane.
|
||||
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) {
|
||||
setAtTabRoot(false);
|
||||
}
|
||||
if (!atRootTabSignal()) {
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
}
|
||||
setMode(NavMode.NORMAL);
|
||||
setCount(null);
|
||||
setCommandBuffer("");
|
||||
@@ -154,8 +191,7 @@ export function createNavigation() {
|
||||
// (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 currentDepth = (): number => Math.max(0, depthStack().length - 1);
|
||||
const topFrame = (): DepthFrame | undefined =>
|
||||
depthStack()[depthStack().length - 1];
|
||||
const isDepthTab = () => DEPTH_TABS.has(activeTab());
|
||||
@@ -199,6 +235,9 @@ export function createNavigation() {
|
||||
* tab change — programmatic or key-driven — goes through one path. */
|
||||
const switchTab = (tab: TABS) => {
|
||||
setActiveTab(tab);
|
||||
// a direct tab switch re-syncs the root panel's cursor so the panel
|
||||
// reflects what is actually active.
|
||||
setTabCursor(tab);
|
||||
applyTabSwitch(tab);
|
||||
};
|
||||
const gotoTab = (tab: TABS) => {
|
||||
@@ -217,16 +256,53 @@ export function createNavigation() {
|
||||
// ── pane focus ──────────────────────────────────────────────────────────
|
||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||
|
||||
/** Move focus to the adjacent pane (fixed-pane tabs only). `dir` =
|
||||
/** Move focus to the adjacent content 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. */
|
||||
* [0, paneCount-1]. The root panel transition (from content pane 0 to
|
||||
* (1..TabPaneCount) is handled by the dispatcher, not here. */
|
||||
const swipe = (dir: -1 | 1, paneCount: number) => {
|
||||
setActivePane((p) => {
|
||||
const n = Math.max(0, Math.min(paneCount - 1, p + dir));
|
||||
const n = Math.max(1, Math.min(paneCount, p + dir));
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
||||
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
||||
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings)
|
||||
* participate; Search & Player are special and always show their content. */
|
||||
const atRootTab = (): boolean =>
|
||||
atRootTabSignal() && DEPTH_TABS.has(activeTab());
|
||||
|
||||
/** Open the active tab's content: the tab slides from CURRENT into the
|
||||
* UP/parent pane and focus lands on the content's current pane. */
|
||||
const enterTabContent = () => {
|
||||
setAtTabRoot(false);
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
};
|
||||
|
||||
/** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping
|
||||
* out of content at depth 0. */
|
||||
const backToTabRoot = () => {
|
||||
setAtTabRoot(true);
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
};
|
||||
|
||||
/** The tab the root's cursor is hovering (independent of activeTab). */
|
||||
const tabCursor = (): TABS => tabCursorSignal();
|
||||
|
||||
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
||||
const moveTabCursor = (dir: -1 | 1) => {
|
||||
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
||||
};
|
||||
|
||||
/** Open the hovered tab (switch to it and enter its content) from the root.
|
||||
* The yazi "open" of a tab row. */
|
||||
const activateTabCursor = () => {
|
||||
switchTab(tabCursorSignal());
|
||||
enterTabContent();
|
||||
};
|
||||
|
||||
// ── per-pane focus index ────────────────────────────────────────────────
|
||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
||||
|
||||
@@ -283,8 +359,7 @@ export function createNavigation() {
|
||||
};
|
||||
|
||||
// ── selection ───────────────────────────────────────────────────────────
|
||||
const selSet = (key: string): Set<string> =>
|
||||
selections()[key] ?? new Set();
|
||||
const selSet = (key: string): Set<string> => selections()[key] ?? new Set();
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
const key = paneKey();
|
||||
@@ -413,6 +488,13 @@ export function createNavigation() {
|
||||
setActiveTab: gotoTab,
|
||||
nextTab,
|
||||
prevTab,
|
||||
// tab root (app's outermost pane)
|
||||
atRootTab,
|
||||
enterTabContent,
|
||||
backToTabRoot,
|
||||
tabCursor,
|
||||
moveTabCursor,
|
||||
activateTabCursor,
|
||||
// pane focus
|
||||
setActivePane: setPane,
|
||||
swipe,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
@@ -151,7 +152,7 @@ function DiscoverPage() {
|
||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||
// mounts/unmounts cleanly on depth change.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1}>
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => (
|
||||
<box
|
||||
@@ -179,80 +180,29 @@ function DiscoverPage() {
|
||||
{/* depth 0: categories */}
|
||||
<Show when={depth() === 0}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
{/* depth ≥1: results */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = focusedPodIdx();
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text fg={index() === lf ? theme.surface : theme.success}>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -260,6 +210,57 @@ function DiscoverPage() {
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
{/* depth ≥1: results */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = focusedPodIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text fg={index() === lf ? theme.surface : theme.success}>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
@@ -316,8 +317,7 @@ function DiscoverPage() {
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{pod().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{pod().description?.slice(0, 400) ?? "No description available."}
|
||||
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<Show when={(pod().categories ?? []).length > 0}>
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
@@ -237,7 +238,7 @@ function FeedPage() {
|
||||
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
|
||||
// mounts/unmounts cleanly on depth change instead of swapping roots.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1}>
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
@@ -268,110 +269,110 @@ function FeedPage() {
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedFeedIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
({feedCount(item)})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedFeedIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
({feedCount(item)})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={depth() >= 1}>
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -439,12 +440,8 @@ function FeedPage() {
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(it.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(it.episode.duration)}
|
||||
</text>
|
||||
<text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(it.episode.duration)}</text>
|
||||
<Show when={downloadLabel(it.episode.id)}>
|
||||
<text fg={downloadColor(it.episode.id)}>
|
||||
{downloadLabel(it.episode.id)}
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
@@ -200,7 +201,7 @@ export function MyShowsPage() {
|
||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||
// mounts/unmounts cleanly on depth change.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1}>
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
@@ -231,105 +232,105 @@ export function MyShowsPage() {
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No shows. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = focusedShowIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No shows. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = focusedShowIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -357,8 +358,7 @@ export function MyShowsPage() {
|
||||
{show().episodes.length} episodes
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ??
|
||||
"No description."}
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
@@ -397,8 +397,7 @@ export function MyShowsPage() {
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{ep().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{ep().description?.slice(0, 400) ?? "No description available."}
|
||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* SearchPage — yazi-style 3-pane view.
|
||||
*
|
||||
* pane 0 (parent) — query input with recent-search history (clickable)
|
||||
* pane 1 (current) — search results list (navigate j/k)
|
||||
* pane 2 (preview) — detail of the focused search result
|
||||
* pane 1 (parent) — query input with recent-search history (clickable)
|
||||
* pane 2 (current) — search results list (navigate j/k)
|
||||
* pane 3 (preview) — detail of the focused search result
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
|
||||
* the results pane. Swipe left (h) to pane 0 to type a query — the Shell
|
||||
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
|
||||
* on tab enter so the user lands on the results pane. Swipe left (h) to pane
|
||||
* 1 to type a query — the Shell
|
||||
* router skips keys while `nav.inputFocused()` is true so the `<input>`
|
||||
* element captures typing natively. Press Enter (onSubmit) to search and
|
||||
* auto-swipe to the results pane.
|
||||
@@ -44,9 +45,9 @@ function SearchPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const INPUT = PaneSlot.PARENT; // 0
|
||||
const RESULTS = PaneSlot.CURRENT; // 1
|
||||
const DETAIL = PaneSlot.PREVIEW; // 2
|
||||
const INPUT = PaneSlot.PARENT; // 1 (input row)
|
||||
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
|
||||
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
|
||||
|
||||
const results = () => searchStore.results();
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useVisualizerItems } from "./VisualizerSettings";
|
||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||
import { useSourceItems } from "./SourceManager";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const SettingsPaneCount = 1;
|
||||
|
||||
@@ -268,6 +269,10 @@ export function SettingsPage() {
|
||||
// root whose inner <Show> children swap instead.
|
||||
const parentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
{/* app root: the tab list as the parent (muted) at the lowest depth */}
|
||||
<TabListPane muted />
|
||||
</Show>
|
||||
<Show when={depth() === 1}>
|
||||
{/* previous depth = sections list (read-only) */}
|
||||
<For each={SECTIONS}>
|
||||
|
||||
@@ -9,23 +9,29 @@
|
||||
*
|
||||
* 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):
|
||||
* `dispatch`. Behavioural rules (tab root + 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`:
|
||||
* • The tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is
|
||||
* the CURRENT pane with nothing above it:
|
||||
* `k`/`j` (`move-down`/`move-up`) move a cursor through the tabs
|
||||
* (highlight follows `tabCursor`; the active tab is untouched),
|
||||
* `l`/Enter (`swipe-next`/`open`) open the hovered tab (`activateTabCursor`)
|
||||
* — the tab slides into UP and its content becomes CURRENT; `h`
|
||||
* (`swipe-prev`) at the root stays (out of the panes); `1-6` / `[`/`]`
|
||||
* switch tabs directly (re-syncing the cursor).
|
||||
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
|
||||
* switch tabs; focus keeps its context (root iff already at the root,
|
||||
* otherwise the content `DEPTH_CENTER_PANE`).
|
||||
* • `h`/`l` are `swipe-prev`/`swipe-next` in content:
|
||||
* - 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]
|
||||
* when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
|
||||
* where the tab becomes CURRENT again.
|
||||
* - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to
|
||||
* [1, paneCount]; `h` on the first content pane stays (no tab overflow).
|
||||
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
|
||||
* `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only.
|
||||
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
|
||||
* • `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`.
|
||||
*/
|
||||
* are unchanged from the pre-rewrite Shell. */
|
||||
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
|
||||
@@ -48,29 +54,30 @@ export type NavActionEvent = {
|
||||
/** 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",
|
||||
]);
|
||||
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 {
|
||||
@@ -100,7 +107,10 @@ export type DispatcherDeps = {
|
||||
export function createDispatcher(deps: DispatcherDeps) {
|
||||
const { nav, audio, k, setShowHelp, advanceEpisode } = deps;
|
||||
|
||||
function dispatch(action: KeybindActionName, evt: { preventDefault: () => void }) {
|
||||
function dispatch(
|
||||
action: KeybindActionName,
|
||||
evt: { preventDefault: () => void },
|
||||
) {
|
||||
const tab = nav.activeTab();
|
||||
const pane = nav.activePane();
|
||||
switch (action) {
|
||||
@@ -147,22 +157,44 @@ export function createDispatcher(deps: DispatcherDeps) {
|
||||
nav.setActiveTab(dt);
|
||||
break;
|
||||
}
|
||||
// ── tab root focus ──
|
||||
// At the app root the tab list is the CURRENT pane: j/k move the tab
|
||||
// cursor (active tab untouched), l/Enter open the hovered tab (switch
|
||||
// to it + enter its content), h stays inert (out of the panes).
|
||||
if (nav.atRootTab()) {
|
||||
evt.preventDefault();
|
||||
if (action === "move-down") {
|
||||
nav.moveTabCursor(1);
|
||||
break;
|
||||
}
|
||||
if (action === "move-up") {
|
||||
nav.moveTabCursor(-1);
|
||||
break;
|
||||
}
|
||||
if (action === "open") {
|
||||
nav.activateTabCursor();
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
nav.activateTabCursor();
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-prev") 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).
|
||||
// Depth-tabs: l at the center drills in (emits `open`); h at the
|
||||
// center pops a depth; at depth 0 h returns to the tab root (the tab
|
||||
// becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
|
||||
// h/l swipe across [1, paneCount]; h on the first pane stays.
|
||||
if (action === "swipe-prev") {
|
||||
evt.preventDefault();
|
||||
if (
|
||||
nav.isDepthTab() &&
|
||||
nav.activePane() === DEPTH_CENTER_PANE
|
||||
) {
|
||||
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]);
|
||||
else nav.backToTabRoot(); // depth 0 → tab root
|
||||
} else if (nav.activePane() > DEPTH_CENTER_PANE) {
|
||||
nav.swipe(-1, TabPaneCount[tab]); // content 1..n
|
||||
}
|
||||
// fixed first content pane (1): no-op, stays (special tabs)
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
|
||||
@@ -6,13 +6,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
||||
- [ ] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
|
||||
- [ ] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
|
||||
- [ ] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
|
||||
- [ ] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
||||
- [ ] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
||||
- [ ] 07 — verify-remake → `07-verify-remake.md`
|
||||
- [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
||||
- [x] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
|
||||
- [x] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
|
||||
- [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
|
||||
- [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
||||
- [x] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
||||
- [x] 07 — verify-remake → `07-verify-remake.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
/**
|
||||
* dispatch-keybinds.test.ts — yazi remake task 06 unit + integration tests.
|
||||
* dispatch-keybinds.test.ts — yazi remake task 06/07 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:
|
||||
* cases, plus the tab-root routing:
|
||||
*
|
||||
* • 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.
|
||||
* • Tab root: j/k (`move-down`/`move-up`) move a tab cursor without touching
|
||||
* the active tab; `open`/`swipe-next` (l/Enter) open the hovered tab and
|
||||
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
|
||||
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
|
||||
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
|
||||
* • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
|
||||
* `h` on the first pane stays — never overflows to the tab root.
|
||||
* • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
|
||||
* tabs and preserve focus context (root stays root for depth-tabs, content
|
||||
* stays content).
|
||||
* • `j`/`k` (move-down/up) in content 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.
|
||||
* The dispatcher is built with fake audio/k/help deps (the paths under test
|
||||
* never reach the audio or advanceEpisode branches) and a real nav store.
|
||||
*/
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { createRoot } from "solid-js";
|
||||
import { createNavigation, DEPTH_CENTER_PANE } from "../src/context/navigation-store";
|
||||
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";
|
||||
@@ -30,16 +36,17 @@ 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) {
|
||||
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: {
|
||||
@@ -57,7 +64,7 @@ function withHarness(fn: (api: {
|
||||
fn({
|
||||
nav,
|
||||
dispatch: (action) => dispatch(action, evt() as any),
|
||||
toggleHelp,
|
||||
toggleHelp: () => (help = !help),
|
||||
helpOpen: () => help,
|
||||
});
|
||||
dispose();
|
||||
@@ -78,10 +85,66 @@ function captureNavActions(fn: () => void) {
|
||||
return captured;
|
||||
}
|
||||
|
||||
// ── Unit: move-down emits nav.action on the current pane only ─────────────────
|
||||
// ── Tab root: j/k move the cursor; l/Enter open the hovered tab ──────────────
|
||||
test("dispatch('move-down') on the tab root moves the cursor (no emit, active tab untouched)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
|
||||
const events = captureNavActions(() => dispatch("move-down"));
|
||||
expect(events).toHaveLength(0); // j on the root moves the cursor only
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED); // active tab untouched until opened
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('move-up') on the tab root moves the cursor up (clamped, no wrap)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
dispatch("move-up");
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED); // clamped at the top
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('open') on the tab root opens the hovered tab and enters its content", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
dispatch("move-down"); // cursor -> MYSHOWS
|
||||
dispatch("move-down"); // cursor -> DISCOVER
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
dispatch("open");
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER); // the hovered tab is opened
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-next') on the tab root opens the hovered tab and enters its content (no emit)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
dispatch("move-down"); // cursor -> MYSHOWS
|
||||
const events = captureNavActions(() => dispatch("swipe-next"));
|
||||
expect(events).toHaveLength(0);
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on the tab root is inert (stays, no emit)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||
expect(events).toHaveLength(0);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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
|
||||
nav.setActiveTab(TABS.FEED); // depth-tab → enter content to test the list move
|
||||
nav.enterTabContent();
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
@@ -96,21 +159,22 @@ test("dispatch('move-down') on a depth-tab current pane emits nav.action {action
|
||||
test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.MYSHOWS);
|
||||
nav.enterTabContent();
|
||||
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);
|
||||
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||
// 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 ──────────────────────
|
||||
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
|
||||
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.DISCOVER);
|
||||
nav.enterTabContent();
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
|
||||
@@ -126,6 +190,7 @@ test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", ()
|
||||
test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
nav.enterTabContent();
|
||||
// simulate the page's open() having drilled one level.
|
||||
nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 });
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
@@ -133,54 +198,105 @@ test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
|
||||
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||
expect(events).toHaveLength(0); // a pop emits nothing — it just pops
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
// focus stays in content (depth > 0 pop does not return to the root).
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') at depth 0 is an inert noop (no emit, no pane/depth change, no error)", () => {
|
||||
test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
nav.enterTabContent();
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
const paneBefore = nav.activePane();
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||
expect(events).toHaveLength(0);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.activePane()).toBe(paneBefore);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
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", () => {
|
||||
test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
// start on FEED (depth-tab, depth 0).
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.SEARCH); // fixed-pane
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
dispatch("swipe-prev");
|
||||
// special tab: h on the first content pane does not return to the root.
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // 3 panes
|
||||
nav.enterTabContent();
|
||||
nav.swipe(1, 3);
|
||||
nav.swipe(1, 3);
|
||||
expect(nav.activePane()).toBe(3);
|
||||
dispatch("swipe-prev");
|
||||
expect(nav.activePane()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
|
||||
test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
// focus starts on the tab root.
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-goto-3"); // → Discover
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER); // cursor re-synced
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-goto-2"); // → MyShows
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
// fixed-pane tab is special: switching from the root opens its content.
|
||||
dispatch("tab-goto-4"); // → Search
|
||||
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||
expect(nav.tabCursor()).toBe(TABS.SEARCH);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("tab-goto-N from content keeps focus in the active tab's content", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.enterTabContent();
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
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
|
||||
dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
|
||||
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("tab-next (]) / tab-prev ([) cycle tabs and reset focus to the current pane", () => {
|
||||
test("tab-next (]) / tab-prev ([) cycle tabs and keep focus context", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-next");
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-prev");
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* nav-model.test.ts — yazi remake task 01 unit/integration tests.
|
||||
* nav-model.test.ts — yazi remake task 01/07 unit/integration tests.
|
||||
*
|
||||
* Covers the removal of the SIDEBAR_PANE concept:
|
||||
* Covers the tab-list-as-root navigation model:
|
||||
* • createNavigation() exposes the nav factory directly (no Solid render
|
||||
* needed), wrapped in a createRoot so effects register/dispose.
|
||||
* • focus starts on the tab list — the app root. `atRootTab()` is true while
|
||||
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
|
||||
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
|
||||
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
|
||||
* for the fixed-pane Search/Player tabs).
|
||||
* • the root tab list is a normal list: `tabCursor` is independent of
|
||||
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
|
||||
* the hovered tab + enters content, and direct tab switches re-sync it.
|
||||
* • 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).
|
||||
* • swipe() is clamped to [1, paneCount] (content panes only; there is no
|
||||
* pane-0 tab slot — the tab root is a flag, not a pane).
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { createRoot } from "solid-js";
|
||||
@@ -27,17 +35,18 @@ function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
|
||||
});
|
||||
}
|
||||
|
||||
test("createNavigation initial activePane is DEPTH_CENTER_PANE (0), not -1", () => {
|
||||
test("createNavigation starts on the tab root (atRootTab true)", () => {
|
||||
withNav((nav) => {
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
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 }.
|
||||
// FeeD is a depth-tab; its root frame is a the top frame on the stack.
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0);
|
||||
@@ -52,6 +61,7 @@ test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () =
|
||||
test("setFocusedIndex on a 2-frame stack writes only the top frame", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
nav.enterTabContent();
|
||||
// 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 });
|
||||
@@ -79,19 +89,120 @@ test("popDepth at depth 0 is a noop (returns false, no frame lost)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── tab-switch resets focus to DEPTH_CENTER_PANE, not a sidebar ──────────────
|
||||
test("tab-switch effect resets activePane to DEPTH_CENTER_PANE", () => {
|
||||
// ── tab switching keeps focus context ────────────────────────────────────────
|
||||
test("tab switch keeps focus context: at the root it stays at the root", () => {
|
||||
withNav((nav) => {
|
||||
// start on a depth-tab, land on the current pane.
|
||||
// focus starts on the tab root.
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
// switching depth-tabs from the root must not drop focus into content.
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("tab switch keeps focus context: in content it stays in content", () => {
|
||||
withNav((nav) => {
|
||||
nav.enterTabContent();
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
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.
|
||||
// switching tabs from content keeps the content context.
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.activePane()).toBeGreaterThanOrEqual(0);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("switching to a Search/Player tab leaves the root (special content)", () => {
|
||||
withNav((nav) => {
|
||||
// at root, opening Search is special: atRootTab() reports false because
|
||||
// Search has its own content and never renders the tab-list root view.
|
||||
nav.setActiveTab(TABS.SEARCH);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── tab root <-> content transitions ─────────────────────────────────────────
|
||||
test("enterTabContent/backToTabRoot round-trip between root and content", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
nav.backToTabRoot();
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("enterTabContent preserves the active tab's depth", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 0 });
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
// moving between the root and content never touches the depth stack.
|
||||
nav.backToTabRoot();
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
expect(nav.popDepth()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("tabCursor starts on the active tab", () => {
|
||||
withNav((nav) => {
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
});
|
||||
});
|
||||
|
||||
test("moveTabCursor moves the cursor without changing the active tab, clamped at the ends", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.MYSHOWS); // cursor syncs to the active tab
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
nav.moveTabCursor(1);
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS); // active tab untouched
|
||||
nav.moveTabCursor(-1);
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
// clamp: from FEED, up stays FEED; from SETTINGS, down stays SETTINGS.
|
||||
nav.moveTabCursor(-1); // MYSHOWS -> FEED
|
||||
nav.moveTabCursor(-1); // FEED -> FEED (clamped)
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
nav.moveTabCursor(1); // SETTINGS -> SETTINGS (clamped)
|
||||
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
|
||||
expect(nav.activeTab()).toBe(TABS.SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
test("activateTabCursor switches to the hovered tab and enters its content", () => {
|
||||
withNav((nav) => {
|
||||
nav.moveTabCursor(1); // cursor -> MYSHOWS
|
||||
nav.moveTabCursor(1); // cursor -> DISCOVER
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.activateTabCursor();
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER); // hovered tab opened
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("direct tab switches re-sync the tab cursor", () => {
|
||||
withNav((nav) => {
|
||||
nav.moveTabCursor(1); // cursor -> MYSHOWS
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,37 +216,44 @@ test("tab-switch resets mode/visual/command state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── swipe clamps to [0, paneCount-1] (no sidebar slot) ────────────────────────
|
||||
test("swipe(-1, 3) on a fixed-pane tab clamps to 0, not -1", () => {
|
||||
// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
|
||||
test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
|
||||
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);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(1);
|
||||
// swipe left stays at 1 (no pane 0).
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
// swipe right up through the columns, then hold the upper bound.
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(2);
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(3);
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(3); // never exceeds paneCount
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(2);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
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", () => {
|
||||
test("swipe on a single-pane fixed tab stays at its one content pane", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.PLAYER); // single-pane
|
||||
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
|
||||
expect(nav.activePane()).toBe(0);
|
||||
nav.enterTabContent(); // lands on its one content pane (1)
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
|
||||
expect(nav.activePane()).toBe(0);
|
||||
expect(nav.activePane()).toBe(1); // upper bound
|
||||
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
|
||||
expect(nav.activePane()).toBe(0);
|
||||
expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user