start revive

This commit is contained in:
2026-07-30 21:25:03 -04:00
parent b7c4938c54
commit d8f11040bc
25 changed files with 5344 additions and 2559 deletions

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
.harness/

BIN
bun.lockb Executable file

Binary file not shown.

573
scripts/tui-harness.tsx Normal file
View File

@@ -0,0 +1,573 @@
#!/usr/bin/env bun
/**
* PodTUI LLM-interactive harness — stateless per-turn snapshot bridge.
*
* Each invocation:
* 1. Points XDG_CONFIG_HOME / XDG_DATA_HOME / PODTUI_AUDIO_BACKEND at a
* sandbox dir under .harness so your real ~/.config/podtui is never touched.
* 2. Replays the saved action log (.harness/actions.json) from scratch.
* 3. Appends + executes the new action passed on the CLI.
* 4. Renders, captures structured spans, and prints: plain frame + distinct
* style summary (colors/attrs) + selected store state + captured issues
* (stderr / uncaught rejections). Full structured spans are dumped to
* .harness/last-frame.json every turn.
*
* Audio is silent (Noop) by default during the snapshot model so replaying the
* log each turn doesn't re-trigger real playback. Pass --audio (or set
* PODTUI_AUDIO_BACKEND) to flip to a real backend for the new action only.
*
* Import order mirrors src/index.tsx (lazy) to avoid a NavigationContext cycle.
*
* Usage:
* bun scripts/tui-harness.tsx init [--size 100x30] [--seed]
* bun scripts/tui-harness.tsx key <key> [mods...] # mods: ctrl shift meta
* bun scripts/tui-harness.tsx arrow <up|down|left|right> [mods...]
* bun scripts/tui-harness.tsx enter|escape|tab|space|backspace
* bun scripts/tui-harness.tsx type "<text>"
* bun scripts/tui-harness.tsx wait <ms>
* bun scripts/tui-harness.tsx resize <w> <h>
* bun scripts/tui-harness.tsx frame # re-render, no new action
* bun scripts/tui-harness.tsx state [all|nav|audio|feed|app]
* bun scripts/tui-harness.tsx actions # print action log
* bun scripts/tui-harness.tsx reset
* bun scripts/tui-harness.tsx seed [--from ~/.config/podtui]
*
* Flags (after the subcommand):
* --size WxH terminal size (default 100x30)
* --audio enable real audio backend for the new action only
* --no-settle skip the extra render-settle loops
*/
import { testRender } from "@opentui/solid";
import {
existsSync,
mkdirSync,
cpSync,
writeFileSync,
readFileSync,
} from "node:fs";
import { join } from "node:path";
// ── Paths & sandbox ────────────────────────────────────────────────────────
const HANDLES_DIR = ".harness";
const CONFIG_HOME = join(HANDLES_DIR, "config-home");
const DATA_HOME = join(HANDLES_DIR, "data-home");
const ACTIONS_FILE = join(HANDLES_DIR, "actions.json");
const FRAME_JSON = join(HANDLES_DIR, "last-frame.json");
const FRAME_TXT = join(HANDLES_DIR, "last-frame.txt");
const STATE_JSON = join(HANDLES_DIR, "state.json");
// Sandbox must be active BEFORE any app module is imported, so the app's
// config-dir / persistence reads resolve into .harness/*.
function activateSandbox(): void {
mkdirSync(CONFIG_HOME, { recursive: true });
mkdirSync(DATA_HOME, { recursive: true });
process.env.XDG_CONFIG_HOME = join(process.cwd(), CONFIG_HOME);
process.env.XDG_DATA_HOME = join(process.cwd(), DATA_HOME);
// Silent audio during replay by default; --audio flips this after import.
if (!process.env.PODTUI_AUDIO_BACKEND)
process.env.PODTUI_AUDIO_BACKEND = "none";
}
// ── Action log ─────────────────────────────────────────────────────────────
type Mod = "ctrl" | "shift" | "meta" | "super" | "hyper";
type Action =
| { t: "key"; k: string; mods?: Mod[] }
| { t: "arrow"; d: "up" | "down" | "left" | "right"; mods?: Mod[] }
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
| { t: "type"; s: string }
| { t: "wait"; ms: number }
| { t: "resize"; w: number; h: number };
function loadActions(): Action[] {
try {
return JSON.parse(readFileSync(ACTIONS_FILE, "utf8") || "[]");
} catch {
return [];
}
}
function saveActions(a: Action[]): void {
writeFileSync(ACTIONS_FILE, JSON.stringify(a, null, 2));
}
// ── Issue capture ──────────────────────────────────────────────────────────
const issues: string[] = [];
// Captured by the StateProbe component rendered inside the provider tree —
// Solid contexts can only be read from within the tree, not from outside.
let navRef: any = null;
function captureIssues(): void {
const origErr = console.error;
const origWarn = console.warn;
console.error = (...args: unknown[]) => {
issues.push("stderr: " + args.map(String).join(" "));
origErr(...(args as any[]));
};
console.warn = (...args: unknown[]) => {
issues.push("warn: " + args.map(String).join(" "));
origWarn(...(args as any[]));
};
process.on("uncaughtException", (e) =>
issues.push("uncaught: " + ((e as Error)?.stack || String(e))),
);
process.on("unhandledRejection", (e) =>
issues.push("unhandledRejection: " + ((e as Error)?.stack || String(e))),
);
}
// ── Span rendering ─────────────────────────────────────────────────────────
type RGBA = { r: number; g: number; b: number; a: number };
type Span = {
text: string;
fg: RGBA | null;
bg: RGBA | null;
attributes: number;
width: number;
};
const ATTR_NAMES: Record<string, number> = {
BOLD: 1,
DIM: 2,
ITALIC: 4,
UNDERLINE: 8,
BLINK: 16,
INVERSE: 32,
HIDDEN: 64,
STRIKETHROUGH: 128,
};
function hex(c: RGBA | null): string | null {
if (!c) return null;
if (c.a === 0) return null; // transparent → "default"
const [r, g, b] = [c.r, c.g, c.b].map((v) =>
Math.max(0, Math.min(255, Math.round(v))),
);
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
}
function attrLabels(attr: number): string[] {
const out: string[] = [];
for (const [name, bit] of Object.entries(ATTR_NAMES))
if (attr & bit) out.push(name.toLowerCase());
return out;
}
// (Plain frame text comes from captureCharFrame instead of span reconstruction.)
function distinctStyles(spans: {
lines: { spans: Span[] }[];
}): { tag: string; sample: string; n: number }[] {
const map = new Map<string, { tag: string; sample: string; n: number }>();
for (const line of spans.lines) {
for (const s of line.spans) {
if (!s.text || s.text.trim() === "") continue;
const fg = hex(s.fg as any);
const bg = hex(s.bg as any);
if (!fg && !bg && s.attributes === 0) continue; // default — skip
const tags = attrLabels(s.attributes);
const tag = `[fg=${fg ?? "·"} bg=${bg ?? "·"}${tags.length ? " " + tags.join("+") : ""}]`;
const ex = map.get(tag);
const sample = s.text.replace(/\n/g, "\\n").slice(0, 28);
if (ex) {
ex.n++;
if (ex.sample.length < 14 && sample.length > ex.sample.length)
ex.sample = sample;
} else {
map.set(tag, { tag, sample, n: 1 });
}
}
}
return [...map.values()].sort((a, b) => b.n - a.n).slice(0, 20);
}
// ── Arg parsing ────────────────────────────────────────────────────────────
function parseFlags(rest: string[]): {
flags: Record<string, string | boolean>;
positional: string[];
} {
const flags: Record<string, string | boolean> = {};
const positional: string[] = [];
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith("--")) {
if (a === "--audio") flags.audio = true;
else if (a === "--no-settle") flags["no-settle"] = true;
else if (a === "--size") {
flags.size = rest[++i];
const m = /(\d+)x(\d+)/.exec(String(flags.size));
if (m) {
flags.w = m[1];
flags.h = m[2];
}
} else if (a === "--from") {
flags.from = rest[++i];
} else {
flags[a.slice(2)] = rest[++i] ?? true;
}
} else {
positional.push(a);
}
}
return { flags, positional };
}
function parseMods(positional: string[]): Mod[] {
const mods: Mod[] = [];
for (const p of positional)
if (["ctrl", "shift", "meta", "super", "hyper"].includes(p))
mods.push(p as Mod);
return mods;
}
function buildAction(cmd: string, positional: string[]): Action | null {
const mods = parseMods(positional);
const first = positional[0];
switch (cmd) {
case "key":
if (!first) throw new Error("key requires a <key> argument");
return { t: "key", k: first, mods: mods.length ? mods : undefined };
case "arrow":
if (!first || !["up", "down", "left", "right"].includes(first))
throw new Error("arrow requires up|down|left|right");
return {
t: "arrow",
d: first as any,
mods: mods.length ? mods : undefined,
};
case "enter":
case "escape":
case "tab":
case "space":
case "backspace":
return { t: cmd, mods: mods.length ? mods : undefined };
case "type":
if (first === undefined) throw new Error("type requires <text>");
// Re-join the rest in case text had spaces; positional[0] already is first token,
// caller should quote. We join all positional as the text.
return { t: "type", s: positional.join(" ") };
case "wait":
if (!first) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(first, 10) || 0 };
case "resize":
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
return {
t: "resize",
w: parseInt(first, 10) || 100,
h: parseInt(positional[1], 10) || 30,
};
case "frame":
case "state":
case "reset":
case "actions":
case "init":
case "seed":
return null;
default:
throw new Error(`unknown command: ${cmd}`);
}
}
// ── Execute one action against a mounted setup ──────────────────────────────
function fmtMods(mods?: Mod[]): Record<string, boolean> | undefined {
if (!mods || !mods.length) return undefined;
const o: Record<string, boolean> = {};
for (const m of mods) o[m] = true;
return o;
}
async function execAction(setup: any, a: Action): Promise<void> {
const mi = setup.mockInput;
switch (a.t) {
case "key":
mi.pressKey(a.k, fmtMods(a.mods));
break;
case "arrow":
mi.pressArrow(a.d, fmtMods(a.mods));
break;
case "enter":
mi.pressEnter(fmtMods(a.mods));
break;
case "escape":
mi.pressEscape(fmtMods(a.mods));
break;
case "tab":
mi.pressTab(fmtMods(a.mods));
break;
case "space":
mi.pressKey("space");
break;
case "backspace":
mi.pressBackspace(fmtMods(a.mods));
break;
case "type":
await mi.typeText(a.s, 0);
break;
case "wait":
await new Promise((r) => setTimeout(r, a.ms));
break;
case "resize":
setup.resize(a.w, a.h);
break;
}
await setup.renderOnce();
// tiny settle for reactive updates
await new Promise((r) => setTimeout(r, 40));
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
activateSandbox();
captureIssues();
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "frame";
const { flags, positional } = parseFlags(argv.slice(1));
// Local-only commands that don't mount.
if (cmd === "reset") {
saveActions([]);
console.log("✔ actions log cleared.");
return;
}
if (cmd === "actions") {
const a = loadActions();
console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2));
return;
}
if (cmd === "seed") {
const from = String(
flags.from || join(process.env.HOME || "~", ".config", "podtui"),
);
if (!existsSync(from)) {
console.error(`seed source not found: ${from}`);
process.exit(1);
}
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from}${dest}`);
return;
}
// Size settings.
let width = 100;
let height = 30;
if (flags.w) width = parseInt(String(flags.w), 10);
if (flags.h) height = parseInt(String(flags.h), 10);
let newAction: Action | null = null;
let actions: Action[] = [];
if (cmd !== "init" && cmd !== "frame" && cmd !== "state") {
newAction = buildAction(cmd, positional);
}
if (cmd === "init") {
saveActions([]);
actions = [];
} else {
actions = loadActions();
}
// Mount the real app. Lazy imports (order matters — see NavigationContext cycle).
const { App } = await import("../src/App");
const { ThemeProvider } = await import("../src/context/ThemeContext");
const toast = await import("../src/ui/toast");
const { KeybindProvider } = await import("../src/context/KeybindContext");
const { NavigationProvider, useNavigation } = await import(
"../src/context/NavigationContext"
);
const { DialogProvider } = await import("../src/ui/dialog");
const { CommandProvider } = await import("../src/ui/command");
// Probe rendered inside the provider tree so context hooks resolve.
const StateProbe = () => {
try {
navRef = useNavigation();
} catch (e) {
issues.push("StateProbe: " + String(e));
}
return null;
};
const HarnessRoot = () => (
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<StateProbe />
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
);
const setup = await testRender(() => <HarnessRoot />, {
width,
height,
useThread: false,
});
(setup.renderer as any).disableStdoutInterception?.();
// Wait for providers (keybinds/theme/feeds) to settle.
const settleLoops = flags["no-settle"] ? 2 : 12;
for (let i = 0; i < settleLoops; i++) {
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
}
// Replay history silently (audio already Noop via env).
for (const a of actions) await execAction(setup, a);
// For the *new* action: if --audio, flip to a real backend just for it.
let audioControls: any = null;
try {
const { useAudio } = await import("../src/hooks/useAudio");
audioControls = useAudio();
} catch (e) {
issues.push("useAudio import: " + String(e));
}
if (newAction) {
if (flags.audio && audioControls?.switchBackend) {
// Re-detect: clear env so detection picks the best real backend.
delete process.env.PODTUI_AUDIO_BACKEND;
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
await audioControls.switchBackend("mpv").catch(() => {});
if (
!audioControls.backendName() ||
audioControls.backendName() === "none"
) {
await audioControls.switchBackend("afplay").catch(() => {});
}
}
actions.push(newAction);
saveActions(actions);
await execAction(setup, newAction);
}
// Final settle + capture.
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame.
try {
writeFileSync(FRAME_JSON, JSON.stringify(spans));
writeFileSync(FRAME_TXT, plainFrame);
} catch {}
// Store state snapshot.
const state: Record<string, unknown> = {};
try {
const nav = navRef;
if (nav) {
state.nav = {
activeTab: nav.activeTab?.(),
activeDepth: nav.activeDepth?.(),
inputFocused: nav.inputFocused?.(),
ready: nav.ready,
};
} else {
state.nav = "NAV_REF not captured (probe did not run)";
}
} catch (e) {
state.nav = "ERR: " + String(e);
}
try {
if (audioControls) {
state.audio = {
backend: audioControls.backendName ? audioControls.backendName() : null,
playing: audioControls.isPlaying ? audioControls.isPlaying() : null,
position: audioControls.position ? audioControls.position() : null,
duration: audioControls.duration ? audioControls.duration() : null,
volume: audioControls.volume ? audioControls.volume() : null,
error: audioControls.error ? audioControls.error() : null,
currentEpisodeTitle: audioControls.currentEpisode
? audioControls.currentEpisode()?.title
: null,
};
}
} catch (e) {
state.audio = "ERR: " + String(e);
}
try {
const { useFeedStore } = await import("../src/stores/feed");
const fs_ = useFeedStore();
const feeds = fs_.feeds ? fs_.feeds() : [];
state.feed = {
count: feeds?.length ?? 0,
selectedFeedId: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
isLoadingFeeds: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
};
} catch (e) {
state.feed = "ERR: " + String(e);
}
try {
writeFileSync(STATE_JSON, JSON.stringify(state, null, 2));
} catch {}
// ── Output ───────────────────────────────────────────────────────────────
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
console.log(
`\n=== FRAME ${spans.cols}x${spans.rows} (cursor ${spans.cursor[0]},${spans.cursor[1]}) | actions=${actions.length} | ${cmd} ===`,
);
console.log(plainFrame);
if (scope === "all") {
const styles = distinctStyles(spans);
if (styles.length) {
console.log("--- distinct styles (sample) ---");
for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`);
}
}
// State subset.
const want = (k: string) => scope === "all" || scope === k;
if (want("nav"))
console.log("--- nav ---\n" + JSON.stringify(state.nav, null, 2));
if (want("audio"))
console.log("--- audio ---\n" + JSON.stringify(state.audio, null, 2));
if (want("feed"))
console.log("--- feed ---\n" + JSON.stringify(state.feed, null, 2));
if (want("app"))
console.log("--- app ---\n(src/stores/app.ts not dumped in v1)");
if (issues.length) {
console.log("--- issues (" + issues.length + ") ---");
for (const i of issues.slice(0, 40)) console.log(" ! " + i);
} else {
console.log("--- issues: none ---");
}
console.log(
`\n(spans→ ${FRAME_JSON} | frame→ ${FRAME_TXT} | state→ ${STATE_JSON})`,
);
// Tear down child processes (audio backend) before exit to avoid orphans.
try {
if (audioControls?.stop) await audioControls.stop().catch(() => {});
} catch (e) {
issues.push("teardown audio: " + String(e));
}
try {
setup.renderer.destroy();
} catch (e) {
issues.push("teardown renderer: " + String(e));
}
process.exit(0);
}
main().catch((err) => {
console.error("HARNESS FAILED:", err?.stack || err);
process.exit(1);
});

View File

@@ -1,194 +1,95 @@
import { createMemo, ErrorBoundary, Accessor } from "solid-js"; import { ErrorBoundary } from "solid-js";
import { useKeyboard, useSelectionHandler } from "@opentui/solid"; import { useSelectionHandler, useRenderer } from "@opentui/solid";
import { TabNavigation } from "./components/TabNavigation";
import { CodeValidation } from "@/components/CodeValidation";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "@/stores/feed";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys"; import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "@/types/feed";
import { Clipboard } from "@/utils/clipboard"; import { Clipboard } from "@/utils/clipboard";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { useRenderer } from "@opentui/solid";
import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode";
import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation";
import { useTheme, ThemeProvider } from "./context/ThemeContext"; import { useTheme, ThemeProvider } from "./context/ThemeContext";
import { KeybindProvider, useKeybinds } from "./context/KeybindContext"; import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
import { NavigationProvider, useNavigation } from "./context/NavigationContext"; import {
import { useAudioNavStore, AudioSource } from "./stores/audio-nav"; NavigationProvider,
useNavigation,
NavMode,
} from "./context/NavigationContext";
import { TABS } from "./utils/navigation";
import { Shell } from "./components/Shell";
const DEBUG = import.meta.env.DEBUG; const DEBUG = import.meta.env.DEBUG;
export function App() { export function App() {
const nav = useNavigation(); const nav = useNavigation();
const auth = useAuthStore(); const auth = useAuthStore();
const feedStore = useFeedStore(); const audio = useAudio();
const audio = useAudio(); const toast = useToast();
const toast = useToast(); const renderer = useRenderer();
const renderer = useRenderer(); const themeContext = useTheme();
const themeContext = useTheme(); const theme = themeContext.theme;
const theme = themeContext.theme; const keybind = useKeybinds();
// Create a reactive expression for background color // Multimedia keys (physical play/seek keys) still feed the audio backend
const backgroundColor = () => { // regardless of the on-screen yazi keybinds.
return themeContext.selected === "system" useMultimediaKeys({
? "transparent" playerFocused: () =>
: themeContext.theme.surface; nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
}; ? true
const keybind = useKeybinds(); : false,
const audioNav = useAudioNavStore(); inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
});
useMultimediaKeys({ // Mouse text-selection → clipboard (unchanged from the old shell).
playerFocused: () => useSelectionHandler((selection: any) => {
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0, if (!selection) return;
inputFocused: () => nav.inputFocused(), const text = selection.getSelectedText?.();
hasEpisode: () => !!audio.currentEpisode(), if (!text || text.trim().length === 0) return;
}); Clipboard.copy(text)
.then(() =>
toast.show({ message: "Copied to Clipboard!", variant: "info" }),
)
.catch(toast.error)
.finally(() => renderer.clearSelection());
});
const handlePlayEpisode = (episode: Episode) => { const backgroundColor = () =>
audio.play(episode); themeContext.selected === "system"
nav.setActiveTab(TABS.PLAYER); ? "transparent"
nav.setActiveDepth(1); : themeContext.theme.surface;
audioNav.setSource(AudioSource.FEED);
};
useSelectionHandler((selection: any) => { return (
if (!selection) return; <ErrorBoundary
const text = selection.getSelectedText?.(); fallback={(err) => (
if (!text || text.trim().length === 0) return; <box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Clipboard.copy(text) Error: {err?.message ?? String(err)}
.then(() => { {"\n"}
toast.show({ message: "Copied to Clipboard!", variant: "info" }); Press 1-6 to switch tabs, or : to open the command bar.
}) </text>
.catch(toast.error) </box>
.finally(() => { )}
renderer.clearSelection(); >
}); <box
}); flexDirection="column"
width="100%"
useKeyboard( height="100%"
(keyEvent) => { backgroundColor={backgroundColor()}
const isCycle = keybind.match("cycle", keyEvent); >
const isUp = keybind.match("up", keyEvent); {DEBUG && (
const isDown = keybind.match("down", keyEvent); <box flexDirection="row" width="100%" height={1}>
const isLeft = keybind.match("left", keyEvent); <text fg={theme.primary}></text>
const isRight = keybind.match("right", keyEvent); <text fg={theme.secondary}></text>
const isDive = keybind.match("dive", keyEvent); <text fg={theme.accent}></text>
const isOut = keybind.match("out", keyEvent); <text fg={theme.error}></text>
const isToggle = keybind.match("audio-toggle", keyEvent); <text fg={theme.warning}></text>
const isNext = keybind.match("audio-next", keyEvent); <text fg={theme.success}></text>
const isPrev = keybind.match("audio-prev", keyEvent); <text fg={theme.info}></text>
const isSeekForward = keybind.match("audio-seek-forward", keyEvent); <text fg={theme.text}></text>
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent); <text fg={theme.textMuted}></text>
const isQuit = keybind.match("quit", keyEvent); <text fg={theme.surface}></text>
const isInverting = keybind.isInverting(keyEvent); </box>
)}
// unified navigation: left->right, top->bottom across all tabs <Shell />
if (nav.activeDepth() == 0) { </box>
// at top level: cycle through tabs </ErrorBoundary>
if ( );
(isCycle && !isInverting) ||
(isDown && !isInverting) ||
(isUp && isInverting)
) {
nav.nextTab();
return;
}
if (
(isCycle && isInverting) ||
(isDown && isInverting) ||
(isUp && !isInverting)
) {
nav.prevTab();
return;
}
// dive out to first pane
if (
(isDive && !isInverting) ||
(isOut && isInverting) ||
(isRight && !isInverting) ||
(isLeft && isInverting)
) {
nav.setActiveDepth(1);
}
} else {
// in panes: navigate between them
if (
(isDive && isInverting) ||
(isOut && !isInverting) ||
(isRight && isInverting) ||
(isLeft && !isInverting)
) {
nav.setActiveDepth(0);
} else if (isDown && !isInverting) {
nav.nextPane();
} else if (isUp && isInverting) {
nav.prevPane();
}
}
},
{ release: false },
);
return (
<ErrorBoundary
fallback={(err) => (
<box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Error: {err?.message ?? String(err)}
{"\n"}
Press a number key (1-6) to switch tabs.
</text>
</box>
)}
>
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface
}
>
<LoadingIndicator />
{DEBUG && (
<box flexDirection="row" width="100%" height={1}>
<text fg={theme.primary}></text>
<text fg={theme.secondary}></text>
<text fg={theme.accent}></text>
<text fg={theme.error}></text>
<text fg={theme.warning}></text>
<text fg={theme.success}></text>
<text fg={theme.info}></text>
<text fg={theme.text}></text>
<text fg={theme.textMuted}></text>
<text fg={theme.surface}></text>
<text fg={theme.background}></text>
<text fg={theme.border}></text>
<text fg={theme.borderActive}></text>
<text fg={theme.diffAdded}></text>
<text fg={theme.diffRemoved}></text>
<text fg={theme.diffContext}></text>
<text fg={theme.markdownText}></text>
<text fg={theme.markdownHeading}></text>
<text fg={theme.markdownLink}></text>
<text fg={theme.markdownCode}></text>
<text fg={theme.syntaxKeyword}></text>
<text fg={theme.syntaxString}></text>
<text fg={theme.syntaxNumber}></text>
<text fg={theme.syntaxFunction}></text>
</box>
)}
<box flexDirection="row" width="100%" height="100%">
<TabNavigation />
{LayerGraph[nav.activeTab()]()}
</box>
</box>
</ErrorBoundary>
);
} }

586
src/components/Shell.tsx Normal file
View File

@@ -0,0 +1,586 @@
/**
* Shell — yazi-style application chrome.
*
* Replaces the old left sidebar (vertical TabNavigation) with a horizontal
* top tab bar, renders the active page (which owns its own panes), and adds a
* bottom status/command bar. 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; pane/list ones are
* dispatched to the active page over the `nav.action` event bus.
*/
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 { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed";
import type { Episode } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus";
import { TABS, TabsCount, TabPaneCount, LayerGraph } 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",
};
/** 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",
],
);
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;
const nav = useNavigation();
const k = useKeybinds();
const audio = useAudio();
const audioNav = useAudioNavStore();
const toast = useToast();
const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false);
/** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) {
const cur = audio.currentEpisode();
if (!cur) {
toast.show({ message: "Nothing playing", variant: "warning" });
return;
}
const pid = audioNav.getPodcastId();
const feeds = feedStore.getFilteredFeeds();
const feed =
feeds.find((f) => f.podcast.id === pid) ??
feeds.find((f) => f.episodes.some((e) => e.id === cur.id));
if (!feed) {
toast.show({ message: "Show not found", variant: "warning" });
return;
}
const eps = [...feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
const idx = eps.findIndex((e) => e.id === cur.id);
const next = eps[idx + offset];
if (!next) {
toast.show({
message: offset > 0 ? "No next episode" : "No previous episode",
variant: "warning",
});
return;
}
audio.play(next).catch(() => {});
audioNav.next(eps.length - 1 - (idx + offset) >= 0 ? idx + offset : idx);
toast.show({ message: `${next.title}`.slice(0, 60), variant: "info" });
}
// ── Command bar dispatch ────────────────────────────────────────────────────
function runCommand(raw: string) {
const cmd = raw.trim();
if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim();
switch (name) {
case "q":
case "quit":
case "exit":
process.exit(0);
case "refresh":
case "r":
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
});
break;
case "play":
case "pause":
case "p":
audio.togglePlayback().catch(() => {});
break;
case "next":
case "n":
advanceEpisode(1);
break;
case "prev":
advanceEpisode(-1);
break;
case "seek": {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
break;
}
case "feed":
case "f":
nav.setActiveTab(TABS.FEED);
break;
case "shows":
case "myshows":
nav.setActiveTab(TABS.MYSHOWS);
break;
case "discover":
case "d":
nav.setActiveTab(TABS.DISCOVER);
break;
case "search":
nav.setActiveTab(TABS.SEARCH);
break;
case "player":
nav.setActiveTab(TABS.PLAYER);
break;
case "settings":
case "set":
nav.setActiveTab(TABS.SETTINGS);
break;
case "help":
case "h":
setShowHelp((v) => !v);
break;
default:
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
}
}
// ── Command-mode key handling ───────────────────────────────────────────────
function handleCommandKey(evt: any) {
if (k.match("escape", evt) || evt.name === "ctrl-c") {
evt.preventDefault();
nav.exitCommand();
return;
}
if (evt.name === "return" || evt.name === "enter") {
evt.preventDefault();
const cmd = nav.commandBuffer();
runCommand(cmd);
nav.exitCommand();
return;
}
if (evt.name === "backspace") {
evt.preventDefault();
const buf = nav.commandBuffer();
if (buf.length === 0) {
nav.exitCommand();
return;
}
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);
return;
}
}
// ── 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 ──
if (action === "swipe-prev") {
evt.preventDefault();
nav.swipe(-1, TabPaneCount[tab]);
break;
}
if (action === "swipe-next") {
evt.preventDefault();
nav.swipe(1, TabPaneCount[tab]);
break;
}
// ── audio transport (global) ──
if (action === "audio-toggle") {
evt.preventDefault();
audio.togglePlayback().catch(() => {});
break;
}
if (action === "audio-seek-forward") {
evt.preventDefault();
audio.seekRelative(10).catch(() => {});
break;
}
if (action === "audio-seek-backward") {
evt.preventDefault();
audio.seekRelative(-10).catch(() => {});
break;
}
if (action === "audio-next") {
evt.preventDefault();
advanceEpisode(1);
break;
}
if (action === "audio-prev") {
evt.preventDefault();
advanceEpisode(-1);
break;
}
// ── global app ──
if (action === "quit") {
evt.preventDefault();
process.exit(0);
}
if (action === "help") {
evt.preventDefault();
setShowHelp((v) => !v);
}
// ── page-local list/pane actions ──
if (PAGE_ACTIONS.has(action)) {
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
}
}
}
}
useKeyboard(
(evt: any) => {
// Input fields (search boxes, dialogs) own their keys.
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return;
if (nav.mode() === NavMode.COMMAND) {
handleCommandKey(evt);
return;
}
const action = k.tryMatch(evt);
if (action) dispatch(action, evt);
},
{ release: false },
);
// ── Status bar fragments ──────────────────────────────────────────────────
const nowPlaying = () => {
const ep = audio.currentEpisode();
if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
return `${title}`;
};
const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
const pendingLabel = () =>
k
.pending()
.map((s) => s.key)
.join(" ");
return (
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={t.surface}
>
{/* ── Top tab bar ─────────────────────────────────────────────────────── */}
<box
flexDirection="row"
height={1}
width="100%"
backgroundColor={t.background}
>
<For
each={Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
)}
>
{(tab) => {
const active = () => nav.activeTab() === tab;
return (
<box
backgroundColor={active() ? t.primary : t.background}
paddingRight={1}
paddingLeft={1}
onMouseDown={() => nav.setActiveTab(tab)}
>
<text fg={active() ? t.surface : t.textMuted}>
{tab}. {TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
<box flexGrow={1} backgroundColor={t.background} />
<text fg={t.textMuted} paddingRight={1}>
{nowPlaying() ?? ""}
</text>
</box>
{/* ── Active page (owns its panes) ────────────────────────────────────── */}
<box flexDirection="column" flexGrow={1} width="100%">
{LayerGraph[nav.activeTab()]()}
</box>
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
<box
flexDirection="row"
height={1}
width="100%"
backgroundColor={t.backgroundPanel ?? t.background}
>
<Show
when={nav.mode() === NavMode.COMMAND}
fallback={
<>
<text fg={t.accent} paddingLeft={1}>
{modeLabel()}
</text>
<text fg={t.textMuted} paddingLeft={1}>
{TAB_LABEL[nav.activeTab()]} · pane {nav.activePane() + 1}/
{TabPaneCount[nav.activeTab()]}
</text>
<Show when={nav.selectedIds().length > 0}>
<text fg={t.warning} paddingLeft={1}>
{nav.selectedIds().length}
</text>
</Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>
<text fg={t.textMuted} paddingRight={1}>
:cmd ~help q quit
</text>
</>
}
>
<text fg={t.accent} paddingLeft={1}>
:
</text>
<text fg={t.text}>{nav.commandBuffer()}</text>
<text fg={t.textMuted}></text>
<Show when={nav.commandError()}>
<text fg={t.error} paddingLeft={1}>
{nav.commandError()}
</text>
</Show>
</Show>
</box>
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
<Show when={showHelp()}>
<HelpOverlay
onClose={() => setShowHelp(false)}
sections={helpSections(k)}
theme={t as any}
/>
</Show>
</box>
);
}
function helpSections(k: ReturnType<typeof useKeybinds>) {
const p = (a: KeybindActionName) => k.print(a);
return [
{
group: "Move",
items: [
["j/k", "move"],
["J/K", "5 lines"],
["ctrl-d/u", "half page"],
["gg/G", "top/bottom"],
],
},
{
group: "Panes",
items: [
["h/l", "swipe pane"],
["1-6 / [ ]", "switch tabs"],
[":", "command"],
["~", "help"],
],
},
{
group: "Select",
items: [
["space", "toggle"],
["v", "visual"],
["ctrl-a", "all"],
["esc", "clear"],
],
},
{
group: "Audio",
items: [
[p("audio-toggle"), "play/pause"],
[p("audio-next"), "next"],
[p("audio-seek-forward"), "fwd 10s"],
[p("audio-seek-backward"), "back 10s"],
],
},
{
group: "List",
items: [
["enter", "open"],
["r", "refresh"],
["s", "search"],
["f", "filter"],
[",", "sort"],
[".", "hidden"],
],
},
];
}
function HelpOverlay(props: {
onClose: () => void;
sections: { group: string; items: string[][] }[];
theme: any;
}) {
useKeyboard((evt: any) => {
if (k_match_escape(evt)) {
evt.preventDefault();
props.onClose();
}
});
const th = props.theme;
return (
<box
position="absolute"
top={2}
left={0}
width="100%"
alignItems="center"
backgroundColor="rgba(0,0,0,160)"
onMouseUp={() => props.onClose()}
>
<box
flexDirection="column"
border
borderColor={th.border}
backgroundColor={th.backgroundPanel ?? th.background}
padding={1}
width={60}
onMouseUp={(e: any) => e.stopPropagation()}
>
<text fg={th.accent}>
Yazi-style keybinds press ~ or Esc to close
</text>
<For each={props.sections}>
{(sec) => (
<box flexDirection="column" marginTop={1}>
<text fg={th.textSecondary}>{sec.group}</text>
<For each={sec.items}>
{(it) => (
<box flexDirection="row" gap={2}>
<text fg={th.accent}>{String(it[0]).padEnd(14, " ")}</text>
<text fg={th.textPrimary ?? th.text}>{it[1]}</text>
</box>
)}
</For>
</box>
)}
</For>
<box marginTop={1}>
<text fg={th.textMuted}>
Edit ~/.config/podtui/keybinds.jsonc to remap.
</text>
</box>
</box>
</box>
);
}
function k_match_escape(evt: any): boolean {
return (
evt.name === "escape" || evt.name === "~" || (evt.ctrl && evt.name === "[")
);
}
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
* search) into the player tab. */
export function playEpisodeAndSwitch(
nav: ReturnType<typeof useNavigation>,
audio: ReturnType<typeof useAudio>,
episode: import("@/types/episode").Episode,
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees.
export type { Episode };

View File

@@ -1,28 +1,27 @@
import { For } from "solid-js";
import { shortcuts } from "@/config/shortcuts"; import { shortcuts } from "@/config/shortcuts";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
/** Yazi-style keybind reference. The Shell has its own overlay; this component
* is kept for embedding inside Settings or other surfaces. */
export function ShortcutHelp() { export function ShortcutHelp() {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<box border title="Shortcuts" style={{ padding: 1 }}> <box
<box style={{ flexDirection: "column" }}> border
<box style={{ flexDirection: "row" }}> title="Shortcuts"
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text> style={{ flexDirection: "column", padding: 1 }}
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text> >
</box> <box style={{ flexDirection: "column" }}>
<box style={{ flexDirection: "row" }}> <For each={shortcuts}>
<text fg={theme.text}>{shortcuts[1]?.keys ?? ""} </text> {(s) => (
<text fg={theme.text}>{shortcuts[1]?.action ?? ""}</text> <box style={{ flexDirection: "row" }} gap={2}>
</box> <text fg={theme.accent}>{s.keys}</text>
<box style={{ flexDirection: "row" }}> <text fg={theme.text}>{s.action}</text>
<text fg={theme.text}>{shortcuts[2]?.keys ?? ""} </text> </box>
<text fg={theme.text}>{shortcuts[2]?.action ?? ""}</text> )}
</box> </For>
<box style={{ flexDirection: "row" }}> </box>
<text fg={theme.text}>{shortcuts[3]?.keys ?? ""} </text> </box>
<text fg={theme.text}>{shortcuts[3]?.action ?? ""}</text> );
</box>
</box>
</box>
);
} }

View File

@@ -1,20 +0,0 @@
{
"up": ["up", "k"],
"down": ["down", "j"],
"left": ["left", "h"],
"right": ["right", "l"],
"cycle": ["tab"], // this will cycle no matter the depth/orientation
"dive": ["return"],
"out": ["esc"],
"inverseModifier": ["shift"],
"leader": ":", // will not trigger while focused on input
"quit": ["<leader>q"],
"refresh": ["<leader>r"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],
"audio-next": ["<leader>n"],
"audio-prev": ["<leader>l"],
"audio-seek-forward": ["<leader>sf"],
"audio-seek-backward": ["<leader>sb"],
}

73
src/config/keybinds.jsonc Normal file
View File

@@ -0,0 +1,73 @@
{
// ── Yazi-style keybinds for PodTui ──────────────────────────────────────
// Notation:
// "j" single lowercase key
// "G" shift + g (uppercase letter = shift)
// "ctrl-d" ctrl modifier
// "shift-j" shift modifier (equivalent to "J")
// "meta-x" alt/meta modifier (macOS users: map Option to Alt)
// ["g","g"] a two-key sequence (matches only when pressed in order)
// "return" special key (also: escape tab space backspace up down left right)
//
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl.
// ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"],
"move-up": ["k", "up"],
"page-down": ["ctrl-d"],
"page-up": ["ctrl-u"],
"full-down": ["ctrl-f"],
"full-up": ["ctrl-b"],
"jump-down": ["J"], // 5 lines down (shift+j)
"jump-up": ["K"], // 5 lines up (shift+k)
"goto-top": [["g", "g"]],
"goto-bottom": ["G"],
// ── Pane focus / swipe (yazi h/l) ────────────────────────────────────────
"swipe-prev": ["h", "left"], // focus left pane (parent)
"swipe-next": ["l", "right"], // focus right pane (preview)
// ── Open / activate ──────────────────────────────────────────────────────
"open": ["return", "enter"],
"open-interactive": ["shift-return"],
// ── Selection & visual mode ──────────────────────────────────────────────
"toggle-select": ["space"],
"visual-mode": ["v"],
"toggle-all": ["ctrl-a"],
"invert-all": ["ctrl-r"],
"escape": ["escape", "ctrl-["],
// ── Tabs ──────────────────────────────────────────────────────────────────
"tab-prev": ["["],
"tab-next": ["]"],
"tab-goto-1": ["1"],
"tab-goto-2": ["2"],
"tab-goto-3": ["3"],
"tab-goto-4": ["4"],
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// ── Command bar & help & quit ────────────────────────────────────────────
"command": [":"],
"quit": ["q", "ctrl-c"],
"help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
"search": ["s"],
"filter": ["f"],
"sort": [","],
"toggle-hidden": ["."],
"refresh": ["r"],
// ── Audio transport (preserved) ──────────────────────────────────────────
// Kept on shifted single keys so they never collide with the yazi core
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
"audio-toggle": ["P"], // play / pause (shift+p)
"audio-next": ["N"], // next episode (shift+n)
"audio-prev": ["B"], // prev episode (shift+b)
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
}

View File

@@ -1,6 +1,26 @@
/**
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
* Shown in help overlays; the canonical source remains keybinds.jsonc.
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
*/
export const shortcuts = [ export const shortcuts = [
{ keys: "Ctrl+Q", action: "Quit" }, { keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "Ctrl+S", action: "Save" }, { keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "Left/Right", action: "Switch tabs" }, { keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "Esc", action: "Close modal" }, { keys: "ctrl-d / u", action: "Half page down / up" },
] as const { keys: "g g / G", action: "Go to top / bottom of list" },
{ keys: "1-6", action: "Go to tab 1-6" },
{ keys: "[ / ]", action: "Previous / next tab" },
{ keys: "Enter", action: "Open / activate focused item" },
{ keys: "Space", action: "Toggle selection on item" },
{ keys: "v", action: "Enter visual (range) select mode" },
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
{ keys: "r / s / f", action: "Refresh / search / filter" },
{ keys: ", / .", action: "Sort / toggle hidden" },
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
{ keys: "< / >", action: "Seek backward / forward 10s" },
{ keys: "~ / F1", action: "Help" },
{ keys: "q", action: "Quit" },
] as const;

View File

@@ -1,136 +1,375 @@
import { createSignal, onMount } from "solid-js"; import { createSignal, onMount } from "solid-js";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { import {
copyKeybindsIfNeeded, copyKeybindsIfNeeded,
loadKeybindsFromFile, loadKeybindsFromFile,
saveKeybindsToFile, saveKeybindsToFile,
} from "../utils/keybinds-persistence"; } from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
export type KeybindsResolved = { // ── Keybind model ───────────────────────────────────────────────────────────
up: string[]; // Yazi-style: every binding is one or more "strokes". A stroke is a single
down: string[]; // key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
left: string[]; // sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
right: string[]; // buffers keystrokes, prefers the longest matching sequence, and exposes the
cycle: string[]; // this will cycle no matter the depth/orientation // pending buffer reactively so the status bar can show it (very yazi).
dive: string[];
out: string[];
inverseModifier: string;
leader: string; // will not trigger while focused on input
quit: string[];
select: string[]; // for selecting/activating items
"audio-toggle": string[];
"audio-pause": string[];
"audio-play": string[];
"audio-next": string[];
"audio-prev": string[];
"audio-seek-forward": string[];
"audio-seek-backward": string[];
};
export enum KeybindAction { /** A single key press. `key` is the lowercase logical key name
UP, * ("j", "return", "space", "up", "f1", ...). */
DOWN, export interface Stroke {
LEFT, key: string;
RIGHT, ctrl?: boolean;
CYCLE, shift?: boolean;
DIVE, meta?: boolean;
OUT, }
QUIT,
SELECT, /** Raw config spec for one action: a list of alternative sequences. Each
AUDIO_TOGGLE, * alternative is itself a list of stroke-notation strings. So
AUDIO_PAUSE, * "j" -> [[ {key:"j"} ]]
AUDIO_PLAY, * ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
AUDIO_NEXT, * [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
AUDIO_PREV, export type KeybindSpec = string | (string | string[])[];
AUDIO_SEEK_F,
AUDIO_SEEK_B, /** Canonical action names. Must match keys in keybinds.jsonc. */
export type KeybindActionName =
| "move-down"
| "move-up"
| "page-down"
| "page-up"
| "full-down"
| "full-up"
| "jump-down"
| "jump-up"
| "goto-top"
| "goto-bottom"
| "swipe-prev"
| "swipe-next"
| "open"
| "open-interactive"
| "toggle-select"
| "visual-mode"
| "toggle-all"
| "invert-all"
| "escape"
| "tab-prev"
| "tab-next"
| "tab-goto-1"
| "tab-goto-2"
| "tab-goto-3"
| "tab-goto-4"
| "tab-goto-5"
| "tab-goto-6"
| "command"
| "quit"
| "help"
| "search"
| "filter"
| "sort"
| "toggle-hidden"
| "refresh"
| "audio-toggle"
| "audio-next"
| "audio-prev"
| "audio-seek-forward"
| "audio-seek-backward"
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
/** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
const SEQ_TIMEOUT_MS = 600;
// ── Stroke parsing ───────────────────────────────────────────────────────────
// Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j".
// uppercase letter "G" => {key:"g", shift:true}
// special: return/enter/escape/tab/space/backspace/up/down/left/right
export function parseStroke(notation: string): Stroke {
const raw = notation.trim();
let ctrl = false,
shift = false,
meta = false;
let key = raw;
const parts = raw.split(/[-+]/);
if (parts.length > 1) {
key = parts[parts.length - 1];
for (const mod of parts.slice(0, -1)) {
const m = mod.toLowerCase();
if (m === "ctrl" || m === "c") ctrl = true;
else if (m === "shift" || m === "s") shift = true;
else if (m === "meta" || m === "alt" || m === "m") meta = true;
}
}
// Uppercase single letter w/o modifier => shift+letter (vim convention)
if (
parts.length === 1 &&
key.length === 1 &&
key >= "A" &&
key <= "Z" &&
!ctrl &&
!shift &&
!meta
) {
shift = true;
key = key.toLowerCase();
}
return { key: key.toLowerCase(), ctrl, shift, meta };
}
/** Turn one raw spec into a list of alternative stroke-sequences. */
export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
if (spec == null) return [];
const alts: Stroke[][] = [];
const push = (item: string | string[]) => {
const seq = Array.isArray(item) ? item : [item];
alts.push(seq.map(parseStroke));
};
if (Array.isArray(spec)) {
for (const item of spec) push(item);
} else {
push(spec);
}
return alts;
}
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return {
key: (evt.name ?? "").toLowerCase(),
ctrl: !!evt.ctrl,
shift: !!evt.shift,
meta: !!evt.meta,
};
}
function strokeEq(a: Stroke, b: Stroke): boolean {
return (
a.key === b.key &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta
);
}
/** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string {
let out = "";
if (s.ctrl) out += "C-";
if (s.meta) out += "M-";
if (s.shift) out += "S-";
out += s.key;
return out;
}
export function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" ");
} }
export const { use: useKeybinds, provider: KeybindProvider } = export const { use: useKeybinds, provider: KeybindProvider } =
createSimpleContext({ createSimpleContext({
name: "Keybinds", name: "Keybinds",
init: () => { init: () => {
const [store, setStore] = createStore({ const [store, setStore] = createStore<KeybindsResolved>({});
up: [], // Resolved sequences per action, recomputed when store changes.
down: [], const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
left: [], {},
right: [], );
cycle: [], const [ready, setReady] = createSignal(false);
dive: [], const [pending, setPending] = createSignal<Stroke[]>([]);
out: [],
inverseModifier: "",
leader: "",
quit: [],
select: [],
refresh: [],
"audio-toggle": [],
"audio-pause": [],
"audio-play": [],
"audio-next": [],
"audio-prev": [],
"audio-seek-forward": [],
"audio-seek-backward": [],
} as KeybindsResolved);
const [ready, setReady] = createSignal(false);
async function load() { let pendingTimer: ReturnType<typeof setTimeout> | undefined;
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
setReady(true);
}
async function save() { function recompute() {
saveKeybindsToFile(store); const out: Record<string, Stroke[][]> = {};
} for (const name of Object.keys(store) as string[]) {
out[name] = parseBindingSpec((store as any)[name]);
}
setResolved(out);
}
function print(input: keyof KeybindsResolved): string { async function load() {
const keys = store[input] || []; await copyKeybindsIfNeeded();
return Array.isArray(keys) ? keys.join(", ") : keys; const keybinds = await loadKeybindsFromFile();
} setStore(keybinds);
recompute();
setReady(true);
}
function match( async function save() {
keybind: keyof KeybindsResolved, saveKeybindsToFile(store as KeybindsResolved);
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }, }
): boolean {
const keys = store[keybind];
if (!keys) return false;
for (const key of keys) { function print(input: KeybindActionName): string {
if (evt.name === key) return true; const alts = resolved()[input] ?? [];
} return alts.map(sequenceLabel).join(" / ") || "—";
return false; }
}
function isInverting(evt: { function clearPending() {
name: string; if (pendingTimer) {
ctrl?: boolean; clearTimeout(pendingTimer);
meta?: boolean; pendingTimer = undefined;
shift?: boolean; }
}) { if (pending().length > 0) setPending([]);
if (store.inverseModifier === "ctrl" && evt.ctrl) return true; }
if (store.inverseModifier === "meta" && evt.meta) return true;
if (store.inverseModifier === "shift" && evt.shift) return true;
return false;
}
// Load on mount function armTimer() {
onMount(() => { if (pendingTimer) clearTimeout(pendingTimer);
load().catch(() => {}); pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS);
}); }
return { /** Look up every action whose sequence-list the candidate is a prefix of
get ready() { * (i.e. a longer match is still possible) and every action that the
return ready(); * candidate exactly equals. */
}, function classify(candidate: Stroke[]) {
get keybinds() { const exact: KeybindActionName[] = [];
return store; const prefix: KeybindActionName[] = [];
}, const map = resolved();
save, for (const name of Object.keys(map) as KeybindActionName[]) {
print, for (const seq of map[name] ?? []) {
match, if (seq.length < candidate.length) continue;
isInverting, let isPrefix = true;
}; for (let i = 0; i < candidate.length; i++) {
}, if (!strokeEq(seq[i], candidate[i])) {
}); isPrefix = false;
break;
}
}
if (!isPrefix) continue;
if (seq.length === candidate.length) exact.push(name);
else prefix.push(name);
}
}
return { exact, prefix };
}
/** Legacy single-key matcher (used by command-palette registrations and
* older call sites). Returns true iff `name`'s sequence list contains a
* single-stroke alternative equal to the event. */
function match(
name: KeybindActionName,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const alts = resolved()[name] ?? [];
const s = strokeFromEvent(evt);
// skip in command/input mode unless explicitly handled by caller
for (const seq of alts) {
if (seq.length === 1 && strokeEq(seq[0], s)) return true;
}
return false;
}
/** New sequence-aware matcher. Returns the resolved action or null.
* Callers should invoke this once per keypress in a single router. */
function tryMatch(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): KeybindActionName | null {
const stroke = strokeFromEvent(evt);
const candidate = [...pending(), stroke];
const { exact, prefix } = classify(candidate);
// Still mid-sequence: wait for more keys (unless this stroke also
// exactly matches something AND nothing depends on a longer prefix).
if (prefix.length > 0) {
setPending(candidate);
armTimer();
// If there's also an exact match, we *could* fire now — but yazi
// prefers to wait for the longer sequence within the timeout, then
// falls through. We honor that: only fire exact if no prefix.
if (exact.length > 0) {
// ambiguous prefix+exact: keep waiting (e.g. `g` could be gg)
}
return null;
}
// No longer-match possible: decide on exact.
clearPending();
if (exact.length === 0) {
// The new stroke might itself begin a fresh sequence.
const fresh = classify([stroke]);
if (fresh.prefix.length > 0) {
setPending([stroke]);
armTimer();
return null;
}
if (fresh.exact.length > 0) {
// prefer longest-sequence match among fresh.exact
return pickLongest(fresh.exact);
}
return null;
}
return pickLongest(exact);
}
function pickLongest(names: KeybindActionName[]): KeybindActionName {
const map = resolved();
let best = names[0];
let bestLen = 0;
for (const n of names) {
for (const seq of map[n] ?? []) {
if (seq.length > bestLen) {
bestLen = seq.length;
best = n;
}
}
}
return best;
}
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => {
load().catch(() => {});
});
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
get resolved() {
return resolved();
},
pending,
match,
tryMatch,
isInverting,
print,
save,
load,
clearPending,
};
},
});

View File

@@ -1,73 +1,305 @@
import { createEffect, createSignal, on } from "solid-js"; import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation"; import { TABS, TabsCount } from "@/utils/navigation";
// Page-specific pane counts // ── Yazi-style navigation state ──────────────────────────────────────────────
const PANE_COUNTS = { // PodTui's interaction model after the yazi redesign. A single source of truth
[TABS.FEED]: 1, // for: which tab is active, which pane within a tab is focused (parent |
[TABS.MYSHOWS]: 2, // current | preview), the current mode (normal/visual/command/input), the
[TABS.DISCOVER]: 2, // count register (for `5j` style motions), and the command-bar buffer.
[TABS.SEARCH]: 3, //
[TABS.PLAYER]: 1, // Panes are addressed by index 0..N-1 within the active tab. Each tab declares
[TABS.SETTINGS]: 5, // how many panes it has via the PaneSystem registry (see navigation.ts). h/l
}; // (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's
// list (handled per-pane via the focusedIndex accessors below).
export enum NavMode {
NORMAL = "NORMAL",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for
* tabs that need more panes (e.g. search = query/results/detail). */
export enum PaneSlot {
PARENT = 0, // left — the container list (e.g. shows)
CURRENT = 1, // middle — the items (e.g. episodes)
PREVIEW = 2, // right — detail of the hovered item
}
export type PaneId = number; // 0-based index into the active tab's pane list
// ── Selection store ───────────────────────────────────────────────────────────
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
// selection anchored at the focused index.
type SelectionMap = Record<string, Set<string>>;
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
export const { use: useNavigation, provider: NavigationProvider } = export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({ createSimpleContext({
name: "Navigation", name: "Navigation",
init: () => { init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED); const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activeDepth, setActiveDepth] = createSignal(0); const [activePane, setActivePane] = createSignal<PaneId>(
const [inputFocused, setInputFocused] = createSignal(false); PaneSlot.CURRENT,
);
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false);
createEffect( // per-pane focused index (for j/k movement). Keyed by `${tab}:${pane}`.
on( const [paneIndices, setPaneIndices] = createSignal<
() => activeTab, Record<string, number>
() => setActiveDepth(0), >({});
), const [selections, setSelections] = createSignal<SelectionMap>({});
); const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const nextTab = () => { const [commandBuffer, setCommandBuffer] = createSignal("");
if (activeTab() >= TabsCount) { const [commandError, setCommandError] = createSignal<string | null>(null);
setActiveTab(1);
return;
}
setActiveTab(activeTab() + 1);
};
const prevTab = () => { // Reset depth/pane/mode on tab change.
if (activeTab() <= 1) { createEffect(
setActiveTab(TabsCount); on(activeTab, () => {
return; batch(() => {
} setActivePane(PaneSlot.CURRENT);
setActiveTab(activeTab() - 1); setMode(NavMode.NORMAL);
}; setCount(null);
setCommandBuffer("");
setCommandError(null);
setVisualAnchor(null);
});
}),
);
const nextPane = () => { // ── tab switching ──────────────────────────────────────────────────────
// Move to next pane within the current tab's pane structure const gotoTab = (tab: TABS) => {
const count = PANE_COUNTS[activeTab()]; if (tab < 1 || tab > TabsCount) return;
if (count <= 1) return; // No panes to navigate (feed/player) setActiveTab(tab);
setActiveDepth((prev) => (prev % count) + 1); };
}; const nextTab = () =>
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
const prevTab = () =>
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
const prevPane = () => { // ── pane focus ──────────────────────────────────────────────────────────
// Move to previous pane within the current tab's pane structure const setPane = (pane: PaneId) => setActivePane(pane);
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev - 2 + count) % count + 1);
};
return { /** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1
activeTab, * (right/preview). Clamped to [0, paneCount-1]. */
activeDepth, const swipe = (dir: -1 | 1, paneCount: number) => {
inputFocused, if (paneCount <= 1) return;
setActiveTab, setActivePane((p) => {
setActiveDepth, const n = Math.max(0, Math.min(paneCount - 1, p + dir));
setInputFocused, return n;
nextTab, });
prevTab, };
nextPane,
prevPane, // ── per-pane focus index ────────────────────────────────────────────────
}; const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
},
}); const focusedIndex = (pane: PaneId = activePane()) =>
paneIndices()[paneKey(pane)] ?? 0;
const setFocusedIndex = (pane: PaneId, index: number) =>
setPaneIndices((m) => ({ ...m, [`${activeTab()}:${pane}`]: index }));
/** Apply a clamped relative motion to the active pane's focus. Returns
* the new index so callers can update their own scroll state. */
const move = (
delta: number,
listLen: number,
countOverride?: number,
): number => {
if (listLen <= 0) return 0;
const steps = countOverride ?? count() ?? 1;
const pane = activePane();
const cur = focusedIndex(pane);
let next = cur + delta * steps;
// wrap-around like yazi (arrow wraps top<->bottom)
next = ((next % listLen) + listLen) % listLen;
setFocusedIndex(pane, next);
// visual-mode range selection: add newly-traversed items to selection
if (HAS_VISUAL(mode()) && visualAnchor()) {
growVisualSelection(next);
}
return next;
};
const gotoIndex = (index: number, listLen: number): number => {
if (listLen <= 0) return 0;
const pane = activePane();
const next = Math.max(0, Math.min(listLen - 1, index));
setFocusedIndex(pane, next);
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
return next;
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> =>
selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
setSelections((m) => {
const set = new Set(m[key] ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
return { ...m, [key]: set };
});
};
const isSelected = (id: string) => selSet(paneKey()).has(id);
const clearSelection = (key?: string) => {
const k = key ?? paneKey();
setSelections((m) => {
if (!(k in m)) return m;
const next = { ...m };
delete next[k];
return next;
});
};
const selectedIds = createMemo(() => [...selSet(paneKey())]);
/** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => {
const pane = activePane();
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
setMode(NavMode.VISUAL);
};
/** Grow selection between the visual anchor and `index` for the active
* pane. Callers pass item ids aligned to indices; we store ids via the
* resolve callback registered per-pane (see registerResolver). */
let resolvers: Record<string, (index: number) => string | undefined> = {};
const registerResolver = (
key: string,
fn: (i: number) => string | undefined,
) => {
resolvers[key] = fn;
};
const growVisualSelection = (index: number) => {
const anchor = visualAnchor();
if (!anchor) return;
const resolve = resolvers[anchor.paneKey];
if (!resolve) return;
const lo = Math.min(anchor.index, index);
const hi = Math.max(anchor.index, index);
const ids: string[] = [];
for (let i = lo; i <= hi; i++) {
const id = resolve(i);
if (id) ids.push(id);
}
const key = anchor.paneKey;
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
};
// ── modes ────────────────────────────────────────────────────────────────
const enterCommand = () => {
setMode(NavMode.COMMAND);
setCommandBuffer("");
setCommandError(null);
};
const enterInput = () => setMode(NavMode.INPUT);
const exitCommand = () => {
batch(() => {
setMode(NavMode.NORMAL);
setCommandBuffer("");
setCommandError(null);
});
};
const exitVisual = () => {
batch(() => {
setMode(NavMode.NORMAL);
setVisualAnchor(null);
});
};
const toNormal = () => {
if (mode() === NavMode.VISUAL) {
clearSelection();
exitVisual();
} else {
setMode(NavMode.NORMAL);
}
};
// ── command buffer ───────────────────────────────────────────────────────
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
const submitCommand = (): string => {
const cmd = commandBuffer().trim();
exitCommand();
return cmd;
};
// ── count register ───────────────────────────────────────────────────────
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
const consumeCount = (): number => {
const c = count();
setCount(null);
return c ?? 1;
};
return {
activeTab,
activePane,
mode,
count,
inputFocused,
commandBuffer,
commandError,
visualAnchor,
selections,
selectedIds,
// tab
setActiveTab: gotoTab,
nextTab,
prevTab,
// pane focus
setActivePane: setPane,
swipe,
// focus index
focusedIndex,
setFocusedIndex,
move,
gotoIndex,
// selection
isSelected,
toggleSelected,
clearSelection,
selectedIdsFor: (key: string) => [...selSet(key)],
registerResolver,
enterVisual,
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode,
enterCommand,
enterInput,
exitCommand,
toNormal,
// command buffer
setCommandBuffer,
appendCommand,
backspaceCommand,
submitCommand,
setCommandError,
// count
pushCountDigit,
consumeCount,
};
},
});

View File

@@ -1,185 +1,353 @@
/** /**
* DiscoverPage component - Main discover/browse interface for PodTUI * DiscoverPage — yazi-style 3-pane view.
*
* pane 0 (parent) — category list (the "containers")
* pane 1 (current) — podcast results for the focused category (landing pane)
* pane 2 (preview) — detail of the focused podcast + subscribe action
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes.
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering.
*/ */
import { createSignal, For, Show, onMount } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover"; import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard"; import {
import { SelectableBox, SelectableText } from "@/components/Selectable"; useNavigation,
import { useNavigation } from "@/context/NavigationContext"; NavMode,
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Podcast } from "@/types/podcast";
import { PANE_RATIO } from "@/utils/navigation";
enum DiscoverPagePaneType { export const DiscoverPaneCount = 3;
CATEGORIES = 1,
SHOWS = 2, function DiscoverPage() {
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
const categories = () => DISCOVER_CATEGORIES;
const podcasts = () => discoverStore.filteredPodcasts();
const focusedCategory = createMemo(() => {
const list = categories();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
});
// ── keep category + results focus in range ───────────────────────────────
const ensureFocus = () => {
const cl = categories();
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
nav.setFocusedIndex(CATS, cl.length - 1);
const pl = podcasts();
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
nav.setFocusedIndex(RESULTS, pl.length - 1);
};
onMount(ensureFocus);
const focusedPodcast = createMemo(() => {
const list = podcasts();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
});
// Register a resolver so visual-mode range selection grows by podcast id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
});
onCleanup(() => unsub());
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubscribe = (podcast: Podcast) => {
discoverStore.toggleSubscription(podcast.id);
};
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
nav.swipe(1, DiscoverPaneCount); // dive to results
return;
}
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) handleSubscribe(pod);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
},
refresh: () => {
discoverStore.refresh().catch(() => {});
},
};
function len(pane: PaneId): number {
if (pane === CATS) return categories().length;
if (pane === RESULTS) return podcasts().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
}
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
focused={isActive(CATS)}
border
borderColor={border(CATS)}
backgroundColor={theme.background}
>
<For each={categories()}>
{(cat, index) => {
const selected = () =>
cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
selected() && !isActive(CATS)
? theme.border
: focusBg(index(), CATS)
}
onMouseDown={() => {
nav.setActivePane(CATS);
nav.setFocusedIndex(CATS, index());
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), CATS)}>
{index() === nav.focusedIndex(CATS) ? "" : " "}
</text>
<text fg={focusFg(index(), CATS)}>{cat.name}</text>
<Show when={selected()}>
<text
fg={
index() === nav.focusedIndex(CATS)
? theme.surface
: theme.accent
}
>
*
</text>
</Show>
</box>
);
}}
</For>
</scrollbox>
</box>
{/* ── pane 1 (current, center): results ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{focusedCategory()?.name ?? "Discover"} · {podcasts().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
<Show when={podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREVIEW)}
border
borderColor={border(PREVIEW)}
backgroundColor={theme.background}
>
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes r: refresh</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
} }
export const DiscoverPaneCount = 2;
export function DiscoverPage() { export { DiscoverPage };
const discoverStore = useDiscoverStore();
const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0);
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
setShowIndex(showIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length === 0) return;
if (isDown && !isInverting()) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if (isUp && isInverting()) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
}
},
{ release: false },
);
});
const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId);
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
if (index >= 0) setCategoryIndex(index);
setShowIndex(0);
};
const handleShowSelect = (index: number) => {
setShowIndex(index);
};
const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id);
};
const { theme } = useTheme();
return (
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
<box
border
padding={1}
borderColor={
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
? theme.border
: theme.accent
}
flexDirection="column"
gap={1}
>
<text
fg={
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
? theme.accent
: theme.text
}
>
Categories:
</text>
<box flexDirection="column" gap={1}>
<For each={discoverStore.categories}>
{(category) => {
const isSelected = () =>
discoverStore.selectedCategory() === category.id;
return (
<SelectableBox
selected={isSelected}
onMouseDown={() => handleCategorySelect(category.id)}
>
<SelectableText selected={isSelected} primary>
{category.icon} {category.name}
</SelectableText>
</SelectableBox>
);
}}
</For>
</box>
</box>
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() == DiscoverPagePaneType.SHOWS
? theme.accent
: theme.border
}
>
<box padding={1}>
<SelectableText
selected={() => false}
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
Trending in{" "}
{DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All"}
</SelectableText>
</box>
<box flexDirection="column" height="100%">
<Show
fallback={
<box padding={2}>
{discoverStore.filteredPodcasts().length !== 0 ? (
<text fg={theme.warning}>Loading trending shows...</text>
) : (
<text fg={theme.textMuted}>
No podcasts found in this category.
</text>
)}
</box>
}
when={
!discoverStore.isLoading() &&
discoverStore.filteredPodcasts().length === 0
}
>
<scrollbox
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
<box flexDirection="column">
<For each={discoverStore.filteredPodcasts()}>
{(podcast, index) => (
<PodcastCard
podcast={podcast}
selected={
index() === showIndex() &&
nav.activeDepth() == DiscoverPagePaneType.SHOWS
}
onSelect={() => handleShowSelect(index())}
onSubscribe={() => handleSubscribe(podcast)}
/>
)}
</For>
</box>
</scrollbox>
</Show>
</box>
</box>
</box>
);
}

View File

@@ -1,195 +1,460 @@
/** /**
* FeedPage - Shows latest episodes across all subscribed shows * FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
* Reverse chronological order, grouped by date *
* pane 0 (parent) — subscribed feeds list (the "containers"); an implicit
* "All Feeds" entry at index 0 shows every episode.
* pane 1 (current) — flat episodes list for the focused feed (reverse
* chronological). This is the landing pane.
* pane 2 (preview) — detail of the focused episode.
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio.
*/ */
import { createSignal, For, Show, onMount } from "solid-js"; import {
createMemo,
For,
Show,
onMount,
onCleanup,
createEffect,
} from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { TABS } from "@/utils/navigation"; import { PANE_RATIO } from "@/utils/navigation";
import { useKeyboard } from "@opentui/solid";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum FeedPaneType { export const FeedPaneCount = 3;
FEED = 1,
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
function FeedPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent)
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane)
const PREV = PaneSlot.PREVIEW; // 2 — episode detail
// ── feeds pane data ──────────────────────────────────────────────────────
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
const feedList = createMemo<FeedListItem[]>(() => {
const all: FeedListItem[] = [{ kind: "all" }];
for (const f of feedStore.getFilteredFeeds())
all.push({ kind: "feed", feed: f });
return all;
});
const focusedFeedItem = createMemo(() => {
const list = feedList();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)];
});
// ── episodes pane data (filtered by focused feed, or all) ────────────────
type EpItem = { episode: Episode; feed: Feed };
const episodes = createMemo<EpItem[]>(() => {
const item = focusedFeedItem();
if (!item || item.kind === "all")
return feedStore.getAllEpisodesChronological() as EpItem[];
return [...item.feed.episodes]
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
.map((episode) => ({ episode, feed: item.feed }));
});
// Reset episodes focus when the feed filter changes.
createEffect(() => {
focusedFeedItem();
nav.setFocusedIndex(EPS, 0);
});
const focusedItem = createMemo<EpItem | undefined>(() => {
const list = episodes();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(EPS), list.length - 1)];
});
// Keep resolvers fresh so visual-mode range selection grows by id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.episode.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.episode.id,
);
});
onCleanup(() => unsub());
});
const ensureFocus = () => {
const eps = episodes();
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length)
nav.setFocusedIndex(EPS, eps.length - 1);
const fl = feedList();
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
nav.setFocusedIndex(FEEDS, fl.length - 1);
};
onMount(ensureFocus);
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (item: EpItem | undefined) => {
if (!item) return;
audio.play(item.episode).catch(() => {});
audioNav.setSource(AudioSource.FEED);
};
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
if (p === EPS) playEpisode(focusedItem());
},
"toggle-select": (p) => {
if (p === EPS) {
const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id);
}
},
refresh: () => {
const item = focusedFeedItem();
if (item?.kind === "feed")
feedStore.refreshFeed(item.feed.id).catch(() => {});
else feedStore.refreshAllFeeds().catch(() => {});
},
};
function len(pane: PaneId): number {
if (pane === FEEDS) return feedList().length;
if (pane === EPS) return episodes().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
</box>
<scrollbox
height="100%"
focused={isActive(FEEDS)}
border
borderColor={border(FEEDS)}
backgroundColor={theme.background}
>
<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 label = () =>
item.kind === "all"
? "All Feeds"
: item.feed.customName || item.feed.podcast.title;
const count = () =>
item.kind === "all"
? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length;
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), FEEDS)}
onMouseDown={() => {
nav.setActivePane(FEEDS);
nav.setFocusedIndex(FEEDS, index());
}}
>
<text fg={focusFg(index(), FEEDS)}>
{index() === nav.focusedIndex(FEEDS) ? "" : " "}
</text>
<text fg={focusFg(index(), FEEDS)}>{label()}</text>
<text
fg={
index() === nav.focusedIndex(FEEDS)
? theme.surface
: muted()
}
>
({count()})
</text>
</box>
);
}}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{(() => {
const fi = focusedFeedItem();
if (fi?.kind === "feed")
return fi.feed.customName || fi.feed.podcast.title;
return "All Episodes";
})()} · {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), EPS)}
onMouseDown={() => {
nav.setActivePane(EPS);
nav.setFocusedIndex(EPS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}>
{index() === nav.focusedIndex(EPS) ? "" : " "}
</text>
<text fg={focusFg(index(), EPS)}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(item.episode.pubDate)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(item.episode.duration)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? 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>
)}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREV)}
border
borderColor={border(PREV)}
backgroundColor={theme.background}
>
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>
{formatDate(item().episode.pubDate)}
</text>
<text fg={muted()}>
{formatDuration(item().episode.duration)}
</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
} }
export const FeedPaneCount = 1;
const ITEMS_PER_BATCH = 50; export { FeedPage };
export function FeedPage() {
const feedStore = useFeedStore();
const nav = useNavigation();
const { theme } = useTheme();
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
string | undefined
>();
const allEpisodes = () => feedStore.getAllEpisodesChronological();
const keybind = useKeybinds();
const [focusedIndex, setFocusedIndex] = createSignal(0);
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const episodes = allEpisodes();
if (episodes.length > 0 && episodes[focusedIndex()]) {
setSelectedEpisodeID(episodes[focusedIndex()].episode.id);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== FeedPaneType.FEED) return;
const episodes = allEpisodes();
if (episodes.length === 0) return;
if (isDown && !isInverting()) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if (isUp && isInverting()) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
}
},
{ release: false },
);
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const groupEpisodesByDate = () => {
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
for (const item of allEpisodes()) {
const dateKey = formatDate(new Date(item.episode.pubDate));
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
}
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
// Convert date strings back to Date objects for proper chronological sorting
const dateA = new Date(a);
const dateB = new Date(b);
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime();
});
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
return (
<box
border
borderColor={
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
}
backgroundColor={theme.background}
flexDirection="column"
height="100%"
width="100%"
>
<Show
when={allEpisodes().length > 0}
fallback={
<box padding={2}>
<text fg={theme.textMuted}>
No episodes yet. Subscribe to shows from Discover or Search.
</text>
</box>
}
>
<scrollbox
height="100%"
focused={nav.activeDepth() == FeedPaneType.FEED}
>
<For each={groupEpisodesByDate()}>
{([date, items]) => (
<box flexDirection="column" gap={1} padding={1}>
<SelectableText selected={() => false} primary>
{date}
</SelectableText>
<For each={items}>
{(item) => {
const isSelected = () => {
if (
nav.activeTab() == TABS.FEED &&
nav.activeDepth() == FeedPaneType.FEED &&
selectedEpisodeID() &&
selectedEpisodeID() === item.episode.id
) {
return true;
}
return false;
};
const isFocused = () => {
const episodes = allEpisodes();
const currentIndex = episodes.findIndex(
(e: any) => e.episode.id === item.episode.id,
);
return currentIndex === focusedIndex();
};
return (
<SelectableBox
selected={isSelected}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
onMouseDown={() => {
setSelectedEpisodeID(item.episode.id);
const episodes = allEpisodes();
setFocusedIndex(
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
);
}}
>
<SelectableText selected={isSelected} primary>
{item.episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={isSelected} primary>
{item.feed.podcast.title}
</SelectableText>
<SelectableText selected={isSelected} tertiary>
{formatDuration(item.episode.duration)}
</SelectableText>
</box>
</SelectableBox>
);
}}
</For>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
);
}

View File

@@ -1,325 +1,431 @@
/** /**
* MyShowsPage - Two-panel file-explorer style view * MyShowsPage — yazi-style 3-pane view (canonical reference migration).
* Left panel: list of subscribed shows *
* Right panel: episodes for the selected show * pane 0 (parent) — subscribed shows
* pane 1 (current) — episodes of the focused show
* pane 2 (preview) — detail of the focused episode
*
* Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the
* Shell router via the `nav.action` event bus; this page only subscribes and
* translates actions against its own data. h/l swipe between panes is handled
* by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane)
* so the page is purely reactive.
*/ */
import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useNavigation } from "@/context/NavigationContext"; import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; import { PANE_RATIO } from "@/utils/navigation";
enum MyShowsPaneType { export const MyShowsPaneCount = 3;
SHOWS = 1,
EPISODES = 2,
}
export const MyShowsPaneCount = 2;
export function MyShowsPage() { export function MyShowsPage() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const [isRefreshing, setIsRefreshing] = createSignal(false); const audio = useAudio();
const [showIndex, setShowIndex] = createSignal(0); const { theme } = useTheme();
const [episodeIndex, setEpisodeIndex] = createSignal(0); const muted = () => theme.muted || theme.text;
const { theme } = useTheme(); const nav = useNavigation();
const mutedColor = () => theme.muted || theme.text;
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => { const SHOWS = PaneSlot.PARENT;
useKeyboard( const EPS = PaneSlot.CURRENT;
(keyEvent: any) => { const PREV = PaneSlot.PREVIEW;
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
const shows = feedStore.getFilteredFeeds(); const shows = () => feedStore.getFilteredFeeds();
const episodesList = episodes();
if (isSelect) { // The selected show tracks the focused row of pane 0.
if (shows.length > 0 && showIndex() < shows.length) { const selectedShow = createMemo(() => {
setShowIndex(showIndex() + 1); const list = shows();
} if (list.length === 0) return undefined;
if (episodesList.length > 0 && episodeIndex() < episodesList.length) { const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
setEpisodeIndex(episodeIndex() + 1); return list[idx];
} });
return;
}
// don't handle pane navigation here - unified in App.tsx const episodes = createMemo(() => {
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return; const show = selectedShow();
if (!show) return [] as Episode[];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
if (episodesList.length > 0) { // Register a resolver so visual-mode range selection grows by episode id.
if (isDown && !isInverting()) { onMount(() => {
setEpisodeIndex((i) => (i + 1) % episodesList.length); nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id);
} else if (isUp && isInverting()) { // keep the resolver fresh as the episode list changes
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length); const unsub = on("nav.action", () => {
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { nav.registerResolver(
setEpisodeIndex((i) => (i + 1) % episodesList.length); `${nav.activeTab()}:${EPS}`,
} else if ((isCycle && isInverting()) || (isUp && isInverting())) { (i) => episodes()[i]?.id,
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length); );
} });
} onCleanup(() => unsub());
}, });
{ release: false },
);
});
/** Threshold: load more when within this many items of the end */ // Keep shows-focus in range after feeds load/change.
const LOAD_MORE_THRESHOLD = 5; const ensureShowsFocus = () => {
const list = shows();
if (list.length === 0) return;
const cur = nav.focusedIndex(SHOWS);
if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1);
};
onMount(ensureShowsFocus);
const shows = () => feedStore.getFilteredFeeds(); // When the show changes, reset episode focus + set audio-nav source + show count.
const onShowChanged = () => {
const show = selectedShow();
if (!show) return;
if (nav.focusedIndex(EPS) > episodes().length - 1)
nav.setFocusedIndex(EPS, 0);
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
};
onMount(onShowChanged);
const selectedShow = createMemo(() => { const focusedEpisode = createMemo(() => {
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation const eps = episodes();
}); if (eps.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1);
return eps[idx];
});
const episodes = createMemo(() => { // ── helpers ─────────────────────────────────────────────────────────────────
const show = selectedShow(); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
if (!show) return []; const formatDuration = (s: number) => {
return [...show.episodes].sort( const mins = Math.floor(s / 60);
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), const hrs = Math.floor(mins / 60);
); return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
}); };
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const formatDate = (date: Date): string => { const playEpisode = (ep: Episode) => {
return format(date, "MMM d, yyyy"); audio.play(ep).catch(() => {});
}; audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
const formatDuration = (seconds: number): string => { // ── nav.action handler ──────────────────────────────────────────────────────
const mins = Math.floor(seconds / 60); const PAGE_ACTIONS: Partial<
const hrs = Math.floor(mins / 60); Record<KeybindActionName, (pane: PaneId) => void>
if (hrs > 0) return `${hrs}h ${mins % 60}m`; > = {
return `${mins}m`; "move-down": (p) => step(p, 1),
}; "move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === SHOWS) {
nav.swipe(1, MyShowsPaneCount);
onShowChanged();
} else if (p === EPS) {
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
},
"toggle-select": (p) => {
if (p === EPS) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
refresh: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
},
};
/** Get download status label for an episode */ function len(pane: PaneId): number {
const downloadLabel = (episodeId: string): string => { if (pane === SHOWS) return shows().length;
const status = downloadStore.getDownloadStatus(episodeId); if (pane === EPS) return episodes().length;
switch (status) { return 0;
case DownloadStatus.QUEUED: }
return "[Q]"; function step(pane: PaneId, delta: number) {
case DownloadStatus.DOWNLOADING: { nav.move(delta, len(pane));
const pct = downloadStore.getDownloadProgress(episodeId); if (pane === SHOWS) {
return `[${pct}%]`; // clamp episode focus + re-resolve after show change
} nav.setFocusedIndex(
case DownloadStatus.COMPLETED: EPS,
return "[DL]"; Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)),
case DownloadStatus.FAILED: );
return "[ERR]"; onShowChanged();
default: }
return ""; }
}
};
const handleRefresh = async () => { const onAction = (data: {
const show = selectedShow(); action: KeybindActionName;
if (!show) return; pane: PaneId;
setIsRefreshing(true); mode: NavMode;
await feedStore.refreshFeed(show.id); }) => {
setIsRefreshing(false); // Only react when our tab is active.
}; // (Shell always emits; router guarantees our tab is active.)
ensureShowsFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
// visual selection growth is handled inside nav.move/registerResolver
};
const handleUnsubscribe = () => { onMount(() => {
const show = selectedShow(); on("nav.action", onAction);
if (!show) return; onCleanup(() => off("nav.action", onAction));
feedStore.removeFeed(show.id); });
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
};
/** Get download status color */ // ── render ──────────────────────────────────────────────────────────────────
const downloadColor = (episodeId: string): string => { const isActive = (p: PaneId) => nav.activePane() === p;
const status = downloadStore.getDownloadStatus(episodeId); const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
switch (status) {
case DownloadStatus.QUEUED:
return theme.warning.toString();
case DownloadStatus.DOWNLOADING:
return theme.primary.toString();
case DownloadStatus.COMPLETED:
return theme.success.toString();
case DownloadStatus.FAILED:
return theme.error.toString();
default:
return mutedColor().toString();
}
};
return ( const focusBg = (i: number, pane: PaneId) =>
<box flexDirection="row" flexGrow={1} width="100%"> i === nav.focusedIndex(pane) && isActive(pane)
<box flexDirection="column" height="100%"> ? theme.primary
<Show when={isRefreshing()}> : i === nav.focusedIndex(pane)
<text fg={theme.warning}>Refreshing...</text> ? theme.border
</Show> : undefined;
<Show const focusFg = (i: number, pane: PaneId) =>
when={shows().length > 0} i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
fallback={
<box padding={1}> return (
<text fg={theme.muted}> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
No shows yet. Subscribe from Discover or Search. {/* ── pane 0: shows ─────────────────────────────────────────────────────── */}
</text> <box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
</box> <box height={1} paddingLeft={1} backgroundColor={theme.background}>
} <text fg={theme.textSecondary}>Shows ({shows().length})</text>
> </box>
<scrollbox <scrollbox
border height="100%"
height="100%" focused={isActive(SHOWS)}
borderColor={ border
nav.activeDepth() == MyShowsPaneType.SHOWS borderColor={border(SHOWS)}
? theme.accent backgroundColor={theme.background}
: theme.border >
} <Show
focused={nav.activeDepth() == MyShowsPaneType.SHOWS} when={shows().length > 0}
> fallback={
<For each={shows()}> <box padding={1}>
{(feed, index) => ( <text fg={muted()}>
<box No shows. Subscribe from Discover/Search.
flexDirection="row" </text>
gap={1} </box>
paddingLeft={1} }
paddingRight={1} >
backgroundColor={ <For each={shows()}>
index() === showIndex() ? theme.primary : undefined {(feed, index) => (
} <box
onMouseDown={() => { flexDirection="row"
setShowIndex(index()); gap={1}
setEpisodeIndex(0); paddingLeft={1}
audioNav.setSource( paddingRight={1}
AudioSource.MY_SHOWS, backgroundColor={focusBg(index(), SHOWS)}
selectedShow()?.podcast.id, onMouseDown={() => {
); nav.setActivePane(SHOWS);
}} nav.setFocusedIndex(SHOWS, index());
> onShowChanged();
<text }}
fg={index() === showIndex() ? theme.surface : theme.text} >
> <text fg={focusFg(index(), SHOWS)}>
{index() === showIndex() ? ">" : " "} {index() === nav.focusedIndex(SHOWS) ? "" : " "}
</text> </text>
<text <text fg={focusFg(index(), SHOWS)}>
fg={index() === showIndex() ? theme.surface : theme.text} {feed.customName || feed.podcast.title}
> </text>
{feed.customName || feed.podcast.title} <text
</text> fg={
<text fg={index() === showIndex() ? undefined : theme.text}> index() === nav.focusedIndex(SHOWS)
({feed.episodes.length}) ? theme.surface
</text> : muted()
</box> }
)} >
</For> ({feed.episodes.length})
</scrollbox> </text>
</Show> </box>
</box> )}
<box flexDirection="column" height="100%"> </For>
<Show </Show>
when={selectedShow()} </scrollbox>
fallback={ </box>
<box padding={1}>
<text fg={theme.muted}>Select a show</text> {/* ── pane 1: episodes ──────────────────────────────────────────────────── */}
</box> <box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
} <box height={1} paddingLeft={1} backgroundColor={theme.background}>
> <text fg={theme.textSecondary}>
<Show {selectedShow()?.customName ||
when={episodes().length > 0} selectedShow()?.podcast.title ||
fallback={ "Episodes"}{" "}
<box padding={1}> · {episodes().length}
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text> </text>
</box> </box>
} <scrollbox
> height="100%"
<scrollbox focused={isActive(EPS)}
border border
height="100%" borderColor={border(EPS)}
borderColor={ backgroundColor={theme.background}
nav.activeDepth() == MyShowsPaneType.EPISODES >
? theme.accent <Show
: theme.border when={episodes().length > 0}
} fallback={
focused={nav.activeDepth() == MyShowsPaneType.EPISODES} <box padding={1}>
> <text fg={muted()}>No episodes. :refresh</text>
<For each={episodes()}> </box>
{(episode, index) => ( }
<box >
flexDirection="column" <For each={episodes()}>
gap={0} {(ep, index) => (
paddingLeft={1} <box
paddingRight={1} flexDirection="column"
backgroundColor={ gap={0}
index() === episodeIndex() ? theme.primary : undefined paddingLeft={1}
} paddingRight={1}
onMouseDown={() => setEpisodeIndex(index())} backgroundColor={focusBg(index(), EPS)}
> onMouseDown={() => {
<box flexDirection="row" gap={1}> nav.setActivePane(EPS);
<text nav.setFocusedIndex(EPS, index());
fg={ }}
index() === episodeIndex() >
? theme.surface <box flexDirection="row" gap={1}>
: theme.text <text fg={focusFg(index(), EPS)}>
} {index() === nav.focusedIndex(EPS) ? "" : " "}
> </text>
{index() === episodeIndex() ? ">" : " "} <text fg={focusFg(index(), EPS)}>
</text> {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
<text {ep.title}
fg={ </text>
index() === episodeIndex() </box>
? theme.surface <box flexDirection="row" gap={2} paddingLeft={2}>
: theme.text <text
} fg={
> index() === nav.focusedIndex(EPS)
{episode.episodeNumber ? theme.surface
? `#${episode.episodeNumber} ` : theme.info
: ""} }
{episode.title} >
</text> {formatDate(ep.pubDate)}
</box> </text>
<box flexDirection="row" gap={2} paddingLeft={2}> <text
<text fg={
fg={index() === episodeIndex() ? undefined : theme.info} index() === nav.focusedIndex(EPS)
> ? theme.surface
{formatDate(episode.pubDate)} : muted()
</text> }
<text fg={theme.muted}> >
{formatDuration(episode.duration)} {formatDuration(ep.duration)}
</text> </text>
<Show when={downloadLabel(episode.id)}> <Show when={nav.isSelected(ep.id)}>
<text fg={downloadColor(episode.id)}> <text fg={theme.warning}></text>
{downloadLabel(episode.id)} </Show>
</text> <Show when={downloadLabel(ep.id)}>
</Show> <text fg={downloadColor(ep.id)}>
</box> {downloadLabel(ep.id)}
</box> </text>
)} </Show>
</For> </box>
<Show when={feedStore.isLoadingMore()}> </box>
<box paddingLeft={2} paddingTop={1}> )}
<LoadingIndicator /> </For>
</box> <Show when={feedStore.isLoadingMore()}>
</Show> <box paddingLeft={2} paddingTop={1}>
<Show <LoadingIndicator />
when={ </box>
!feedStore.isLoadingMore() && </Show>
selectedShow() && </Show>
feedStore.hasMoreEpisodes(selectedShow()!.id) </scrollbox>
} </box>
>
<box paddingLeft={2} paddingTop={1}> {/* ── pane 2: preview ───────────────────────────────────────────────────── */}
<text fg={theme.muted}>Scroll down for more episodes</text> <box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
</box> <box height={1} paddingLeft={1} backgroundColor={theme.background}>
</Show> <text fg={theme.textSecondary}>Preview</text>
</scrollbox> </box>
</Show> <scrollbox
</Show> height="100%"
</box> focused={isActive(PREV)}
</box> border
); borderColor={border(PREV)}
backgroundColor={theme.background}
>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ??
"No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
} }

View File

@@ -1,112 +1,122 @@
/**
* PlayerPage — single-pane audio now-playing view.
*
* Audio transport (play/pause, next/prev, seek) is handled globally by the
* Shell router (P/N/B/</>). This page renders a single rich pane showing the
* current episode, waveform, and playback controls. Panes/swipe do nothing
* (PaneCount=1).
*/
import { Show } from "solid-js";
import { PlaybackControls } from "./PlaybackControls"; import { PlaybackControls } from "./PlaybackControls";
import { RealtimeWaveform } from "./RealtimeWaveform"; import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import { useNavigation } from "@/context/NavigationContext";
import { useKeybinds } from "@/context/KeybindContext";
import { useKeyboard } from "@opentui/solid";
import { onMount } from "solid-js";
enum PlayerPaneType {
PLAYER = 1,
}
export const PlayerPaneCount = 1; export const PlayerPaneCount = 1;
export function PlayerPage() { export function PlayerPage() {
const audio = useAudio(); const audio = useAudio();
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const nav = useNavigation();
const muted = () => theme.muted || theme.text;
const keybind = useKeybinds(); // Single pane — always active.
const isActive = () => true;
const border = () => theme.accent;
onMount(() => { const progressPercent = () => {
useKeyboard( const d = audio.duration();
(keyEvent: any) => { if (d <= 0) return 0;
const isInverting = keybind.isInverting(keyEvent); return Math.min(100, Math.round((audio.position() / d) * 100));
};
if (keybind.match("audio-toggle", keyEvent)) { const formatTime = (seconds: number) => {
audio.togglePlayback(); const m = Math.floor(seconds / 60);
return; const s = Math.floor(seconds % 60);
} return `${m}:${String(s).padStart(2, "0")}`;
};
if (keybind.match("audio-seek-forward", keyEvent)) { return (
audio.seek(audio.currentEpisode()?.duration ?? 0); <box flexDirection="column" width="100%" height="100%">
return; {/* ── pane 0: now playing ─────────────────────────────────────────── */}
} <box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Player</text>
</box>
<scrollbox
height="100%"
focused={isActive()}
border
borderColor={border()}
backgroundColor={theme.background}
>
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={muted()}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box>
if (keybind.match("audio-seek-backward", keyEvent)) { <Show when={audio.error()}>
audio.seek(0); {(err) => <text fg={theme.error}>{err()}</text>}
return; </Show>
}
},
{ release: false },
);
});
const progressPercent = () => { <Show
const d = audio.duration(); when={audio.currentEpisode()}
if (d <= 0) return 0; fallback={
return Math.min(100, Math.round((audio.position() / d) * 100)); <box padding={1}>
}; <text fg={muted()}>No episode loaded.</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ??
"No description available."}
</text>
const formatTime = (seconds: number) => { <RealtimeWaveform
const m = Math.floor(seconds / 60); visualizerConfig={(() => {
const s = Math.floor(seconds % 60); const viz = useAppStore().state().settings.visualizer;
return `${m}:${String(s).padStart(2, "0")}`; return {
}; bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
)}
</Show>
return ( <PlaybackControls
<box flexDirection="column" gap={1} width="100%"> isPlaying={audio.isPlaying()}
<box flexDirection="row" justifyContent="space-between"> volume={audio.volume()}
<text fg={theme.text}> speed={audio.speed()}
<strong>Now Playing</strong> backendName={audio.backendName()}
</text> hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
<text fg={theme.muted}> onToggle={audio.togglePlayback}
{formatTime(audio.position())} / {formatTime(audio.duration())} ( onPrev={() => audio.seek(0)}
{progressPercent()}%) onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
</text> onSpeedChange={(s: number) => audio.setSpeed(s)}
</box> onVolumeChange={(v: number) => audio.setVolume(v)}
/>
{audio.error() && <text fg={theme.error}>{audio.error()}</text>} <box height={1} />
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
<box </box>
border </scrollbox>
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border} </box>
padding={1} );
flexDirection="column"
gap={1}
>
<text fg={theme.text}>
<strong>{audio.currentEpisode()?.title}</strong>
</text>
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
onToggle={audio.togglePlayback}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
</box>
);
} }

View File

@@ -1,210 +1,395 @@
/** /**
* SearchPage component - Main search interface for PodTUI * 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
*
* 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
* 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.
*/ */
import { createSignal, createEffect, Show, onMount } from "solid-js"; import {
import { useKeyboard } from "@opentui/solid"; createSignal,
createMemo,
createEffect,
For,
Show,
onMount,
onCleanup,
} from "solid-js";
import { useSearchStore } from "@/stores/search"; import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults"; import { format } from "date-fns";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
import { MyShowsPage } from "../MyShows/MyShowsPage";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import {
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source";
import { PANE_RATIO } from "@/utils/navigation";
enum SearchPaneType {
INPUT = 1,
RESULTS = 2,
HISTORY = 3,
}
export const SearchPaneCount = 3; export const SearchPaneCount = 3;
export function SearchPage() { function SearchPage() {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0); const { theme } = useTheme();
const [historyIndex, setHistoryIndex] = createSignal(0); const muted = () => theme.muted || theme.text;
const { theme } = useTheme(); const nav = useNavigation();
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => { const INPUT = PaneSlot.PARENT; // 0
useKeyboard( const RESULTS = PaneSlot.CURRENT; // 1
(keyEvent: any) => { const DETAIL = PaneSlot.PREVIEW; // 2
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) { const results = () => searchStore.results();
const results = searchStore.results();
if (results.length > 0 && resultIndex() < results.length) {
setResultIndex(resultIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx // The focused result tracks pane 1's focused row.
if (nav.activeDepth() !== SearchPaneType.RESULTS) return; const focusedResult = createMemo(() => {
const list = results();
if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1);
return list[idx];
});
const results = searchStore.results(); // Register a resolver so visual-mode range selection grows by result id.
if (results.length === 0) return; onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => results()[i]?.podcast.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => results()[i]?.podcast.id,
);
});
onCleanup(() => unsub());
});
if (isDown && !isInverting()) { // Keep results focus in range after searches complete.
setResultIndex((i) => (i + 1) % results.length); const ensureFocus = () => {
} else if (isUp && isInverting()) { const list = results();
setResultIndex((i) => (i - 1 + results.length) % results.length); if (list.length === 0) return;
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { const cur = nav.focusedIndex(RESULTS);
setResultIndex((i) => (i + 1) % results.length); if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) { };
setResultIndex((i) => (i - 1 + results.length) % results.length); onMount(ensureFocus);
}
},
{ release: false },
);
});
const handleSearch = async () => { // ── input pane: set inputFocused so Shell router yields keys to <input> ─────
const query = inputValue().trim(); createEffect(() => {
if (query) { const isInputPane = nav.activePane() === INPUT;
await searchStore.search(query); nav.setInputFocused(isInputPane);
if (searchStore.results().length > 0) { });
//setFocusArea("results"); //TODO: move level onMount(() => {
setResultIndex(0); onCleanup(() => nav.setInputFocused(false));
} });
}
};
const handleHistorySelect = async (query: string) => { // ── helpers ─────────────────────────────────────────────────────────────────
setInputValue(query); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
};
const handleResultSelect = (result: SearchResult) => { const handleSubmit = () => {
//props.onSubscribe?.(result); const query = inputValue().trim();
searchStore.markSubscribed(result.podcast.id); if (!query) return;
}; searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
};
return ( const handleHistorySelect = (query: string) => {
<box flexDirection="column" height="100%" gap={1} width="100%"> setInputValue(query);
{/* Search Header */} searchStore.search(query).catch(() => {});
<box flexDirection="column" gap={1}> nav.setFocusedIndex(RESULTS, 0);
<text fg={theme.text}> nav.setActivePane(RESULTS);
<strong>Search Podcasts</strong> };
</text>
{/* Search Input */} const handleSubscribe = (result: SearchResult) => {
<box flexDirection="row" gap={1} alignItems="center"> searchStore.markSubscribed(result.podcast.id);
<text fg="gray">Search:</text> };
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={nav.activeDepth() === SearchPaneType.INPUT}
width={50}
/>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={handleSearch}
>
<text fg={theme.primary}>[Enter] Search</text>
</box>
</box>
{/* Status */} // ── nav.action handler ──────────────────────────────────────────────────────
<Show when={searchStore.isSearching()}> const PAGE_ACTIONS: Partial<
<text fg={theme.warning}>Searching...</text> Record<KeybindActionName, (pane: PaneId) => void>
</Show> > = {
<Show when={searchStore.error()}> "move-down": (p) => step(p, 1),
<text fg={theme.error}>{searchStore.error()}</text> "move-up": (p) => step(p, -1),
</Show> "jump-down": (p) => step(p, 5),
</box> "jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === RESULTS || p === DETAIL) {
const result = focusedResult();
if (result) handleSubscribe(result);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const result = focusedResult();
if (result) nav.toggleSelected(result.podcast.id);
}
},
search: () => {
nav.setActivePane(INPUT);
},
refresh: () => {
if (inputValue().trim()) {
searchStore.search(inputValue().trim()).catch(() => {});
}
},
};
{/* Main Content - Results or History */} function len(pane: PaneId): number {
<box flexDirection="row" height="100%" gap={2}> if (pane === RESULTS) return results().length;
{/* Results Panel */} return 0;
<box }
flexDirection="column" function step(pane: PaneId, delta: number) {
flexGrow={1} nav.move(delta, len(pane));
border }
borderColor={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.accent
: theme.border
}
>
<box padding={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.primary
: theme.muted
}
>
Results ({searchStore.results().length})
</text>
</box>
<Show
when={searchStore.results().length > 0}
fallback={
<box padding={2}>
<text fg={theme.muted}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<SearchResults
results={searchStore.results()}
selectedIndex={resultIndex()}
focused={nav.activeDepth() === SearchPaneType.RESULTS}
onSelect={handleResultSelect}
onChange={setResultIndex}
isSearching={searchStore.isSearching()}
error={searchStore.error()}
/>
</Show>
</box>
{/* History Sidebar */} const onAction = (data: {
<box width={30} border borderColor={theme.border}> action: KeybindActionName;
<box padding={1} flexDirection="column"> pane: PaneId;
<box paddingBottom={1}> mode: NavMode;
<text }) => {
fg={ ensureFocus();
nav.activeDepth() === SearchPaneType.HISTORY const handler = PAGE_ACTIONS[data.action];
? theme.primary if (handler) handler(data.pane);
: theme.muted };
}
> onMount(() => {
History on("nav.action", onAction);
</text> onCleanup(() => off("nav.action", onAction));
</box> });
<SearchHistory
history={searchStore.history()} // ── render ──────────────────────────────────────────────────────────────────
selectedIndex={historyIndex()} const isActive = (p: PaneId) => nav.activePane() === p;
focused={nav.activeDepth() === SearchPaneType.HISTORY} const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory} const focusBg = (i: number, pane: PaneId) =>
onClear={searchStore.clearHistory} i === nav.focusedIndex(pane) && isActive(pane)
onChange={setHistoryIndex} ? theme.primary
/> : i === nav.focusedIndex(pane)
</box> ? theme.border
</box> : undefined;
</box> const focusFg = (i: number, pane: PaneId) =>
</box> i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: query input ──────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Search</text>
</box>
<scrollbox
height="100%"
focused={false}
border
borderColor={border(INPUT)}
backgroundColor={theme.background}
>
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text>
<input
value={inputValue()}
onInput={setInputValue}
onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..."
focused={isActive(INPUT)}
width={28}
/>
</box>
<text fg={muted()}>Enter to search · h/l: panes</text>
<Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>Recent</text>
<Show
when={searchStore.history().length > 0}
fallback={<text fg={muted()}>No recent searches</text>}
>
<For each={searchStore.history().slice(0, 12)}>
{(query) => (
<box
flexDirection="row"
paddingLeft={1}
onMouseDown={() => handleHistorySelect(query)}
>
<text fg={muted()}>
{">"} {query}
</text>
</box>
)}
</For>
</Show>
</box>
</scrollbox>
</box>
{/* ── pane 1: results ──────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Results · {results().length}</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show
when={results().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<For each={results()}>
{(result, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>
{result.podcast.title}
</text>
<Show when={result.podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={result.podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 2: detail ───────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Detail</text>
</box>
<scrollbox
height="100%"
focused={isActive(DETAIL)}
border
borderColor={border(DETAIL)}
backgroundColor={theme.background}
>
<Show
when={focusedResult()}
fallback={
<box padding={1}>
<text fg={muted()}>No result focused</text>
</box>
}
>
{(result) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<Show when={result().podcast.author}>
<text fg={muted()}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400) ??
"No description available."}
{(result().podcast.description?.length ?? 0) > 400
? "…"
: ""}
</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
<text fg={muted()}>
Updated: {formatDate(result().podcast.lastUpdated)}
</text>
<Show when={result().sourceName}>
<text fg={muted()}>Source: {result().sourceName}</text>
</Show>
<box height={1} />
<Show when={!result().podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
} }
export { SearchPage };

View File

@@ -1,119 +1,185 @@
import { createSignal, For, onMount } from "solid-js"; /**
import { useKeyboard } from "@opentui/solid"; * SettingsPage — yazi-style 2-pane view.
*
* pane 0 (parent) — section list (Sync, Sources, Preferences, ...)
* pane 1 (current) — active panel for the focused section
*
* Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list.
* The panel (pane 1) reactively shows the focused section's content.
* Audio transport and tab/pane swipes are handled by the Shell router.
*/
import { For, Show, onMount, onCleanup } from "solid-js";
import { SourceManager } from "./SourceManager"; import { SourceManager } from "./SourceManager";
import { useTheme } from "@/context/ThemeContext";
import { PreferencesPanel } from "./PreferencesPanel"; import { PreferencesPanel } from "./PreferencesPanel";
import { SyncPanel } from "./SyncPanel"; import { SyncPanel } from "./SyncPanel";
import { VisualizerSettings } from "./VisualizerSettings"; import { VisualizerSettings } from "./VisualizerSettings";
import { useNavigation } from "@/context/NavigationContext"; import { useTheme } from "@/context/ThemeContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum SettingsPaneType { export const SettingsPaneCount = 2;
SYNC = 1,
SOURCES = 2,
PREFERENCES = 3,
VISUALIZER = 4,
ACCOUNT = 5,
}
export const SettingsPaneCount = 5;
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [ const SECTIONS = [
{ id: SettingsPaneType.SYNC, label: "Sync" }, { id: 0, label: "Sync" },
{ id: SettingsPaneType.SOURCES, label: "Sources" }, { id: 1, label: "Sources" },
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" }, { id: 2, label: "Preferences" },
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" }, { id: 3, label: "Visualizer" },
{ id: SettingsPaneType.ACCOUNT, label: "Account" }, { id: 4, label: "Account" },
]; ] as const;
export function SettingsPage() { export function SettingsPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const muted = () => theme.muted || theme.text;
const keybind = useKeybinds(); const nav = useNavigation();
// Helper function to check if a depth is active const SECTIONS_PANE = PaneSlot.PARENT; // 0
const isActive = (depth: SettingsPaneType): boolean => { const PANEL = PaneSlot.CURRENT; // 1
return nav.activeDepth() === depth;
};
// Helper function to get the current depth as a number // The focused section tracks pane 0's focused index.
const currentDepth = () => nav.activeDepth() as number; const focusedSection = () => {
const idx = nav.focusedIndex(SECTIONS_PANE);
return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0];
};
onMount(() => { // Register a resolver so visual-mode range selection grows by section id.
useKeyboard( onMount(() => {
(keyEvent: any) => { nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) =>
const isDown = keybind.match("down", keyEvent); SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
const isUp = keybind.match("up", keyEvent); );
const isCycle = keybind.match("cycle", keyEvent); });
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
// don't handle pane navigation here - unified in App.tsx // ── nav.action handler ──────────────────────────────────────────────────────
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return; const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === SECTIONS_PANE) {
nav.swipe(1, SettingsPaneCount);
}
},
};
if (isDown && !isInverting()) { function len(pane: PaneId): number {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1); if (pane === SECTIONS_PANE) return SECTIONS.length;
} else if (isUp && isInverting()) { return 0;
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1); }
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { function step(pane: PaneId, delta: number) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1); nav.move(delta, len(pane));
} else if ((isCycle && isInverting()) || (isUp && isInverting())) { }
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
}
},
{ release: false },
);
});
return ( const onAction = (data: {
<box flexDirection="column" gap={1} height="100%" width="100%"> action: KeybindActionName;
<box flexDirection="row" gap={1}> pane: PaneId;
<For each={SECTIONS}> mode: NavMode;
{(section, index) => ( }) => {
<box const handler = PAGE_ACTIONS[data.action];
border if (handler) handler(data.pane);
borderColor={theme.border} };
padding={0}
backgroundColor={
currentDepth() === section.id ? theme.primary : undefined
}
onMouseDown={() => nav.setActiveDepth(section.id)}
>
<text
fg={
currentDepth() === section.id ? theme.text : theme.textMuted
}
>
[{index() + 1}] {section.label}
</text>
</box>
)}
</For>
</box>
<box onMount(() => {
border on("nav.action", onAction);
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border} onCleanup(() => off("nav.action", onAction));
flexGrow={1} });
padding={1}
flexDirection="column" // ── render ──────────────────────────────────────────────────────────────────
gap={1} const isActive = (p: PaneId) => nav.activePane() === p;
> const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
{isActive(SettingsPaneType.SYNC) && <SyncPanel />}
{isActive(SettingsPaneType.SOURCES) && ( const focusBg = (i: number, pane: PaneId) =>
<SourceManager focused /> i === nav.focusedIndex(pane) && isActive(pane)
)} ? theme.primary
{isActive(SettingsPaneType.PREFERENCES) && ( : i === nav.focusedIndex(pane)
<PreferencesPanel /> ? theme.border
)} : undefined;
{isActive(SettingsPaneType.VISUALIZER) && ( const focusFg = (i: number, pane: PaneId) =>
<VisualizerSettings /> i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
)}
{isActive(SettingsPaneType.ACCOUNT) && ( return (
<box flexDirection="column" gap={1}> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
<text fg={theme.textMuted}>Account</text> {/* ── pane 0: sections ─────────────────────────────────────────────────── */}
</box> <box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
)} <box height={1} paddingLeft={1} backgroundColor={theme.background}>
</box> <text fg={theme.textSecondary}>Settings</text>
</box> </box>
); <scrollbox
height="100%"
focused={isActive(SECTIONS_PANE)}
border
borderColor={border(SECTIONS_PANE)}
backgroundColor={theme.background}
>
<For each={SECTIONS}>
{(section, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), SECTIONS_PANE)}
onMouseDown={() => {
nav.setActivePane(SECTIONS_PANE);
nav.setFocusedIndex(SECTIONS_PANE, index());
}}
>
<text fg={focusFg(index(), SECTIONS_PANE)}>
{index() === nav.focusedIndex(SECTIONS_PANE) ? "" : " "}
</text>
<text fg={focusFg(index(), SECTIONS_PANE)}>
{section.label}
</text>
</box>
)}
</For>
</scrollbox>
</box>
{/* ── pane 1: panel ─────────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>{focusedSection().label}</text>
</box>
<scrollbox
height="100%"
focused={isActive(PANEL)}
border
borderColor={border(PANEL)}
backgroundColor={theme.background}
>
<Show when={focusedSection().id === 0}>
<SyncPanel />
</Show>
<Show when={focusedSection().id === 1}>
<SourceManager focused />
</Show>
<Show when={focusedSection().id === 2}>
<PreferencesPanel />
</Show>
<Show when={focusedSection().id === 3}>
<VisualizerSettings />
</Show>
<Show when={focusedSection().id === 4}>
<box padding={1} flexDirection="column" gap={1}>
<text fg={muted()}>Account settings (not yet implemented)</text>
</box>
</Show>
</scrollbox>
</box>
</box>
);
} }

View File

@@ -3,185 +3,201 @@
* Manages search state, history, and results * Manages search state, history, and results
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search" import { searchPodcasts } from "../utils/search";
import { useFeedStore } from "./feed" import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source" import type { SearchResult } from "../types/source";
const STORAGE_KEY = "podtui_search_history" const STORAGE_KEY = "podtui_search_history";
const MAX_HISTORY = 20 const MAX_HISTORY = 20;
export interface SearchState { export interface SearchState {
query: string query: string;
isSearching: boolean isSearching: boolean;
results: SearchResult[] results: SearchResult[];
error: string | null error: string | null;
} }
const CACHE_TTL = 1000 * 60 * 5 const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */ /** Load search history from localStorage */
function loadHistory(): string[] { function loadHistory(): string[] {
if (typeof localStorage === "undefined") return [] if (typeof localStorage === "undefined") return [];
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [] return stored ? JSON.parse(stored) : [];
} catch { } catch {
return [] return [];
} }
} }
/** Save search history to localStorage */ /** Save search history to localStorage */
function saveHistory(history: string[]): void { function saveHistory(history: string[]): void {
if (typeof localStorage === "undefined") return if (typeof localStorage === "undefined") return;
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history)) localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch { } catch {
// Ignore errors // Ignore errors
} }
} }
/** Create search store */ /** Create search store */
export function createSearchStore() { export function createSearchStore() {
const feedStore = useFeedStore() const feedStore = useFeedStore();
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false) const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]) const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory()) const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]) const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => { const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds() const feeds = feedStore.feeds();
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl)) const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id)) const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
return items.map((item) => ({ return items.map((item) => ({
...item, ...item,
podcast: { podcast: {
...item.podcast, ...item.podcast,
isSubscribed: isSubscribed:
item.podcast.isSubscribed || item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) || subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id), subscribedIds.has(item.podcast.id),
}, },
})) }));
} };
/** Perform search (multi-source implementation) */ /** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => { const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim() const q = searchQuery.trim();
if (!q) { if (!q) {
setResults([]) setResults([]);
return return;
} }
setQuery(q) setQuery(q);
setIsSearching(true) setIsSearching(true);
setError(null) setError(null);
// Add to history // Add to history
addToHistory(q) addToHistory(q);
try { try {
const sources = feedStore.sources() const sources = feedStore.sources();
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id) const enabledSourceIds = sources
const sourceIds = selectedSources().length > 0 .filter((s) => s.enabled)
? selectedSources() .map((s) => s.id);
: enabledSourceIds const sourceIds =
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
const searchResults = await searchPodcasts(q, sourceIds, sources, { // Empty query guard already returned above; if there are no enabled
cacheTtl: CACHE_TTL, // sources, tell the user instead of returning an empty list that looks
}) // like a network outage.
if (enabledSourceIds.length === 0) {
setError(
"No search sources are enabled. Enable one in Settings → Sources.",
);
setResults([]);
return;
}
setResults(applySubscribedStatus(searchResults)) const searchResults = await searchPodcasts(q, sourceIds, sources, {
} catch (e) { cacheTtl: CACHE_TTL,
setError("Search failed. Please try again.") });
setResults([])
} finally {
setIsSearching(false)
}
}
/** Add query to history */ setResults(applySubscribedStatus(searchResults));
const addToHistory = (q: string) => { } catch (e) {
setHistory((prev) => { setError(
// Remove duplicates and add to front e instanceof Error && e.message
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()) ? e.message
const updated = [q, ...filtered].slice(0, MAX_HISTORY) : "Search failed. Please try again.",
saveHistory(updated) );
return updated setResults([]);
}) } finally {
} setIsSearching(false);
}
};
/** Clear search history */ /** Add query to history */
const clearHistory = () => { const addToHistory = (q: string) => {
setHistory([]) setHistory((prev) => {
saveHistory([]) // Remove duplicates and add to front
} const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
/** Remove single history item */ /** Clear search history */
const removeFromHistory = (q: string) => { const clearHistory = () => {
setHistory((prev) => { setHistory([]);
const updated = prev.filter((h) => h !== q) saveHistory([]);
saveHistory(updated) };
return updated
})
}
/** Clear results */ /** Remove single history item */
const clearResults = () => { const removeFromHistory = (q: string) => {
setResults([]) setHistory((prev) => {
setQuery("") const updated = prev.filter((h) => h !== q);
setError(null) saveHistory(updated);
} return updated;
});
};
/** Mark a podcast as subscribed in results */ /** Clear results */
const markSubscribed = (podcastId: string, feedUrl?: string) => { const clearResults = () => {
setResults((prev) => setResults([]);
prev.map((result) => { setQuery("");
const matchesId = result.podcast.id === podcastId setError(null);
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false };
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
}
}
return result
})
)
}
return { /** Mark a podcast as subscribed in results */
// State const markSubscribed = (podcastId: string, feedUrl?: string) => {
query, setResults((prev) =>
isSearching, prev.map((result) => {
results, const matchesId = result.podcast.id === podcastId;
error, const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
history, if (matchesId || matchesUrl) {
selectedSources, return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
};
}
return result;
}),
);
};
// Actions return {
search, // State
setQuery, query,
clearResults, isSearching,
clearHistory, results,
removeFromHistory, error,
setSelectedSources, history,
markSubscribed, selectedSources,
}
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
};
} }
/** Singleton search store */ /** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() { export function useSearchStore() {
if (!searchStoreInstance) { if (!searchStoreInstance) {
searchStoreInstance = createSearchStore() searchStoreInstance = createSearchStore();
} }
return searchStoreInstance return searchStoreInstance;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -20,128 +20,148 @@
* ``` * ```
*/ */
type EventHandler<T = unknown> = (data: T) => void type EventHandler<T = unknown> = (data: T) => void;
// Export EventHandler type for external use // Export EventHandler type for external use
export type { EventHandler } export type { EventHandler };
interface EventBusInstance { interface EventBusInstance {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
once<T = unknown>(event: string, handler: EventHandler<T>): () => void once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
off<T = unknown>(event: string, handler: EventHandler<T>): void off<T = unknown>(event: string, handler: EventHandler<T>): void;
emit<T = unknown>(event: string, data: T): void emit<T = unknown>(event: string, data: T): void;
clear(): void clear(): void;
} }
function createEventBus(): EventBusInstance { function createEventBus(): EventBusInstance {
const handlers = new Map<string, Set<EventHandler>>() const handlers = new Map<string, Set<EventHandler>>();
return { return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void { on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) { if (!handlers.has(event)) {
handlers.set(event, new Set()) handlers.set(event, new Set());
} }
handlers.get(event)!.add(handler as EventHandler) handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function // Return unsubscribe function
return () => { return () => {
this.off(event, handler) this.off(event, handler);
} };
}, },
once<T = unknown>(event: string, handler: EventHandler<T>): () => void { once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => { const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler) this.off(event, wrappedHandler);
handler(data) handler(data);
} };
return this.on(event, wrappedHandler) return this.on(event, wrappedHandler);
}, },
off<T = unknown>(event: string, handler: EventHandler<T>): void { off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event) const eventHandlers = handlers.get(event);
if (eventHandlers) { if (eventHandlers) {
eventHandlers.delete(handler as EventHandler) eventHandlers.delete(handler as EventHandler);
if (eventHandlers.size === 0) { if (eventHandlers.size === 0) {
handlers.delete(event) handlers.delete(event);
} }
} }
}, },
emit<T = unknown>(event: string, data: T): void { emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event) const eventHandlers = handlers.get(event);
if (eventHandlers) { if (eventHandlers) {
for (const handler of eventHandlers) { for (const handler of eventHandlers) {
try { try {
handler(data) handler(data);
} catch (error) { } catch (error) {
console.error(`Error in event handler for "${event}":`, error) console.error(`Error in event handler for "${event}":`, error);
} }
} }
} }
}, },
clear(): void { clear(): void {
handlers.clear() handlers.clear();
}, },
} };
} }
// Singleton event bus instance // Singleton event bus instance
export const EventBus = createEventBus() export const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation";
import type { PaneId, NavMode } from "@/context/NavigationContext";
// Common event types for the application // Common event types for the application
export type AppEvents = { export type AppEvents = {
"theme.changed": { theme: string; mode: "dark" | "light" } "theme.changed": { theme: string; mode: "dark" | "light" };
"theme.mode.changed": { mode: "dark" | "light" } "theme.mode.changed": { mode: "dark" | "light" };
"theme.reload": {} "theme.reload": {};
"navigation.tab.changed": { tab: string; previousTab?: string } "navigation.tab.changed": { tab: string; previousTab?: string };
"navigation.layer.changed": { depth: number; previousDepth: number } "navigation.layer.changed": { depth: number; previousDepth: number };
"feed.subscribed": { feedId: string; feedUrl: string } "feed.subscribed": { feedId: string; feedUrl: string };
"feed.unsubscribed": { feedId: string } "feed.unsubscribed": { feedId: string };
"player.play": { episodeId: string } "player.play": { episodeId: string };
"player.pause": { episodeId: string } "player.pause": { episodeId: string };
"player.stop": {} "player.stop": {};
"auth.login": { userId: string } "auth.login": { userId: string };
"auth.logout": {} "auth.logout": {};
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number } "toast.show": {
"dialog.open": { dialogId: string } message: string;
"dialog.close": { dialogId?: string } variant: "info" | "success" | "warning" | "error";
"command.execute": { command: string; args?: unknown } title?: string;
"clipboard.copied": { text: string } duration?: number;
"selection.start": { x: number; y: number } };
"selection.end": { text: string } "dialog.open": { dialogId: string };
"dialog.close": { dialogId?: string };
"command.execute": { command: string; args?: unknown };
// Yazi-style unified router → active page dispatch. The Shell router
// emits these; each page subscribes to the subset it implements.
"nav.action": {
action: KeybindActionName;
tab: TABS;
pane: PaneId;
mode: NavMode;
};
"clipboard.copied": { text: string };
"selection.start": { x: number; y: number };
"selection.end": { text: string };
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio) // Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
"media.toggle": {} "media.toggle": {};
"media.volumeUp": {} "media.volumeUp": {};
"media.volumeDown": {} "media.volumeDown": {};
"media.seekForward": {} "media.seekForward": {};
"media.seekBackward": {} "media.seekBackward": {};
"media.speedCycle": {} "media.speedCycle": {};
} };
// Type-safe emit and on functions // Type-safe emit and on functions
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void { export function emit<K extends keyof AppEvents>(
EventBus.emit(event, data) event: K,
data: AppEvents[K],
): void {
EventBus.emit(event, data);
} }
export function on<K extends keyof AppEvents>( export function on<K extends keyof AppEvents>(
event: K, event: K,
handler: EventHandler<AppEvents[K]> handler: EventHandler<AppEvents[K]>,
): () => void { ): () => void {
return EventBus.on(event, handler) return EventBus.on(event, handler);
} }
export function once<K extends keyof AppEvents>( export function once<K extends keyof AppEvents>(
event: K, event: K,
handler: EventHandler<AppEvents[K]> handler: EventHandler<AppEvents[K]>,
): () => void { ): () => void {
return EventBus.once(event, handler) return EventBus.once(event, handler);
} }
export function off<K extends keyof AppEvents>( export function off<K extends keyof AppEvents>(
event: K, event: K,
handler: EventHandler<AppEvents[K]> handler: EventHandler<AppEvents[K]>,
): void { ): void {
EventBus.off(event, handler) EventBus.off(event, handler);
} }

View File

@@ -1,90 +1,121 @@
/** /**
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME * Keybinds persistence via JSONC file in XDG_CONFIG_HOME
* *
* Handles copying keybind.jsonc from package to user config directory * Handles copying keybinds.jsonc from package to user config directory
* and loading/saving keybind configurations. * and loading/saving keybind configurations.
*/ */
import { copyFile, mkdir } from "fs/promises"; import { copyFile } from "fs/promises";
import path from "path"; import path from "path";
import { parseJSONC } from "./jsonc"; import { parseJSONC } from "./jsonc";
import { getConfigFilePath, ensureConfigDir } from "./config-dir"; import { getConfigFilePath, ensureConfigDir } from "./config-dir";
import type { KeybindsResolved } from "../context/KeybindContext"; import type { KeybindsResolved } from "../context/KeybindContext";
const KEYBINDS_SOURCE = path.join( const KEYBINDS_SOURCE = path.join(
process.cwd(), process.cwd(),
"src", "src",
"config", "config",
"keybind.jsonc", "keybinds.jsonc",
); );
const KEYBINDS_FILE = "keybinds.jsonc"; const KEYBINDS_FILE = "keybinds.jsonc";
/** Default keybinds from package */ /** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the
* app works before a user keybinds file is copied into place. */
const DEFAULT_KEYBINDS: KeybindsResolved = { const DEFAULT_KEYBINDS: KeybindsResolved = {
up: ["up", "k"], // movement
down: ["down", "j"], "move-down": ["j", "down"],
left: ["left", "h"], "move-up": ["k", "up"],
right: ["right", "l"], "page-down": ["ctrl-d"],
cycle: ["tab"], "page-up": ["ctrl-u"],
dive: ["return"], "full-down": ["ctrl-f"],
select: ["return"], "full-up": ["ctrl-b"],
out: ["esc"], "jump-down": ["J"],
inverseModifier: "shift", "jump-up": ["K"],
leader: ":", "goto-top": [["g", "g"]],
quit: ["<leader>q"], "goto-bottom": ["G"],
"audio-toggle": ["<leader>p"], // pane swipe
"audio-pause": [], "swipe-prev": ["h", "left"],
"audio-play": [], "swipe-next": ["l", "right"],
"audio-next": ["<leader>n"], // open / select
"audio-prev": ["<leader>l"], open: ["return", "enter"],
"audio-seek-forward": ["<leader>sf"], "open-interactive": ["shift-return"],
"audio-seek-backward": ["<leader>sb"], "toggle-select": ["space"],
"visual-mode": ["v"],
"toggle-all": ["ctrl-a"],
"invert-all": ["ctrl-r"],
escape: ["escape", "ctrl-["],
// tabs
"tab-prev": ["["],
"tab-next": ["]"],
"tab-goto-1": ["1"],
"tab-goto-2": ["2"],
"tab-goto-3": ["3"],
"tab-goto-4": ["4"],
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// command / help / quit
command: [":"],
quit: ["q", "ctrl-c"],
help: ["~", "f1"],
// list ops
search: ["s"],
filter: ["f"],
sort: [","],
"toggle-hidden": ["."],
refresh: ["r"],
// audio transport (preserved; shifted single keys, no collisions)
"audio-toggle": ["P"],
"audio-next": ["N"],
"audio-prev": ["B"],
"audio-seek-forward": ["shift-."],
"audio-seek-backward": ["shift-,"],
}; };
/** Copy keybind.jsonc to user config directory on first run */ /** Copy keybinds.jsonc to user config directory on first run */
export async function copyKeybindsIfNeeded(): Promise<void> { export async function copyKeybindsIfNeeded(): Promise<void> {
try { try {
const targetPath = getConfigFilePath(KEYBINDS_FILE); const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists // Check if file already exists
const targetFile = Bun.file(targetPath); const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return; if (await targetFile.exists()) return;
await ensureConfigDir(); await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath); await copyFile(KEYBINDS_SOURCE, targetPath);
} catch { } catch {
// Silently ignore errors // Silently ignore errors
} }
} }
/** Load keybinds from JSONC file */ /** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> { export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try { try {
const filePath = getConfigFilePath(KEYBINDS_FILE); const filePath = getConfigFilePath(KEYBINDS_FILE);
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (!(await file.exists())) return DEFAULT_KEYBINDS; if (!(await file.exists())) return DEFAULT_KEYBINDS;
const raw = await file.text(); const raw = await file.text();
const parsed = parseJSONC(raw); const parsed = parseJSONC(raw);
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS; if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved; // Merge so partial user configs inherit defaults for missing keys.
} catch { return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
return DEFAULT_KEYBINDS; } catch {
} return DEFAULT_KEYBINDS;
}
} }
/** Save keybinds to JSONC file */ /** Save keybinds to JSONC file */
export async function saveKeybindsToFile( export async function saveKeybindsToFile(
keybinds: KeybindsResolved, keybinds: KeybindsResolved,
): Promise<void> { ): Promise<void> {
try { try {
await ensureConfigDir(); await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE); const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2)); await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }

View File

@@ -6,33 +6,55 @@ import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage"; import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
export enum DIRECTION { export enum DIRECTION {
Increment, Increment,
Decrement, Decrement,
} }
export enum TABS { export enum TABS {
FEED = 1, FEED = 1,
MYSHOWS = 2, MYSHOWS = 2,
DISCOVER = 3, DISCOVER = 3,
SEARCH = 4, SEARCH = 4,
PLAYER = 5, PLAYER = 5,
SETTINGS = 6, SETTINGS = 6,
} }
export const TabsCount = 6; export const TabsCount = 6;
export const LayerGraph = { export const LayerGraph = {
[TABS.FEED]: FeedPage, [TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage, [TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage, [TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage, [TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage, [TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage, [TABS.SETTINGS]: SettingsPage,
}; };
export const LayerDepths = { export const LayerDepths = {
[TABS.FEED]: FeedPaneCount, [TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount, [TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount, [TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount, [TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount, [TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount, [TABS.SETTINGS]: SettingsPaneCount,
};
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
// of terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
export const PANE_RATIO = {
parent: 1,
current: 4,
preview: 3,
} as const;
// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond
// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing
// the old NavigationContext top-level-init circular deadlock.
export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 3, // feeds | episodes | preview
[TABS.MYSHOWS]: 3, // shows | episodes | preview
[TABS.DISCOVER]: 3, // categories | results | detail
[TABS.SEARCH]: 3, // query | results | detail
[TABS.PLAYER]: 1, // single pane
[TABS.SETTINGS]: 2, // sections | panel
}; };

View File

@@ -1,156 +1,174 @@
import { searchSourceByType } from "./source-searcher" import { searchSourceByType } from "./source-searcher";
import type { PodcastSource, SearchResult } from "../types/source" import type { PodcastSource, SearchResult } from "../types/source";
import type { Episode } from "../types/episode" import type { Episode } from "../types/episode";
type SearchCacheEntry = { type SearchCacheEntry = {
timestamp: number timestamp: number;
results: SearchResult[] results: SearchResult[];
} };
type SearchOptions = { type SearchOptions = {
cacheTtl?: number cacheTtl?: number;
} };
const searchCache = new Map<string, SearchCacheEntry>() const searchCache = new Map<string, SearchCacheEntry>();
const rateLimitState = new Map<string, number[]>() const rateLimitState = new Map<string, number[]>();
const RATE_LIMIT_WINDOW_MS = 60000 const RATE_LIMIT_WINDOW_MS = 60000;
const RATE_LIMIT_MAX_CALLS = 20 const RATE_LIMIT_MAX_CALLS = 20;
const throttleSource = async (sourceId: string) => { const throttleSource = async (sourceId: string) => {
const now = Date.now() const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS const windowStart = now - RATE_LIMIT_WINDOW_MS;
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [] const timestamps =
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) { if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
if (waitMs > 0) { if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs)) await new Promise((resolve) => setTimeout(resolve, waitMs));
} }
} }
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [] const updated =
updated.push(Date.now()) rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
rateLimitState.set(sourceId, updated) updated.push(Date.now());
} rateLimitState.set(sourceId, updated);
};
const buildCacheKey = (query: string, sourceIds: string[]) => { const buildCacheKey = (query: string, sourceIds: string[]) => {
const keySources = [...sourceIds].sort().join(",") const keySources = [...sourceIds].sort().join(",");
return `${query.toLowerCase()}::${keySources}` return `${query.toLowerCase()}::${keySources}`;
} };
const isCacheValid = (entry: SearchCacheEntry, ttl: number) => const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
Date.now() - entry.timestamp < ttl Date.now() - entry.timestamp < ttl;
const dedupeResults = (results: SearchResult[]): SearchResult[] => { const dedupeResults = (results: SearchResult[]): SearchResult[] => {
const map = new Map<string, SearchResult>() const map = new Map<string, SearchResult>();
for (const result of results) { for (const result of results) {
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title const key =
const existing = map.get(key) result.podcast.feedUrl || result.podcast.id || result.podcast.title;
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) { const existing = map.get(key);
map.set(key, result) if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
} map.set(key, result);
} }
return Array.from(map.values()) }
} return Array.from(map.values());
};
export const searchPodcasts = async ( export const searchPodcasts = async (
query: string, query: string,
sourceIds: string[], sourceIds: string[],
sources: PodcastSource[], sources: PodcastSource[],
options: SearchOptions = {} options: SearchOptions = {},
): Promise<SearchResult[]> => { ): Promise<SearchResult[]> => {
const trimmed = query.trim() const trimmed = query.trim();
if (!trimmed) return [] if (!trimmed) return [];
const activeSources = sources.filter( const activeSources = sources.filter(
(source) => sourceIds.includes(source.id) && source.enabled (source) => sourceIds.includes(source.id) && source.enabled,
) );
if (activeSources.length === 0) return [] if (activeSources.length === 0) {
// No enabled sources — surface a clear cause instead of returning empty,
// which otherwise looks indistinguishable from a network failure.
if (sourceIds.length === 0) {
throw new Error("No search sources are enabled");
}
throw new Error("No enabled sources match the selected search sources");
}
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5 const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id)) const cacheKey = buildCacheKey(
const cached = searchCache.get(cacheKey) trimmed,
if (cached && isCacheValid(cached, cacheTtl)) { activeSources.map((s) => s.id),
return cached.results );
} const cached = searchCache.get(cacheKey);
if (cached && isCacheValid(cached, cacheTtl)) {
return cached.results;
}
const results: SearchResult[] = [] const results: SearchResult[] = [];
const errors: Error[] = [] const errors: Error[] = [];
await Promise.all( await Promise.all(
activeSources.map(async (source) => { activeSources.map(async (source) => {
try { try {
await throttleSource(source.id) await throttleSource(source.id);
const sourceResults = await searchSourceByType(trimmed, source) const sourceResults = await searchSourceByType(trimmed, source);
results.push(...sourceResults) results.push(...sourceResults);
} catch (error) { } catch (error) {
errors.push(error as Error) errors.push(error as Error);
} }
}) }),
) );
const deduped = dedupeResults(results) const deduped = dedupeResults(results);
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
if (sorted.length === 0 && errors.length > 0) { if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources") throw new Error("Search failed for all sources");
} }
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted }) searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
return sorted return sorted;
} };
type ItunesEpisodeResult = { type ItunesEpisodeResult = {
trackId?: number trackId?: number;
trackName?: string trackName?: string;
description?: string description?: string;
shortDescription?: string shortDescription?: string;
releaseDate?: string releaseDate?: string;
trackTimeMillis?: number trackTimeMillis?: number;
episodeUrl?: string episodeUrl?: string;
previewUrl?: string previewUrl?: string;
trackViewUrl?: string trackViewUrl?: string;
} };
type ItunesEpisodeResponse = { type ItunesEpisodeResponse = {
resultCount: number resultCount: number;
results: ItunesEpisodeResult[] results: ItunesEpisodeResult[];
} };
export const searchEpisodes = async ( export const searchEpisodes = async (
query: string, query: string,
feedId: string feedId: string,
): Promise<Episode[]> => { ): Promise<Episode[]> => {
const trimmed = query.trim() const trimmed = query.trim();
if (!trimmed) return [] if (!trimmed) return [];
const url = new URL("https://itunes.apple.com/search") const url = new URL("https://itunes.apple.com/search");
url.searchParams.set("term", trimmed) url.searchParams.set("term", trimmed);
url.searchParams.set("media", "podcast") url.searchParams.set("media", "podcast");
url.searchParams.set("entity", "podcastEpisode") url.searchParams.set("entity", "podcastEpisode");
url.searchParams.set("country", "US") url.searchParams.set("country", "US");
url.searchParams.set("lang", "en_us") url.searchParams.set("lang", "en_us");
const response = await fetch(url.toString()) const response = await fetch(url.toString());
if (!response.ok) return [] if (!response.ok) return [];
const data = (await response.json()) as ItunesEpisodeResponse const data = (await response.json()) as ItunesEpisodeResponse;
return data.results return data.results
.map((item) => { .map((item) => {
if (!item.trackName) return null if (!item.trackName) return null;
const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}` const id = item.trackId
const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || "" ? `episode-${item.trackId}`
: `episode-${item.trackName}`;
const audioUrl =
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
return { return {
id, id,
podcastId: feedId, podcastId: feedId,
title: item.trackName, title: item.trackName,
description: item.description || item.shortDescription || "", description: item.description || item.shortDescription || "",
audioUrl, audioUrl,
duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0, duration: item.trackTimeMillis
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(), ? Math.round(item.trackTimeMillis / 1000)
} : 0,
}) pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
.filter((item): item is Episode => Boolean(item)) };
} })
.filter((item): item is Episode => Boolean(item));
};

View File

@@ -0,0 +1,131 @@
import { parseStroke, parseBindingSpec } from "../src/context/KeybindContext";
const cfg = {
"move-down": parseBindingSpec(["j", "down"]),
"move-up": parseBindingSpec(["k", "up"]),
"goto-top": parseBindingSpec([["g", "g"]]),
"goto-bottom": parseBindingSpec(["G"]),
"toggle-select": parseBindingSpec(["space"]),
"swipe-prev": parseBindingSpec(["h", "left"]),
"audio-toggle": parseBindingSpec(["P"]),
"audio-seek-forward": parseBindingSpec(["shift-."]),
"audio-seek-backward": parseBindingSpec(["shift-,"]),
sort: parseBindingSpec([","]),
quit: parseBindingSpec(["q"]),
command: parseBindingSpec([":"]),
"tab-next": parseBindingSpec(["]"]),
} as Record<string, ReturnType<typeof parseBindingSpec>>;
function eq(a: any, b: any) {
return (
a.key === b.key &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta
);
}
function classify(candidate: any[]) {
const exact: string[] = [];
const prefix: string[] = [];
for (const name of Object.keys(cfg)) {
for (const seq of cfg[name]) {
if (seq.length < candidate.length) continue;
let p = true;
for (let i = 0; i < candidate.length; i++)
if (!eq(seq[i], candidate[i])) {
p = false;
break;
}
if (!p) continue;
if (seq.length === candidate.length) exact.push(name);
else prefix.push(name);
}
}
return { exact, prefix };
}
function longest(names: string[]) {
let best = names[0],
bl = 0;
for (const n of names)
for (const s of cfg[n])
if (s.length > bl) {
bl = s.length;
best = n;
}
return best;
}
function sim(strokes: any[]) {
let pending: any[] = [];
let fired: string | null = null;
for (const st of strokes) {
const cand = [...pending, st];
const { exact, prefix } = classify(cand);
if (prefix.length > 0) {
pending = cand;
continue;
}
if (exact.length === 0) {
const fresh = classify([st]);
if (fresh.prefix.length > 0) {
pending = [st];
continue;
}
if (fresh.exact.length > 0) fired = longest(fresh.exact);
else fired = null;
} else fired = longest(exact);
pending = [];
}
return fired;
}
const E = (k: string, o: any = {}) => ({ key: k, ...o });
let pass = 0,
fail = 0;
function check(label: string, got: string | null, want: string | null) {
const ok = got === want;
if (ok) pass++;
else fail++;
console.log(
`${ok ? "PASS" : "FAIL"} ${label} => ${got}${ok ? "" : ` (want ${want})`}`,
);
}
check("j -> move-down", sim([E("j")]), "move-down");
check("down -> move-down", sim([E("down")]), "move-down");
check("gg -> goto-top", sim([E("g"), E("g")]), "goto-top");
check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom");
check("space -> toggle-select", sim([E("space")]), "toggle-select");
check("q -> quit", sim([E("q")]), "quit");
check(": -> command", sim([E(":")]), "command");
check("] -> tab-next", sim([E("]")]), "tab-next");
check(
"shift+p -> audio-toggle",
sim([E("p", { shift: true })]),
"audio-toggle",
);
check("plain p -> null", sim([E("p")]), null);
check(
"shift+, -> audio-seek-backward",
sim([E(",", { shift: true })]),
"audio-seek-backward",
);
check(", -> sort", sim([E(",")]), "sort");
check(
"shift+. -> audio-seek-forward",
sim([E(".", { shift: true })]),
"audio-seek-forward",
);
check(
"g then j -> move-down (timeout-ish fallthrough)",
sim([E("g"), E("j")]),
"move-down",
);
console.log(
parseStroke("ctrl-d"),
parseStroke("G"),
parseStroke(">"),
parseStroke("shift-return"),
);
console.log(`\n${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);

View File

@@ -12,8 +12,8 @@
"types": ["bun-types"], "types": ["bun-types"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"]
} }
}, },
"include": ["src/**/*", "tests/**/*"] "include": ["src/**/*", "tests/**/*", "scripts/**/*"]
} }