8 Commits

Author SHA1 Message Date
d2f6c5c525 some hygiene 2026-08-09 23:39:35 -04:00
f758b53336 drop tasks dir 2026-08-09 22:29:24 -04:00
b0bfa41028 bump VERSION to 0.3.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m12s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-09 22:24:58 -04:00
25307f83e9 fix: up highlight made legible for transparent bg, bring back mouse nav 2026-08-09 22:21:30 -04:00
db285530b6 feat: private feeds, all input fields supersede keyboard nav 2026-08-09 15:39:02 -04:00
e1cdd6b2a5 finished hygenie 2026-08-09 14:38:33 -04:00
2abdbaa4e9 cleaning up code 2026-08-09 09:54:52 -04:00
1d06156b8b docs: repoint Homebrew tap to mikefreno/tap after repo rename 2026-08-09 09:19:05 -04:00
103 changed files with 1117 additions and 3686 deletions

View File

@@ -159,11 +159,11 @@ Releases are built and published from **tags**
4. A release is auto-created with all 4 tarballs attached. `brew` never 4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that `mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no `Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/podtui/podtui`. test: `brew install mikefreno/tap/podtui`.
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is 5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
staged, not yet published (AUR account registrations are closed; see the staged, not yet published (AUR account registrations are closed; see the
README note in section 3). On each release, keep the AUR sources in sync README note in section 3). On each release, keep the AUR sources in sync
@@ -177,7 +177,7 @@ Releases are built and published from **tags**
If you ever need to sync the tap by hand (or before the hourly job runs): If you ever need to sync the tap by hand (or before the hourly job runs):
```bash ```bash
cd <clone of mikefreno/homebrew-podtui> cd <clone of mikefreno/homebrew-tap>
./scripts/sync-formula.sh 0.2.0 ./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push git commit -am 'podtui 0.2.0' && git push
``` ```

View File

@@ -19,12 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
---
This project bundles third-party components under their own licenses:
- **cava** (karlstav/cava, vendored under `cava/`) — MIT,
Copyright (c) 2015 Karl Stavestrand. See `cava/LICENSE-cava.txt`.
- **Bun runtime** (embedded in the standalone binary) — MIT.
- **OpenTUI** (`@opentui/core`) — MIT.

View File

@@ -26,18 +26,9 @@ external player with full transport control — all from your terminal.
- A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm, - A terminal with UTF-8 and modern color support (kitty, iTerm2, WezTerm,
tmux, GNOME Terminal, etc.). tmux, GNOME Terminal, etc.).
- An **audio player** on `PATH`. PodTui auto-detects in priority order: - **mpv** on `PATH` for audio playback. PodTui drives mpv over JSON IPC, so
seek, speed, and position tracking all work. Without `mpv` on `PATH`,
| Player | Platforms | Seek | Speed | Position tracking | playback is a silent no-op (the `none` backend).
|----------|----------------|:----:|:-----:|:------------------|
| `mpv` | any | ✔ | ✔ | ✔ (recommended) |
| `ffplay` | any | ✔ | ✘ | ✘ |
| `afplay` | macOS built-in | ✔ | ✔ | ✘ |
| `open`/`xdg-open` | any | ✘ | ✘ | ✘ |
Install `mpv` for the best experience (`brew install mpv`,
`sudo apt install mpv`, `pacman -S mpv`). You can force a specific backend
with `PODTUI_AUDIO_BACKEND=mpv|ffplay|afplay|system|none`.
## Installation ## Installation
@@ -47,7 +38,7 @@ Linux (arm64/x64). Pick whichever fits your platform.
### 1. Homebrew (macOS) ### 1. Homebrew (macOS)
```sh ```sh
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv brew install mikefreno/tap/podtui
``` ```
> The formula installs the standalone binary plus its two native libraries > The formula installs the standalone binary plus its two native libraries
@@ -98,8 +89,7 @@ symlink, and pulls in `mpv` (the sole audio backend) as a dependency.
> >
> Publishing is on hold until [AUR account registrations](https://aur.archlinux.org) > Publishing is on hold until [AUR account registrations](https://aur.archlinux.org)
> reopen (suspended while the AUR team works on suspicious-package > reopen (suspended while the AUR team works on suspicious-package
> moderation). Once a key can be registered, push `PKGBUILD` + `.SRCINFO` > moderation).
> with `git push ssh://aur@aur.archlinux.org/podtui-bin` and update this note.
### 4. From source ### 4. From source
@@ -177,12 +167,14 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `feeds.json` | Your subscribed feeds (RSS/podcast sources) | | `config.json` | Unified settings (theme, playback speed, download path), feeds, and custom feed sources |
| `sources.json` | Custom feed sources |
| `downloads.json` | Downloaded episode metadata | | `downloads.json` | Downloaded episode metadata |
| `keybinds.jsonc` | Keybinding remaps (see above) | | `keybinds.jsonc` | Keybinding remaps (see above) |
| `themes/` | Optional custom theme files | | `themes/` | Optional custom theme files |
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
into `config.json` on first run.
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
the same OpenTUI environment variables. the same OpenTUI environment variables.

BIN
bun.lockb

Binary file not shown.

View File

@@ -18,21 +18,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@opentui/core": "^0.1.77", "@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77", "@opentui/solid": "^0.1.77",
"babel-preset-solid": "1.9.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"solid-js": "^1.9.9", "solid-js": "^1.9.9"
"uuid": "^13.0.0",
"zustand": "^5.0.11"
} }
} }

View File

@@ -132,7 +132,7 @@ for r in $REMOTES; do
done done
echo "" echo ""
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform" echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}" echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
echo "" echo ""
read -p "Proceed? (y/n) " -n 1 -r read -p "Proceed? (y/n) " -n 1 -r
echo "" echo ""
@@ -236,5 +236,5 @@ echo ""
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}" echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:" echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}" echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula" echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
echo " URLs + sha256s); brew upgrade podtui afterwards." echo " URLs + sha256s); brew upgrade podtui afterwards."

View File

@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
} else if (a === "--from") { } else if (a === "--from") {
flags.from = rest[++i]; flags.from = rest[++i];
} else { } else {
flags[a.slice(2)] = rest[++i] ?? true; throw new Error(`unknown flag: ${a}`);
} }
} else { } else {
positional.push(a); positional.push(a);
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
return mods; return mods;
} }
function buildAction(cmd: string, positional: string[]): Action | null { // Per-command builders. Leading positional tokens that name a modifier
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
const mods = parseMods(positional); const mods = parseMods(positional);
const first = positional[0]; return mods.length ? mods : undefined;
switch (cmd) { };
case "key":
if (!first) throw new Error("key requires a <key> argument"); const BUILDERS: Record<string, (positional: string[]) => Action> = {
return { t: "key", k: first, mods: mods.length ? mods : undefined }; key: (p) => {
case "arrow": if (!p[0]) throw new Error("key requires a <key> argument");
if (!first || !["up", "down", "left", "right"].includes(first)) return { t: "key", k: p[0], mods: modsOrUndefined(p) };
throw new Error("arrow requires up|down|left|right"); },
return { arrow: (p) => {
t: "arrow", if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
d: first as any, throw new Error("arrow requires up|down|left|right");
mods: mods.length ? mods : undefined, return {
}; t: "arrow",
case "enter": d: p[0] as "up" | "down" | "left" | "right",
case "escape": mods: modsOrUndefined(p),
case "tab": };
case "space": },
case "backspace": enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
return { t: cmd, mods: mods.length ? mods : undefined }; escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
case "type": tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
if (first === undefined) throw new Error("type requires <text>"); space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
// Re-join the rest in case text had spaces; positional[0] already is first token, backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
// caller should quote. We join all positional as the text. type: (p) => {
return { t: "type", s: positional.join(" ") }; if (p[0] === undefined) throw new Error("type requires <text>");
case "wait": // Re-join the rest in case text had spaces; p[0] already is first token,
if (!first) throw new Error("wait requires <ms>"); // caller should quote. We join all positional as the text.
return { t: "wait", ms: parseInt(first, 10) || 0 }; return { t: "type", s: p.join(" ") };
case "resize": },
if (!first || !positional[1]) throw new Error("resize requires <w> <h>"); wait: (p) => {
return { if (!p[0]) throw new Error("wait requires <ms>");
t: "resize", return { t: "wait", ms: parseInt(p[0], 10) || 0 };
w: parseInt(first, 10) || 100, },
h: parseInt(positional[1], 10) || 30, resize: (p) => {
}; if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
case "frame": return {
case "state": t: "resize",
case "reset": w: parseInt(p[0], 10) || 100,
case "actions": h: parseInt(p[1], 10) || 30,
case "init": };
case "seed": },
return null; };
default:
throw new Error(`unknown command: ${cmd}`); function buildAction(cmd: string, positional: string[]): Action | null {
} const builder = BUILDERS[cmd];
if (builder) return builder(positional);
// Local-only commands return early in main before this is reached; keep
// the null contract so the public behavior is unchanged.
if (
cmd === "frame" ||
cmd === "state" ||
cmd === "reset" ||
cmd === "actions" ||
cmd === "init" ||
cmd === "seed"
)
return null;
// Single table-miss error for any unknown command.
throw new Error(`unknown command: ${cmd}`);
} }
// ── Execute one action against a mounted setup ────────────────────────────── // ── Execute one action against a mounted setup ──────────────────────────────
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
await new Promise((r) => setTimeout(r, 40)); await new Promise((r) => setTimeout(r, 40));
} }
// ── Main ─────────────────────────────────────────────────────────────────── // ── Mount, snapshot & output (extracted from main) ─────────────────────────
async function main() { // A line is "visually empty" if it's either fully blank OR contains only
activateSandbox(); // box-drawing chars + whitespace (i.e. empty-pane interior padding like
captureIssues(); // "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const argv = process.argv.slice(2); function trimFrame(plainFrame: string): string {
const cmd = argv[0] ?? "frame"; const lines = plainFrame
const { flags, positional } = parseFlags(argv.slice(1)); .replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(` …${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
}
// Local-only commands that don't mount. // Local-only commands that don't mount. Returns true if handled (main returns).
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
if (cmd === "reset") { if (cmd === "reset") {
saveActions([]); saveActions([]);
console.log("✔ actions log cleared."); console.log("✔ actions log cleared.");
return; return true;
} }
if (cmd === "actions") { if (cmd === "actions") {
const a = loadActions(); const a = loadActions();
console.log(`Action log (${a.length}):`); console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2)); console.log(JSON.stringify(a, null, 2));
return; return true;
} }
if (cmd === "seed") { if (cmd === "seed") {
const from = String( const from = String(
@@ -349,9 +392,29 @@ async function main() {
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui"); const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true }); cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from} → ${dest}`); console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
return; return true;
} }
return false;
}
type FrameCapture = {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
async function mountApp(
flags: Record<string, string | boolean>,
cmd: string,
positional: string[],
): Promise<{
setup: any;
spans: FrameCapture;
plainFrame: string;
audioControls: any;
actions: Action[];
}> {
// Size settings. // Size settings.
let width = 100; let width = 100;
let height = 30; let height = 30;
@@ -448,12 +511,7 @@ async function main() {
// Final settle + capture. // Final settle + capture.
await setup.renderOnce(); await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60)); await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as { const spans = setup.captureSpans() as FrameCapture;
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
const plainFrame = setup.captureCharFrame(); const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame. // Dump structured spans + plain frame.
@@ -462,6 +520,10 @@ async function main() {
writeFileSync(FRAME_TXT, plainFrame); writeFileSync(FRAME_TXT, plainFrame);
} catch {} } catch {}
return { setup, spans, plainFrame, audioControls, actions };
}
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
// Store state snapshot. // Store state snapshot.
const state: Record<string, unknown> = {}; const state: Record<string, unknown> = {};
try { try {
@@ -514,54 +576,31 @@ async function main() {
try { try {
writeFileSync(STATE_JSON, JSON.stringify(state)); writeFileSync(STATE_JSON, JSON.stringify(state));
} catch {} } catch {}
return state;
}
// ── Output ────────────────────────────────────────────────────────────── function emitOutput(p: {
spans: FrameCapture;
plainFrame: string;
state: Record<string, unknown>;
actions: Action[];
cmd: string;
flags: Record<string, string | boolean>;
positional: string[];
}): void {
// Compact by default: trimmed frame, one-line state per section, no styles // Compact by default: trimmed frame, one-line state per section, no styles
// block, no boilerplate footer. Use --styles / --verbose to opt back in. // block, no boilerplate footer. Use --styles / --verbose to opt back in.
const verbose = !!flags.verbose; const verbose = !!p.flags.verbose;
const scope = cmd === "state" ? String(positional[0] || "all") : "all"; const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
// A line is "visually empty" if it's either fully blank OR contains only
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const frameTrimmed = (() => {
const lines = plainFrame
.replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(` …${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
})();
console.log( console.log(
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`, `FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
); );
console.log(frameTrimmed); console.log(trimFrame(p.plainFrame));
// ── distinct styles: opt-in only (--styles OR --verbose) ── // ── distinct styles: opt-in only (--styles OR --verbose) ──
if (scope === "all" && (flags.styles || verbose)) { if (scope === "all" && (p.flags.styles || verbose)) {
const styles = distinctStyles(spans); const styles = distinctStyles(p.spans);
if (styles.length) { if (styles.length) {
console.log("-- styles (top 20) --"); console.log("-- styles (top 20) --");
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`); for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
@@ -572,9 +611,9 @@ async function main() {
const want = (k: string) => scope === "all" || scope === k; const want = (k: string) => scope === "all" || scope === k;
const compact = (obj: unknown): string => const compact = (obj: unknown): string =>
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj); verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
if (want("nav")) console.log("nav " + compact(state.nav)); if (want("nav")) console.log("nav " + compact(p.state.nav));
if (want("audio")) console.log("audio " + compact(state.audio)); if (want("audio")) console.log("audio " + compact(p.state.audio));
if (want("feed")) console.log("feed " + compact(state.feed)); if (want("feed")) console.log("feed " + compact(p.state.feed));
if (want("app")) console.log("app (not dumped in v1)"); if (want("app")) console.log("app (not dumped in v1)");
// ── issues: terse ── // ── issues: terse ──
@@ -586,12 +625,14 @@ async function main() {
} }
// Footer is identical every run — only print on init or --verbose. // Footer is identical every run — only print on init or --verbose.
if (cmd === "init" || verbose) { if (p.cmd === "init" || verbose) {
console.log( console.log(
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`, `(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
); );
} }
}
async function teardown(setup: any, audioControls: any): Promise<void> {
// Tear down child processes (audio backend) before exit to avoid orphans. // Tear down child processes (audio backend) before exit to avoid orphans.
try { try {
if (audioControls?.stop) await audioControls.stop().catch(() => {}); if (audioControls?.stop) await audioControls.stop().catch(() => {});
@@ -606,6 +647,32 @@ async function main() {
process.exit(0); process.exit(0);
} }
// ── 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 (runLocal(cmd, flags)) return;
const m = await mountApp(flags, cmd, positional);
const state = await snapshotState(m.audioControls);
emitOutput({
spans: m.spans,
plainFrame: m.plainFrame,
state,
actions: m.actions,
cmd,
flags,
positional,
});
await teardown(m.setup, m.audioControls);
}
main().catch((err) => { main().catch((err) => {
console.error("HARNESS FAILED:", err?.stack || err); console.error("HARNESS FAILED:", err?.stack || err);
process.exit(1); process.exit(1);

View File

@@ -50,6 +50,7 @@ export function App() {
}); });
const backgroundColor = () => const backgroundColor = () =>
themeContext.transparentBackground() ||
themeContext.selected === "system" themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface; : themeContext.theme.surface;

View File

@@ -1,73 +0,0 @@
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import type { PodcastSource } from "../types/source"
import { parseRSSFeed } from "@/api/rss-parser"
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl)
if (!response.ok) return []
const xml = await response.text()
return parseRSSFeed(xml, feedUrl).episodes
} catch {
return []
}
}
export const fetchFeeds = async (
sourceIds: string[],
sources: PodcastSource[]
): Promise<Feed[]> => {
const active = sources.filter((source) => sourceIds.includes(source.id))
const feeds: Feed[] = []
await Promise.all(
active.map(async (source) => {
try {
if (source.type === "rss") {
const rssFeeds = await handleRSSSource(source)
feeds.push(...rssFeeds)
} else if (source.type === "api") {
const apiFeeds = await handleAPISource(source, "")
feeds.push(...apiFeeds)
} else {
const customFeeds = await handleCustomSource(source, "")
feeds.push(...customFeeds)
}
} catch {
// ignore individual source errors
}
})
)
return feeds
}
export const searchPodcasts = async (
query: string,
sources: PodcastSource[]
): Promise<Podcast[]> => {
const results: Podcast[] = []
await Promise.all(
sources.map(async (source) => {
try {
if (source.type === "rss") {
const feeds = await handleRSSSource(source)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else if (source.type === "api") {
const feeds = await handleAPISource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else {
const feeds = await handleCustomSource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
}
} catch {
// ignore errors
}
})
)
return results
}

View File

@@ -1,94 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { PodcastSource } from "../types/source"
import type { Podcast } from "../types/podcast"
import { parseRSSFeed } from "./rss-parser"
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
return {
id: `${sourceId}-${podcast.id}`,
podcast,
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId,
lastUpdated: new Date(),
isPinned: false,
}
}
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
if (!source.baseUrl) return []
const response = await fetch(source.baseUrl)
if (!response.ok) return []
const xml = await response.text()
const parsed = parseRSSFeed(xml, source.baseUrl)
return [
{
id: `${source.id}-${parsed.feedUrl}`,
podcast: {
id: parsed.id,
title: parsed.title,
description: parsed.description,
feedUrl: parsed.feedUrl,
author: parsed.author,
categories: parsed.categories,
lastUpdated: parsed.lastUpdated,
isSubscribed: true,
},
episodes: parsed.episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: source.id,
lastUpdated: parsed.lastUpdated,
isPinned: false,
},
]
}
export const handleAPISource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
url.searchParams.set("term", query || "podcast")
url.searchParams.set("media", "podcast")
url.searchParams.set("entity", "podcast")
url.searchParams.set("country", source.country || "US")
url.searchParams.set("lang", source.language || "en_us")
const response = await fetch(url.toString())
if (!response.ok) return []
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
const results = data.results ?? []
return results
.filter((item) => item.collectionName && item.feedUrl)
.map((item) => {
const podcast: Podcast = {
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
title: item.collectionName || "Untitled Podcast",
description: item.collectionName || "",
feedUrl: item.feedUrl || "",
author: item.artistName,
lastUpdated: new Date(),
isSubscribed: false,
}
return buildFeedFromPodcast(podcast, source.id)
})
}
export const handleCustomSource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
if (!query) return []
const podcast: Podcast = {
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
title: `${query} Highlights`,
description: `Curated results for ${query}`,
feedUrl: source.baseUrl || "",
author: source.name,
lastUpdated: new Date(),
isSubscribed: false,
}
return [buildFeedFromPodcast(podcast, source.id)]
}

View File

@@ -3,7 +3,6 @@ import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
//TODO: Watch for actual loading state (fetching feeds)
export function LoadingIndicator() { export function LoadingIndicator() {
const { theme } = useTheme(); const { theme } = useTheme();
const [index, setIndex] = createSignal(0); const [index, setIndex] = createSignal(0);

View File

@@ -1,15 +1,15 @@
/** /**
* PaneRow — the shared parent | current | preview 3-pane layout primitive. * PaneRow — the shared parent | current | preview 3-pane layout primitive.
* *
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns * Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list * grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}` * tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
* so the ratio is exact regardless of content width — a column's content can * so the ratio is exact regardless of content width — a column's content can
* never stretch its slot. * never stretch its slot.
* *
* Column semantics (per the yazi depth model): * Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and * parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 1/7 slot when blank (never collapses to width 0). * KEEPS its 1/5 slot when blank (never collapses to width 0).
* current — the current-depth list. The only focusable content column; it * current — the current-depth list. The only focusable content column; it
* carries the active-border focus ring when `focused` is truthy. * carries the active-border focus ring when `focused` is truthy.
* preview — detail of the hovered item in `current`; always muted border. * preview — detail of the hovered item in `current`; always muted border.
@@ -43,7 +43,7 @@ type PaneLabel = string | (() => string);
export type PaneRowProps = { export type PaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted /** Parent column content (previous-depth list, or null for a muted
* placeholder — the 1/7 slot is always preserved). */ * placeholder — the 1/5 slot is always preserved). */
parent?: PaneContent; parent?: PaneContent;
/** Current column content (the focused list). */ /** Current column content (the focused list). */
current?: PaneContent; current?: PaneContent;
@@ -69,16 +69,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
} }
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor. /** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
* We deliberately do NOT use Solid's `children()` helper here: that helper * We deliberately avoid Solid's `children()` helper: it flattens accessor
* flattens accessor children into a stable resolved-nodes array and is the * children into a stable resolved-nodes array and won't re-resolve on a
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane * truthy→truthy root swap (e.g. the current pane switching between a
* switching between a depth-1 list fragment and a depth-2 editor — both * depth-1 list fragment and a depth-2 editor), freezing the previous
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy * subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
* root swap, freezing the previous subtree in place. Instead we hand the * expression — a tracked `insert` effect that disposes the old subtree and
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below, * mounts the new whenever the accessor returns a different element identity. */
* which Solid compiles into a tracked `insert` effect that disposes the old
* subtree and mounts the new whenever the accessor returns a different
* element identity. */
function normalizeContent( function normalizeContent(
v: PaneContent | undefined, v: PaneContent | undefined,
): () => JSX.Element | undefined { ): () => JSX.Element | undefined {
@@ -102,7 +99,8 @@ function Pane(props: {
borderColor: () => RGBA; borderColor: () => RGBA;
scrollFocused: () => boolean; scrollFocused: () => boolean;
}) { }) {
const { theme } = useTheme(); const themeContext = useTheme();
const theme = themeContext.theme;
const muted = () => theme.muted ?? theme.textMuted ?? theme.text; const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
// Memoize accessor results so the prop expressions below stay reactive // Memoize accessor results so the prop expressions below stay reactive
@@ -118,7 +116,15 @@ function Pane(props: {
height="100%" height="100%"
> >
{/* ── slim header label row ─────────────────────────────────────────── */} {/* ── slim header label row ─────────────────────────────────────────── */}
<box height={1} paddingLeft={1} backgroundColor={theme.background}> <box
height={1}
paddingLeft={1}
backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
>
<text fg={theme.textSecondary}>{props.label()}</text> <text fg={theme.textSecondary}>{props.label()}</text>
</box> </box>
{/* ── bordered scrollbox ────────────────────────────────────────────── */} {/* ── bordered scrollbox ────────────────────────────────────────────── */}
@@ -127,22 +133,13 @@ function Pane(props: {
focused={scrollFocused()} focused={scrollFocused()}
border border
borderColor={borderColor()} borderColor={borderColor()}
backgroundColor={theme.background} backgroundColor={
themeContext.transparentBackground()
? "transparent"
: theme.background
}
> >
{/* {props.content() ?? <Placeholder color={muted} />}
* Render the content accessor directly via a reactive expression.
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
* effect that re-runs whenever the accessor's tracked signals
* change (e.g. `depth()` swapping the root from a list fragment to
* an editor). Solid disposes the previously-rendered subtree and
* mounts the new element identity. `null`/`undefined` falls back
* to the muted placeholder so the parent pane keeps its 1/7 slot
* visibly blank at depth 0. This is the correct tool for root
* swapping — unlike Solid's `children()` / `<Show>`-children,
* which only react to truthiness flips, not truthy<@->truthy root
* identity changes.
*/}
{props.content() ?? <Placeholder color={muted} />}
</scrollbox> </scrollbox>
</box> </box>
); );
@@ -179,7 +176,7 @@ export function PaneRow(props: PaneRowProps) {
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */} {/* ── parent (1/5) — previous-depth list; always muted ─────────────── */}
<Pane <Pane
grow={PANE_RATIO.parent} grow={PANE_RATIO.parent}
label={parentLabel} label={parentLabel}
@@ -195,7 +192,7 @@ export function PaneRow(props: PaneRowProps) {
borderColor={() => (focused() ? theme.borderActive : theme.border)} borderColor={() => (focused() ? theme.borderActive : theme.border)}
scrollFocused={() => focused()} scrollFocused={() => focused()}
/> />
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */} {/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
<Show when={panes() === 3}> <Show when={panes() === 3}>
<Pane <Pane
grow={PANE_RATIO.preview} grow={PANE_RATIO.preview}

View File

@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
backgroundColor={ backgroundColor={
props.selected() props.selected()
? theme.primary ? theme.primary
: themeContext.selected === "system" : themeContext.transparentBackground() ||
themeContext.selected === "system"
? "transparent" ? "transparent"
: themeContext.theme.surface : themeContext.theme.surface
} }

View File

@@ -12,15 +12,16 @@
*/ */
import { createSignal, Show, For } from "solid-js"; import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid"; import { useKeyboard, useRenderer } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext"; import { useNavigation, NavMode } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus"; import { emit, on } from "@/utils/event-bus";
import { LayerGraph } from "@/utils/layer-graph"; import { LayerGraph } from "@/utils/layer-graph";
import { TABS, TabPaneCount } from "@/utils/navigation"; import { TABS, TabPaneCount } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch"; import { createDispatcher } from "@/utils/dispatch";
@@ -42,12 +43,25 @@ export function Shell() {
const nav = useNavigation(); const nav = useNavigation();
const k = useKeybinds(); const k = useKeybinds();
const audio = useAudio(); const audio = useAudio();
const renderer = useRenderer();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const toast = useToast(); const toast = useToast();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false); const [showHelp, setShowHelp] = createSignal(false);
// ── Auto jump to Player on podcast start ───────────────────────────────────
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
// "player.started" — distinct from "player.play", which also fires on
// resume), switch to the Player tab and drop into its content pane.
on("player.started", () => {
const app = useAppStore();
if (app.state().preferences.autoJumpToPlayer) {
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
}
});
/** Play the episode adjacent (offset ±1) to the currently-playing one, /** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */ * within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) { function advanceEpisode(offset: number) {
@@ -83,74 +97,60 @@ export function Shell() {
} }
// ── Command bar dispatch ──────────────────────────────────────────────────── // ── Command bar dispatch ────────────────────────────────────────────────────
const COMMANDS: Record<string, (arg: string) => void> = {
quit: () => process.exit(0),
exit: () => process.exit(0),
q: () => process.exit(0),
refresh: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
r: () =>
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
}),
play: () => audio.togglePlayback().catch(() => {}),
pause: () => audio.togglePlayback().catch(() => {}),
p: () => audio.togglePlayback().catch(() => {}),
next: () => advanceEpisode(1),
n: () => advanceEpisode(1),
prev: () => advanceEpisode(-1),
seek: (arg) => {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
},
feed: () => nav.setActiveTab(TABS.FEED),
f: () => nav.setActiveTab(TABS.FEED),
shows: () => nav.setActiveTab(TABS.MYSHOWS),
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
discover: () => nav.setActiveTab(TABS.DISCOVER),
d: () => nav.setActiveTab(TABS.DISCOVER),
search: () => nav.setActiveTab(TABS.SEARCH),
player: () => nav.setActiveTab(TABS.PLAYER),
settings: () => nav.setActiveTab(TABS.SETTINGS),
set: () => nav.setActiveTab(TABS.SETTINGS),
help: () => setShowHelp((v) => !v),
h: () => setShowHelp((v) => !v),
};
function runCommand(raw: string) { function runCommand(raw: string) {
const cmd = raw.trim(); const cmd = raw.trim();
if (!cmd) return; if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase(); const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim(); const arg = cmd.slice(name.length).trim();
switch (name) { const unknownCommand = () => {
case "q": nav.setCommandError(`unknown command: ${name}`);
case "quit": // re-enter command mode so the user sees the error + can correct
case "exit": nav.enterCommand();
return process.exit(0); nav.setCommandBuffer(cmd);
case "refresh": };
case "r": (COMMANDS[name] ?? unknownCommand)(arg);
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 ─────────────────────────────────────────────── // ── Command-mode key handling ───────────────────────────────────────────────
@@ -206,6 +206,11 @@ export function Shell() {
if (evt.name === "escape") { if (evt.name === "escape") {
evt.preventDefault(); evt.preventDefault();
nav.setInputFocused(false); nav.setInputFocused(false);
// Actually blur the focused renderable too — setting the flag alone
// leaves the opentui input owning keys, so nav keys would still be
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
// (and re-blurs the SearchPage input via its `focused` prop).
renderer.currentFocusedRenderable?.blur();
} }
return; return;
} }
@@ -236,11 +241,13 @@ export function Shell() {
return ( return (
<box <box
flexDirection="column" flexDirection="column"
width="100%" width="100%"
height="100%" height="100%"
backgroundColor={t.surface} backgroundColor={
> theme.transparentBackground() ? "transparent" : t.surface
}
>
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
<box flexDirection="row" flexGrow={1} width="100%"> <box flexDirection="row" flexGrow={1} width="100%">
<Show <Show
@@ -276,7 +283,11 @@ export function Shell() {
flexDirection="row" flexDirection="row"
height={1} height={1}
width="100%" width="100%"
backgroundColor={t.backgroundPanel ?? t.background} backgroundColor={
theme.transparentBackground()
? "transparent"
: (t.backgroundPanel ?? t.background)
}
> >
<Show <Show
when={nav.mode() === NavMode.COMMAND} when={nav.mode() === NavMode.COMMAND}
@@ -458,18 +469,5 @@ function k_match_escape(evt: any): boolean {
); );
} }
/** 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);
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees. // Re-export Episode type for callers building pane trees.
export type { Episode } from "@/types/episode"; export type { Episode } from "@/types/episode";

View File

@@ -1,27 +0,0 @@
import { For } from "solid-js";
import { shortcuts } from "@/config/shortcuts";
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() {
const { theme } = useTheme();
return (
<box
border
title="Shortcuts"
style={{ flexDirection: "column", padding: 1 }}
>
<box style={{ flexDirection: "column" }}>
<For each={shortcuts}>
{(s) => (
<box style={{ flexDirection: "row" }} gap={2}>
<text fg={theme.accent}>{s.keys}</text>
<text fg={theme.text}>{s.action}</text>
</box>
)}
</For>
</box>
</box>
);
}

View File

@@ -1,55 +0,0 @@
import { useTheme } from "@/context/ThemeContext";
import { TABS, TabsCount } from "@/utils/navigation";
import { For } from "solid-js";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
export const tabs: TabDefinition[] = [
{ id: TABS.FEED, label: "Feed" },
{ id: TABS.MYSHOWS, label: "My Shows" },
{ id: TABS.DISCOVER, label: "Discover" },
{ id: TABS.SEARCH, label: "Search" },
{ id: TABS.PLAYER, label: "Player" },
{ id: TABS.SETTINGS, label: "Settings" },
];
export function TabNavigation() {
const { theme } = useTheme();
const { activeTab, setActiveTab, activeDepth } = useNavigation();
return (
<box
border
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
backgroundColor={"transparent"}
style={{
flexDirection: "column",
width: 12,
height: TabsCount * 3 + 2,
}}
>
<For each={tabs}>
{(tab) => (
<SelectableBox
border
height={3}
selected={() => tab.id == activeTab()}
onMouseDown={() => setActiveTab(tab.id)}
>
<SelectableText
selected={() => tab.id == activeTab()}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
)}
</For>
</box>
);
}
export type TabDefinition = {
id: TABS;
label: string;
};

View File

@@ -53,7 +53,11 @@ export function TabListPane(props: { muted?: boolean }) {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (t: TABS) => const focusFg = (t: TABS) =>
t === cursor() && active() ? theme.surface : theme.text; t === cursor() && active()
? theme.surface
: t === cursor()
? theme.selectedListItemText ?? theme.text
: theme.text;
return ( return (
<For each={TAB_ORDER}> <For each={TAB_ORDER}>
@@ -77,6 +81,14 @@ export function TabListPane(props: { muted?: boolean }) {
flexDirection="row" flexDirection="row"
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(tab)} backgroundColor={focusBg(tab)}
onMouseDown={() => {
// Click = hover + open, the yazi "open" of the row
// (switches to the tab and enters its content), the same
// as l/Enter. Restores mouse support the tab-strip
// refactor dropped.
nav.setTabCursor(tab);
nav.activateTabCursor();
}}
> >
{/* ── selection marker (j/k cursor) ─────────────────────────── */} {/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text> <text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>

View File

@@ -11,7 +11,8 @@
// //
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select, // 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, // 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. // : / q command palette (q + Enter quits there), Q quick quit, ~ help.
// Audio transport kept on shifted keys / ctrl.
// ── Movement (within a pane) ───────────────────────────────────────────── // ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"], "move-down": ["j", "down"],
@@ -50,9 +51,11 @@
"tab-goto-5": ["5"], "tab-goto-5": ["5"],
"tab-goto-6": ["6"], "tab-goto-6": ["6"],
// ── Command bar & help & quit ──────────────────────────────────────────── // ── Command palette & help & quit ────────────────────────────────────────
"command": [":"], // q opens the command palette (neovim-style: type q + Enter to quit there).
"quit": ["q", "ctrl-c"], // Q (shift+q) is the instant quick quit. ctrl-c also quits.
"command": [":", "q"],
"quit": ["Q", "ctrl-c"],
"help": ["~", "f1"], "help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh) // ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)

View File

@@ -1,27 +0,0 @@
/**
* 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 = [
{ keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "ctrl-d / u", action: "Half page down / up" },
{ 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: "x", action: "Unsubscribe focused show (My Shows)" },
{ 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,12 +0,0 @@
export const syncFormats = {
json: {
version: "1.0",
extension: ".json",
},
xml: {
version: "1.0",
extension: ".xml",
},
}
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]

View File

@@ -72,20 +72,7 @@ export type KeybindActionName =
| "audio-next" | "audio-next"
| "audio-prev" | "audio-prev"
| "audio-seek-forward" | "audio-seek-forward"
| "audio-seek-backward" | "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. */ /** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>; export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
@@ -146,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
} }
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */ /** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: { function strokeFromEvent(evt: {
name: string; name: string;
ctrl?: boolean; ctrl?: boolean;
meta?: boolean; meta?: boolean;
@@ -154,7 +141,7 @@ export function strokeFromEvent(evt: {
}): Stroke { }): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize. // Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return { return {
key: (evt.name ?? "").toLowerCase(), key: evt.name.toLowerCase(),
ctrl: !!evt.ctrl, ctrl: !!evt.ctrl,
shift: !!evt.shift, shift: !!evt.shift,
meta: !!evt.meta, meta: !!evt.meta,
@@ -171,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
} }
/** A human label for a stroke, for the status bar / help. */ /** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string { function strokeLabel(s: Stroke): string {
let out = ""; let out = "";
if (s.ctrl) out += "C-"; if (s.ctrl) out += "C-";
if (s.meta) out += "M-"; if (s.meta) out += "M-";
@@ -180,7 +167,7 @@ export function strokeLabel(s: Stroke): string {
return out; return out;
} }
export function sequenceLabel(seq: Stroke[]): string { function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" "); return seq.map(strokeLabel).join(" ");
} }
@@ -338,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
return best; 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(() => { onMount(() => {
load().catch(() => {}); load().catch(() => {});
}); });
@@ -366,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
pending, pending,
match, match,
tryMatch, tryMatch,
isInverting,
print, print,
save, save,
load, load,

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { useRenderer } from "@opentui/solid"; import { useRenderer } from "@opentui/solid";
@@ -10,6 +11,7 @@ import {
generateSubtleSyntax, generateSubtleSyntax,
} from "../utils/syntax-highlighter"; } from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme"; import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { import {
setupThemeSignalHandler, setupThemeSignalHandler,
@@ -84,6 +86,8 @@ export type ThemeResolved = {
muted?: RGBA; muted?: RGBA;
surface?: RGBA; surface?: RGBA;
selectedListItemText?: RGBA; selectedListItemText?: RGBA;
/** Theme declares a transparent (terminal-bg-visible) background. */
transparent?: boolean;
layerBackgrounds?: { layerBackgrounds?: {
layer0: RGBA; layer0: RGBA;
layer1: RGBA; layer1: RGBA;
@@ -94,6 +98,61 @@ export type ThemeResolved = {
thinkingOpacity?: number; thinkingOpacity?: number;
}; };
/**
* A TerminalColors with no values — used to keep the "system" theme rendering
* with default ANSI colors + the detected dark/light mode when the terminal
* cannot answer OSC queries (e.g. inside tmux without OSC forwarding).
*/
const EMPTY_TERMINAL_COLORS: TerminalColors = {
palette: Array.from({ length: 16 }, () => null),
defaultForeground: null,
defaultBackground: null,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
};
/** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null;
/**
* Detect the terminal's dark/light mode.
*
* Priority:
* 1. The terminal's real background color (OSC 11 response) — terminal-specific.
* 2. The macOS appearance via `defaults read -g AppleInterfaceStyle` — works
* even inside tmux, where OSC queries are usually not forwarded.
* An unset value means light mode (macOS defaults to light).
* 3. null → keep whatever mode is currently active.
*/
function detectSystemMode(
colors: TerminalColors | null,
): "dark" | "light" | null {
const fromBg = detectModeFromBackground(colors?.defaultBackground);
if (fromBg) return fromBg;
if (process.platform === "darwin" && cachedOsMode === null) {
let style: string | null = null;
try {
style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
encoding: "utf8",
timeout: 2000,
})
.trim()
.toLowerCase();
} catch {
// Unset → light appearance (macOS default).
}
cachedOsMode = style?.includes("dark") ? "dark" : "light";
}
return cachedOsMode;
}
/** /**
* Theme context using the createSimpleContext pattern. * Theme context using the createSimpleContext pattern.
* *
@@ -195,6 +254,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
} }
} }
// ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
// real one from the terminal's background color (OSC 11) or, when that
// is unavailable (tmux without OSC forwarding), the OS appearance.
const detectedMode = detectSystemMode(colors);
if (detectedMode && detectedMode !== store.mode) {
setStore("mode", detectedMode);
emitThemeModeChanged(detectedMode);
}
const hasPalette = Boolean( const hasPalette = Boolean(
colors?.palette?.some((value) => Boolean(value)), colors?.palette?.some((value) => Boolean(value)),
); );
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
); );
if (!hasPalette && !hasDefaultColors) { if (!hasPalette && !hasDefaultColors) {
// No system colors available, fall back to default // No system colors available — the terminal can't answer OSC queries
// This happens when the terminal doesn't support OSC palette queries // (e.g. inside tmux, or unsupported terminals). Keep the "system"
// (e.g., running inside tmux, or on unsupported terminals) // theme anyway: the detected dark/light mode plus default ANSI colors
// still produce a usable, mode-correct palette.
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.active = "catppuccin"; draft.system = colors ?? EMPTY_TERMINAL_COLORS;
draft.ready = true; draft.ready = true;
}), }),
); );
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() { mode() {
return store.mode; return store.mode;
}, },
/** Whether the app background should be transparent (no solid fill):
* either the global preference is on, or the selected theme declares
* transparency (e.g. the system theme). */
transparentBackground() {
return (
appStore.state().settings.transparentBackground ||
values().transparent === true
);
},
setMode(mode: "dark" | "light") { setMode(mode: "dark" | "light") {
setStore("mode", mode); setStore("mode", mode);
emitThemeModeChanged(mode); emitThemeModeChanged(mode);

View File

@@ -13,7 +13,7 @@
* *
* parent | current | preview * parent | current | preview
* *
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in * Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable* * `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The * nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused. * parent/preview columns are always derived, never focused.
@@ -267,6 +267,9 @@ export function createNavigation() {
/** The tab the root's cursor is hovering (independent of activeTab). */ /** The tab the root's cursor is hovering (independent of activeTab). */
const tabCursor = (): TABS => tabCursorSignal(); const tabCursor = (): TABS => tabCursorSignal();
/** Directly set the root's tab cursor (e.g. a mouse click on a tab row). */
const setTabCursorTo = (tab: TABS) => setTabCursor(tab);
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */ /** Move the root's cursor to the adjacent tab (clamped, no wrap). */
const moveTabCursor = (dir: -1 | 1) => { const moveTabCursor = (dir: -1 | 1) => {
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS); setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
@@ -469,6 +472,7 @@ export function createNavigation() {
enterTabContent, enterTabContent,
backToTabRoot, backToTabRoot,
tabCursor, tabCursor,
setTabCursor: setTabCursorTo,
moveTabCursor, moveTabCursor,
activateTabCursor, activateTabCursor,
// pane focus // pane focus
@@ -488,11 +492,7 @@ export function createNavigation() {
exitVisual, exitVisual,
// modes // modes
setActiveTabSignal: setActiveTab, setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused, setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode, setMode,
enterCommand, enterCommand,
enterInput, enterInput,

View File

@@ -138,7 +138,6 @@ function startPolling(): void {
const progressStore = useProgressStore(); const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
// Update platform media position
const media = useMediaRegistry(); const media = useMediaRegistry();
media.setPosition(pos); media.setPosition(pos);
} }
@@ -215,6 +214,9 @@ async function play(episode: Episode): Promise<void> {
startPolling(); startPolling();
emit("player.play", { episodeId: episode.id }); emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Playback failed"); setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false); setIsPlaying(false);
@@ -285,7 +287,6 @@ async function stop(): Promise<void> {
stopPolling(); stopPolling();
emit("player.stop", {}); emit("player.stop", {});
// Clear platform media controls
const media = useMediaRegistry(); const media = useMediaRegistry();
media.clearNowPlaying(); media.clearNowPlaying();
} catch (err) { } catch (err) {
@@ -332,12 +333,8 @@ async function doSetSpeed(spd: number): Promise<void> {
setSpeed(clamped); setSpeed(clamped);
// Sync back to app store // Sync back to app store
try { const appStore = useAppStore();
const appStore = useAppStore(); appStore.updateSettings({ playbackSpeed: clamped });
appStore.updateSettings({ playbackSpeed: clamped });
} catch {
// Store may not be available
}
} }
async function switchBackend(name: BackendName): Promise<void> { async function switchBackend(name: BackendName): Promise<void> {
@@ -347,14 +344,12 @@ async function switchBackend(name: BackendName): Promise<void> {
const vol = volume(); const vol = volume();
const spd = speed(); const spd = speed();
// Stop current backend
if (backend) { if (backend) {
stopPolling(); stopPolling();
backend.dispose(); backend.dispose();
backend = null; backend = null;
} }
// Create new backend
backend = createAudioBackend(name); backend = createAudioBackend(name);
setBackendName(backend.name); setBackendName(backend.name);
setAvailablePlayers(detectPlayers()); setAvailablePlayers(detectPlayers());
@@ -388,14 +383,10 @@ export function useAudio(): AudioControls {
// Sync initial speed from app store // Sync initial speed from app store
if (refCount === 0) { if (refCount === 0) {
try { const appStore = useAppStore();
const appStore = useAppStore(); const storeSpeed = appStore.state().settings.playbackSpeed;
const storeSpeed = appStore.state().settings.playbackSpeed; if (storeSpeed && storeSpeed !== speed()) {
if (storeSpeed && storeSpeed !== speed()) { setSpeed(storeSpeed);
setSpeed(storeSpeed);
}
} catch {
// Store may not be available yet
} }
} }

View File

@@ -1,34 +0,0 @@
import { createSignal, onCleanup } from "solid-js"
type CacheOptions<T> = {
fetcher: () => Promise<T>
intervalMs?: number
}
export const useCachedData = <T,>(options: CacheOptions<T>) => {
const [data, setData] = createSignal<T | null>(null)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const refresh = async () => {
setLoading(true)
setError(null)
try {
const value = await options.fetcher()
setData(() => value)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data")
} finally {
setLoading(false)
}
}
refresh()
if (options.intervalMs) {
const interval = setInterval(refresh, options.intervalMs)
onCleanup(() => clearInterval(interval))
}
return { data, loading, error, refresh }
}

View File

@@ -0,0 +1,65 @@
/**
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
* focusable renderable) that holds the navigation store's `inputFocused`
* flag true while the renderable has focus.
*
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
* flag to each input's real focus/blur state fixes that.
*
* A module-level counter guards the blur→focus ordering gap that occurs when
* tabbing between two inputs in the same form (the old input blurs before the
* new one focuses) so the flag never flickers off mid-handoff.
*/
import { onCleanup } from "solid-js";
import { RenderableEvents } from "@opentui/core";
import { useNavigation } from "@/context/NavigationContext";
// Inputs (managed by this hook) currently holding focus.
let focusedCount = 0;
export function useInputFocusNav() {
const nav = useNavigation();
let current: any | undefined;
const onFocused = () => {
focusedCount++;
nav.setInputFocused(true);
};
const onBlurred = () => {
focusedCount = Math.max(0, focusedCount - 1);
if (focusedCount === 0) nav.setInputFocused(false);
};
const detach = (el: any) => {
el.off(RenderableEvents.FOCUSED, onFocused);
el.off(RenderableEvents.BLURRED, onBlurred);
// Treat a focused element being torn down as a blur so the counter
// doesn't leak and leave inputFocused stuck on.
if (el.focused) onBlurred();
};
const ref = (el: any) => {
if (current && current !== el) detach(current);
current = el;
if (el) {
el.on(RenderableEvents.FOCUSED, onFocused);
el.on(RenderableEvents.BLURRED, onBlurred);
// If the renderable is already focused when attached, count it.
if (el.focused) onFocused();
}
};
onCleanup(() => {
if (current) {
detach(current);
current = undefined;
}
});
return ref;
}

View File

@@ -21,20 +21,6 @@ export type MediaKeyAction =
| "media.seekBackward" | "media.seekBackward"
| "media.speedCycle"; | "media.speedCycle";
/** Key-to-action mappings for multimedia controls */
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
// Common terminal media keys — these overlap with Player.tsx local
// bindings, but Player guards on `props.focused` so the global
// handler fires independently when the player tab is *not* active.
//
// When Player IS focused both handlers fire, but since the audio
// actions are idempotent (toggle = toggle, seek = additive) having
// them called twice for the same keypress is avoided by the event
// bus approach — the audio hook only processes event-bus events, and
// Player.tsx calls audio methods directly. We therefore guard with
// a "playerFocused" flag passed via options.
};
export interface MultimediaKeysOptions { export interface MultimediaKeysOptions {
/** When true, skip handling (Player.tsx handles keys locally) */ /** When true, skip handling (Player.tsx handles keys locally) */
playerFocused?: () => boolean; playerFocused?: () => boolean;

View File

@@ -1,4 +1,7 @@
const VERSION = "0.2.1"; import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.3.0";
interface CliArgs { interface CliArgs {
version: boolean; version: boolean;
@@ -37,160 +40,173 @@ if (cliArgs.version) {
process.exit(0); process.exit(0);
} }
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
let latest: { feed: Feed; episode: Episode } | null = null
let latestDate = 0
for (const feed of feeds) {
if (feed.episodes.length === 0) continue
const ep = feed.episodes[0]
const epDate =
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
if (epDate > latestDate) {
latestDate = epDate
latest = { feed, episode: ep }
}
}
return latest
}
/** Search feeds by title and print matching shows */
function handleQuery(feeds: Feed[], query: string): void {
const normalizedQuery = query.toLowerCase()
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase()
return title.includes(normalizedQuery)
})
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`)
if (feeds.length > 0) {
console.log("\nAvailable shows:")
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`)
})
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`)
}
}
process.exit(0)
}
if (matches.length === 1) {
const feed = matches[0]
console.log(`\n${feed.podcast.title}`)
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
)
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate)
console.log(` ${idx + 1}. ${ep.title} (${date})`)
})
process.exit(0)
}
console.log(`\nClosest matches for "${query}":`)
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`)
})
process.exit(0)
}
/** Resolve and play an episode from `arg` (title path or "latest") */
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const normalizedArg = arg.toLowerCase()
let feedResult: Feed | null = null
let episodeResult: Episode | null = null
if (normalizedArg === "latest") {
const latest = findLatestEpisode(feeds)
if (latest) {
feedResult = latest.feed
episodeResult = latest.episode
}
} else {
const parts = normalizedArg.split("/")
const showQuery = parts[0]
const episodeQuery = parts[1]
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
)
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`)
process.exit(1)
}
const feed = matchingFeeds[0]
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
console.log(`No episodes available for: ${feed.podcast.title}`)
process.exit(1)
}
} else if (episodeQuery === "latest") {
feedResult = feed
episodeResult = feed.episodes[0]
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
)
if (matchingEpisode) {
feedResult = feed
episodeResult = matchingEpisode
} else {
console.log(`Episode not found: ${episodeQuery}`)
console.log(`Available episodes for ${feed.podcast.title}:`)
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`)
})
process.exit(1)
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play")
process.exit(1)
}
console.log(`\nPlaying: ${episodeResult.title}`)
console.log(`Show: ${feedResult.podcast.title}`)
try {
const { createAudioBackend } = await import("./utils/audio-player")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl)
console.log("Playback started (use the UI to control)")
} else {
console.log("No audio URL available for this episode")
process.exit(1)
}
} catch (err) {
console.error("Playback error:", err)
process.exit(1)
}
}
if (cliArgs.query !== null || cliArgs.play !== null) { if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence") import("./utils/feeds-persistence")
.then(async ({ loadFeedsFromFile }) => { .then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile(); const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) { if (cliArgs.query !== null) {
const query = cliArgs.query; handleQuery(feeds, cliArgs.query)
const normalizedQuery = query.toLowerCase();
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase();
return title.includes(normalizedQuery);
});
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`);
if (feeds.length > 0) {
console.log("\nAvailable shows:");
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`);
});
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`);
}
}
process.exit(0);
}
if (matches.length === 1) {
const feed = matches[0];
console.log(`\n${feed.podcast.title}`);
if (feed.podcast.description) {
console.log(
feed.podcast.description.substring(0, 200) +
(feed.podcast.description.length > 200 ? "..." : ""),
);
}
console.log(
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date =
ep.pubDate instanceof Date
? ep.pubDate.toLocaleDateString()
: String(ep.pubDate);
console.log(` ${idx + 1}. ${ep.title} (${date})`);
});
process.exit(0);
}
console.log(`\nClosest matches for "${query}":`);
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`);
});
process.exit(0);
} }
if (cliArgs.play !== null) { if (cliArgs.play !== null) {
const playArg = cliArgs.play; await handlePlay(feeds, cliArgs.play)
const normalizedArg = playArg.toLowerCase();
let feedResult: (typeof feeds)[0] | null = null;
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
if (normalizedArg === "latest") {
let latestFeed: (typeof feeds)[0] | null = null;
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
let latestDate = 0;
for (const feed of feeds) {
if (feed.episodes.length > 0) {
const ep = feed.episodes[0];
const epDate =
ep.pubDate instanceof Date
? ep.pubDate.getTime()
: Number(ep.pubDate);
if (epDate > latestDate) {
latestDate = epDate;
latestFeed = feed;
latestEpisode = ep;
}
}
}
feedResult = latestFeed;
episodeResult = latestEpisode;
} else {
const parts = normalizedArg.split("/");
const showQuery = parts[0];
const episodeQuery = parts[1];
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery),
);
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`);
process.exit(1);
}
const feed = matchingFeeds[0];
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
console.log(`No episodes available for: ${feed.podcast.title}`);
process.exit(1);
}
} else if (episodeQuery === "latest") {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery),
);
if (matchingEpisode) {
feedResult = feed;
episodeResult = matchingEpisode;
} else {
console.log(`Episode not found: ${episodeQuery}`);
console.log(`Available episodes for ${feed.podcast.title}:`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`);
});
process.exit(1);
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play");
process.exit(1);
}
console.log(`\nPlaying: ${episodeResult.title}`);
console.log(`Show: ${feedResult.podcast.title}`);
try {
const { createAudioBackend } = await import("./utils/audio-player");
const backend = createAudioBackend();
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl);
console.log("Playback started (use the UI to control)");
} else {
console.log("No audio URL available for this episode");
process.exit(1);
}
} catch (err) {
console.error("Playback error:", err);
process.exit(1);
}
} }
}) })
.catch((err) => { .catch((err) => {

View File

@@ -2,7 +2,7 @@
* DiscoverPage — yazi depth-stack view of discoverable podcasts. * DiscoverPage — yazi depth-stack view of discoverable podcasts.
* *
* depth 0 (current) — category list. Parent pane shows the muted * depth 0 (current) — category list. Parent pane shows the muted
* placeholder (1/7 slot kept). * placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent * depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list. * pane = the categories list.
* preview — detail of the hovered item (category summary, or * preview — detail of the hovered item (category summary, or
@@ -146,7 +146,11 @@ function DiscoverPage() {
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
@@ -154,7 +158,6 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`; : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (

View File

@@ -1,85 +0,0 @@
/**
* PodcastCard component - Reusable card for displaying podcast info
*/
import { Show, For } from "solid-js";
import type { Podcast } from "@/types/podcast";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type PodcastCardProps = {
podcast: Podcast;
selected: boolean;
compact?: boolean;
onSelect?: () => void;
onSubscribe?: () => void;
};
export function PodcastCard(props: PodcastCardProps) {
const { theme } = useTheme();
const handleSubscribeClick = () => {
props.onSubscribe?.();
};
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText selected={() => props.selected} primary>
<strong>{props.podcast.title}</strong>
</SelectableText>
<Show when={props.podcast.isSubscribed}>
<text fg={theme.success}>[+]</text>
</Show>
</box>
{/* Author */}
<Show when={props.podcast.author && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {props.podcast.author}
</SelectableText>
</Show>
{/* Description */}
<Show when={props.podcast.description && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
{props.podcast.description!.length > 80
? props.podcast.description!.slice(0, 80) + "..."
: props.podcast.description}
</SelectableText>
</Show>
{/**<box
flexDirection="row"
justifyContent="space-between"
marginTop={props.compact ? 0 : 1}
/>**/}
<box flexDirection="row" gap={1}>
<Show when={(props.podcast.categories ?? []).length > 0}>
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</Show>
</box>
<Show when={props.selected}>
<box onMouseDown={handleSubscribeClick}>
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,194 +0,0 @@
/**
* Feed detail view component for PodTUI
* Shows podcast info and episode list
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import type { Feed } from "@/types/feed";
import type { Episode } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedDetailProps {
feed: Feed;
focused?: boolean;
onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void;
}
export function FeedDetail(props: FeedDetailProps) {
const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [showInfo, setShowInfo] = createSignal(true);
const episodes = () => {
// Sort episodes by publication date (newest first)
return [...props.feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.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`;
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const handleKeyPress = (key: { name: string }) => {
const eps = episodes();
if (key.name === "escape" && props.onBack) {
props.onBack();
return;
}
if (key.name === "i") {
setShowInfo((v) => !v);
return;
}
if (key.name === "v") {
props.feed.podcast.onToggleVisibility?.(props.feed.id);
return;
}
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return") {
const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
return (
<box flexDirection="column" gap={1}>
{/* Header with back button */}
<box flexDirection="row" justifyContent="space-between">
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
</box>
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
</box>
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
</box>
</box>
{/* Podcast info section */}
<Show when={showInfo()}>
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<SelectableText selected={() => false} primary>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
{props.feed.podcast.author && (
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>by</SelectableText>
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
</box>
)}
<box height={1} />
<SelectableText selected={() => false} tertiary>
{props.feed.podcast.description?.slice(0, 200)}
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
</SelectableText>
<box height={1} />
<box flexDirection="row" gap={2}>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
</box>
<SelectableText selected={() => false} tertiary>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</SelectableText>
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
</box>
</box>
</Show>
{/* Episodes header */}
<box flexDirection="row" justifyContent="space-between">
<SelectableText selected={() => false} primary>
<strong>Episodes</strong>
</SelectableText>
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
</box>
{/* Episode list */}
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
<For each={episodes()}>
{(episode, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
if (props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
}}
>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{index() === selectedIndex() ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
{episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
</box>
</SelectableBox>
)}
</For>
</scrollbox>
{/* Help text */}
<text fg={theme.textMuted}>
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text>
</box>
);
}

View File

@@ -1,207 +0,0 @@
/**
* Feed filter component for PodTUI
* Toggle and filter options for feed list
*/
import { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedFilterProps {
filter: FeedFilter;
focused?: boolean;
onFilterChange: (filter: FeedFilter) => void;
}
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
export function FeedFilterComponent(props: FeedFilterProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "visibility") {
cycleVisibility();
} else if (focusField() === "sort") {
cycleSort();
} else if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
}
};
const cycleVisibility = () => {
const current = props.filter.visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
props.onFilterChange({ ...props.filter, visibility: next });
};
const cycleSort = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const currentIndex = sortOptions.indexOf(
props.filter.sortBy as FeedSortField,
);
const nextIndex = (currentIndex + 1) % sortOptions.length;
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
};
const togglePinned = () => {
props.onFilterChange({
...props.filter,
pinnedOnly: !props.filter.pinnedOnly,
});
};
const togglePrivate = () => {
props.onFilterChange({
...props.filter,
showPrivate: !props.filter.showPrivate,
});
};
const handleSearchInput = (value: string) => {
setSearchValue(value);
props.onFilterChange({ ...props.filter, searchQuery: value });
};
const visibilityLabel = () => {
const vis = props.filter.visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const visibilityColor = () => {
const vis = props.filter.visibility;
if (vis === "public") return theme.success;
if (vis === "private") return theme.warning;
return theme.text;
};
const sortLabel = () => {
const sort = props.filter.sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
case "updated":
default:
return "Updated";
}
};
return (
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Filter Feeds</strong>
</text>
<box flexDirection="row" gap={2} flexWrap="wrap">
{/* Visibility filter */}
<box
border
padding={0}
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
borderColor={theme.border}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
{/* Sort filter */}
<box
border
padding={0}
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
<text fg={theme.text}>{sortLabel()}</text>
</box>
</box>
{/* Pinned filter */}
<box
border
padding={0}
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</text>
</box>
</box>
{/* Private filter */}
<box
border
padding={0}
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
Private:
</text>
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
{props.filter.showPrivate ? "Yes" : "No"}
</text>
</box>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
<input
value={searchValue()}
onInput={handleSearchInput}
placeholder="Filter by name..."
focused={props.focused && focusField() === "search"}
width={25}
/>
</box>
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
</box>
);
}

View File

@@ -1,154 +0,0 @@
/**
* Feed item component for PodTUI
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedItemProps {
feed: Feed;
isSelected: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
compact?: boolean;
}
export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => {
return format(date, "MMM d");
};
const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length;
};
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]";
};
const visibilityColor = () => {
return props.feed.visibility === "public" ? theme.success : theme.warning;
};
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " ";
};
const { theme } = useTheme();
if (props.compact) {
// Compact single-line view
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => {}}
>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.feed.customName || props.feed.podcast.title}
</SelectableText>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
({episodeCount()})
</SelectableText>
)}
</SelectableBox>
);
}
// Full view with details
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {}}
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
secondary
>
{pinnedIndicator()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
</box>
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
{episodeCount()} episodes ({unplayedCount()} new)
</SelectableText>
)}
{props.showLastUpdated && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
Updated: {formatDate(props.feed.lastUpdated)}
</SelectableText>
)}
</box>
{props.feed.podcast.description && (
<SelectableText
selected={() => props.isSelected}
paddingLeft={4}
paddingTop={0}
tertiary
>
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</SelectableText>
)}
</SelectableBox>
);
}

View File

@@ -1,198 +0,0 @@
/**
* Feed list component for PodTUI
* Scrollable list of feeds with keyboard navigation and mouse support
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { FeedItem } from "./FeedItem";
import { useFeedStore } from "@/stores/feed";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedListProps {
focused?: boolean;
compact?: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
onSelectFeed?: (feed: Feed) => void;
onOpenFeed?: (feed: Feed) => void;
onFocusChange?: (focused: boolean) => void;
}
export function FeedList(props: FeedListProps) {
const { theme } = useTheme();
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false);
return;
}
const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return") {
const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") {
// Toggle pin on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
feedStore.togglePinned(feed.id);
}
} else if (key.name === "v") {
// Toggle visibility on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
feedStore.updateFeed(feed.id, { visibility: newVisibility });
}
} else if (key.name === "f") {
// Cycle visibility filter
cycleVisibilityFilter();
} else if (key.name === "s") {
// Cycle sort
cycleSortField();
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed);
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
};
const cycleSortField = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const current = feedStore.filter().sortBy as FeedSortField;
const idx = sortOptions.indexOf(current);
const next = sortOptions[(idx + 1) % sortOptions.length];
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
};
const visibilityLabel = () => {
const vis = feedStore.filter().visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const sortLabel = () => {
const sort = feedStore.filter().sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
}
};
const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index);
if (props.onSelectFeed) {
props.onSelectFeed(feed);
}
};
const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) {
props.onOpenFeed(feed);
}
};
return (
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text fg={theme.text}>
<strong>My Feeds</strong>
</text>
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
</box>
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
<text fg={theme.primary}>[s] {sortLabel()}</text>
</box>
</box>
</box>
{/* Feed list in scrollbox */}
<Show
when={filteredFeeds().length > 0}
fallback={
<box border padding={2} borderColor={theme.border}>
<text fg={theme.textMuted}>
No feeds found. Add podcasts from the Discover or Search tabs.
</text>
</box>
}
>
<scrollbox height={15} focused={props.focused}>
<For each={filteredFeeds()}>
{(feed, index) => (
<box onMouseDown={() => handleFeedClick(feed, index())}>
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
compact={props.compact}
showEpisodeCount={props.showEpisodeCount ?? true}
showLastUpdated={props.showLastUpdated ?? true}
/>
</box>
)}
</For>
</scrollbox>
</Show>
{/* Navigation help */}
<box paddingTop={0}>
<text fg={theme.textMuted}>
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
</text>
</box>
</box>
);
}

View File

@@ -168,7 +168,11 @@ function FeedPage() {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text; i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`; const currentLabel = () => `Feed · ${episodes().length}`;

View File

@@ -2,7 +2,7 @@
* MyShowsPage — yazi depth-stack view of subscribed shows. * MyShowsPage — yazi depth-stack view of subscribed shows.
* *
* depth 0 (current) — subscribed shows. Parent pane shows the muted * depth 0 (current) — subscribed shows. Parent pane shows the muted
* placeholder (1/7 slot kept). * placeholder (1/5 slot kept).
* depth 1 (current) — episodes of the drilled show. Parent pane = shows. * depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column. * preview — detail of the hovered item in the current column.
* *
@@ -199,7 +199,11 @@ export function MyShowsPage() {
const focusBg = (i: number, lf: number, active: boolean) => const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined; i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) => const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.surface : theme.text; i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title; const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () => const currentLabel = () =>
@@ -208,7 +212,6 @@ export function MyShowsPage() {
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`; : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
// Stable <Show> gate (not a ternary root swap) so the parent list // Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change. // mounts/unmounts cleanly on depth change.
const parentContent = () => ( const parentContent = () => (

View File

@@ -116,7 +116,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
} }
reader.start(position, speed); reader.start(position, speed);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL); frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
}; };
@@ -140,11 +139,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const renderFrame = () => { const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return; if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer); const count = reader.read(sampleBuffer);
if (count === 0) return; if (count === 0) return;
// Feed samples to cavacore → get frequency bars
const input = const input =
count < sampleBuffer.length count < sampleBuffer.length
? sampleBuffer.subarray(0, count) ? sampleBuffer.subarray(0, count)
@@ -198,7 +195,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}), }),
); );
// Cleanup on unmount
onCleanup(() => { onCleanup(() => {
stopVisualization(); stopVisualization();
if (reader) { if (reader) {
@@ -222,7 +218,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const bars = barData(); const bars = barData();
const count = numBars(); const count = numBars();
// If no data yet, show empty placeholder
if (bars.length === 0) { if (bars.length === 0) {
const placeholder = ".".repeat(count); const placeholder = ".".repeat(count);
return ( return (

View File

@@ -1,95 +0,0 @@
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type ResultCardProps = {
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const { theme } = useTheme();
const podcast = () => props.result.podcast;
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText
selected={() => props.selected}
primary
>
<strong>{podcast().title}</strong>
</SelectableText>
<SourceBadge
sourceId={props.result.sourceId}
sourceName={props.result.sourceName}
sourceType={props.result.sourceType}
/>
</box>
<Show when={podcast().isSubscribed}>
<text fg={theme.success}>[Subscribed]</text>
</Show>
</box>
<Show when={podcast().author}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {podcast().author}
</SelectableText>
</Show>
<Show when={podcast().description}>
{(description) => (
<SelectableText
selected={() => props.selected}
tertiary
>
{description().length > 120
? description().slice(0, 120) + "..."
: description()}
</SelectableText>
)}
</Show>
<Show when={(podcast().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(podcast().categories ?? []).slice(0, 3).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<Show when={!podcast().isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -1,75 +0,0 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
const { theme } = useTheme();
return (
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
<Show
when={props.result}
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
>
{(result) => (
<>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg={theme.textMuted}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textMuted}>{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
<text fg={theme.textMuted}>
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
</text>
<Show when={!result().podcast.isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={() => props.onSubscribe?.(result())}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -1,89 +0,0 @@
/**
* SearchHistory component for displaying and managing search history
*/
import { For, Show } from "solid-js"
import { useTheme } from "@/context/ThemeContext"
import { SelectableBox, SelectableText } from "@/components/Selectable"
type SearchHistoryProps = {
history: string[]
focused: boolean
selectedIndex: number
onSelect?: (query: string) => void
onRemove?: (query: string) => void
onClear?: () => void
onChange?: (index: number) => void
}
export function SearchHistory(props: SearchHistoryProps) {
const { theme } = useTheme();
const handleSearchClick = (index: number, query: string) => {
props.onChange?.(index)
props.onSelect?.(query)
}
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text fg={theme.error}>[Clear All]</text>
</box>
</Show>
</box>
<Show
when={props.history.length > 0}
fallback={
<box padding={1}>
<text fg={theme.textMuted}>No recent searches</text>
</box>
}
>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
const isSelected = () => index() === props.selectedIndex && props.focused
return (
<SelectableBox
selected={isSelected}
flexDirection="row"
justifyContent="space-between"
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => handleSearchClick(index(), query)}
>
<SelectableText
selected={isSelected}
tertiary
>
{">"}
</SelectableText>
<SelectableText
selected={isSelected}
primary
>
{query}
</SelectableText>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg={theme.error}>[x]</text>
</box>
</SelectableBox>
)
}}
</For>
</box>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -205,7 +205,11 @@ function SearchPage() {
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, listFocus: number, active: boolean) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text; i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
// ── parent pane: previous-depth content (tab list at depth 0) ────────────── // ── parent pane: previous-depth content (tab list at depth 0) ──────────────
const parentContent = () => ( const parentContent = () => (
@@ -385,8 +389,7 @@ function SearchPage() {
</Show> </Show>
<Show when={result().podcast.description}> <Show when={result().podcast.description}>
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400) ?? {result().podcast.description!.slice(0, 400)}
"No description available."}
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""} {(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
</Show> </Show>

View File

@@ -1,80 +0,0 @@
/**
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index);
};
return (
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
);
}

View File

@@ -1,38 +0,0 @@
import { SourceType } from "@/types/source";
import { useTheme } from "@/context/ThemeContext";
type SourceBadgeProps = {
sourceId: string;
sourceName?: string;
sourceType?: SourceType;
};
const typeLabel = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "API";
if (sourceType === SourceType.RSS) return "RSS";
if (sourceType === SourceType.CUSTOM) return "Custom";
return "Source";
};
// No module-level typeColor here — it needs the theme from the component.
// The correct definition lives inside SourceBadge below.
export function SourceBadge(props: SourceBadgeProps) {
const { theme } = useTheme();
const label = () => props.sourceName || props.sourceId;
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return theme.primary;
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg={theme.textMuted}>{label()}</text>
</box>
);
}

View File

@@ -1,38 +1,55 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => { const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value let current = value;
return [() => current, (next) => { return [
current = next () => current,
}] (next) => {
} current = next;
},
];
};
import { SyncStatus } from "./SyncStatus" import { SyncStatus } from "./SyncStatus";
import { useTheme } from "@/context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export function ExportDialog() { export function ExportDialog() {
const { theme } = useTheme(); const { theme } = useTheme();
const filename = createSignal("podcast-sync.json") const filename = createSignal("podcast-sync.json");
const format = createSignal<"json" | "xml">("json") const format = createSignal<"json" | "xml">("json");
// Yield navigation keybinds to the Shell router while the input is focused.
const filenameRef = useInputFocusNav();
return ( return (
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}> <box
<box style={{ flexDirection: "row", gap: 1 }}> border
<text fg={theme.text}>File:</text> title="Export"
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} /> style={{ padding: 1, flexDirection: "column", gap: 1 }}
</box> >
<box style={{ flexDirection: "row", gap: 1 }}> <box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text> <text fg={theme.text}>File:</text>
<tab_select <input
options={[ ref={filenameRef}
{ name: "JSON", description: "Portable" }, value={filename[0]()}
{ name: "XML", description: "Structured" }, onInput={filename[1]}
]} style={{ width: 30 }}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")} />
/> </box>
</box> <box style={{ flexDirection: "row", gap: 1 }}>
<box border borderColor={theme.border}> <text fg={theme.text}>Format:</text>
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text> <tab_select
</box> options={[
<SyncStatus /> { name: "JSON", description: "Portable" },
</box> { name: "XML", description: "Structured" },
) ]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>
Export {format[0]()} to {filename[0]()}
</text>
</box>
<SyncStatus />
</box>
);
} }

View File

@@ -1,24 +1,28 @@
import { detectFormat } from "@/utils/file-detector"; import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = { type FilePickerProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
export function FilePicker(props: FilePickerProps) { export function FilePicker(props: FilePickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const format = detectFormat(props.value); // Yield navigation keybinds to the Shell router while the input is focused.
const inputRef = useInputFocusNav();
const format = detectFormat(props.value);
return ( return (
<box style={{ flexDirection: "column", gap: 1 }}> <box style={{ flexDirection: "column", gap: 1 }}>
<input <input
value={props.value} ref={inputRef}
onInput={props.onChange} value={props.value}
placeholder="/path/to/sync-file.json" onInput={props.onChange}
style={{ width: 40 }} placeholder="/path/to/sync-file.json"
/> style={{ width: 40 }}
<text fg={theme.text}>Format: {format}</text> />
</box> <text fg={theme.text}>Format: {format}</text>
); </box>
);
} }

View File

@@ -39,6 +39,19 @@ export function usePreferencesItems(): SettingItem[] {
app.setTheme(THEME_LABELS[next].value); app.setTheme(THEME_LABELS[next].value);
}, },
}, },
{
id: "transparentBackground",
label: "Transparent Background",
kind: "toggle",
display: () =>
settings().transparentBackground ? "On" : "Off",
help: () =>
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateSettings({
transparentBackground: !settings().transparentBackground,
}),
},
{ {
id: "fontSize", id: "fontSize",
label: "Font Size", label: "Font Size",
@@ -90,5 +103,17 @@ export function usePreferencesItems(): SettingItem[] {
autoDownload: !prefs().autoDownload, autoDownload: !prefs().autoDownload,
}), }),
}, },
{
id: "autoJumpToPlayer",
label: "Auto Jump to Player",
kind: "toggle",
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
help: () =>
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
]; ];
} }

View File

@@ -7,7 +7,7 @@
* *
* Renders entirely through `<PaneRow>` (parent | current | preview): * Renders entirely through `<PaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2); * parent = previous depth's list (sections at depth 1, items at depth 2);
* blank placeholder at depth 0 (1/7 slot kept). * blank placeholder at depth 0 (1/5 slot kept).
* current = the current-depth list (or editor at depth 2); the only * current = the current-depth list (or editor at depth 2); the only
* focusable column. * focusable column.
* preview = help/preview text for the hovered item in current. * preview = help/preview text for the hovered item in current.
@@ -424,7 +424,12 @@ function Row(props: {
: props.focused : props.focused
? theme.border ? theme.border
: undefined; : undefined;
const fg = () => (props.focused && props.active ? theme.surface : theme.text); const fg = () =>
props.focused && props.active
? theme.surface
: props.focused
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused); const ref = useScrollIntoView(() => props.focused);
return ( return (
<box <box

View File

@@ -14,6 +14,7 @@
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { SourceType } from "@/types/source"; import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source"; import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
@@ -61,6 +62,9 @@ function AddSourceForm() {
const [name, setName] = createSignal(""); const [name, setName] = createSignal("");
const [url, setUrl] = createSignal(""); const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
// Yield navigation keybinds to the Shell router while either input is focused.
const nameRef = useInputFocusNav();
const urlRef = useInputFocusNav();
const submit = () => { const submit = () => {
const u = url().trim(); const u = url().trim();
@@ -94,6 +98,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Name:</text> <text fg={theme.textMuted}>Name:</text>
<input <input
ref={nameRef}
value={name()} value={name()}
onInput={setName} onInput={setName}
placeholder="My Custom Feed" placeholder="My Custom Feed"
@@ -103,6 +108,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>URL:</text> <text fg={theme.textMuted}>URL:</text>
<input <input
ref={urlRef}
value={url()} value={url()}
onInput={(v) => { onInput={(v) => {
setUrl(v); setUrl(v);

View File

@@ -3,21 +3,13 @@
* Export dialogs render as depth-2 editors. No own useKeyboard. * Export dialogs render as depth-2 editors. No own useKeyboard.
*/ */
import { createSignal } from "solid-js";
import { ImportDialog } from "./ImportDialog"; import { ImportDialog } from "./ImportDialog";
import { ExportDialog } from "./ExportDialog"; import { ExportDialog } from "./ExportDialog";
import { SyncStatus } from "./SyncStatus";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
// Module-level state so the action items can open their dialogs as depth-2 // closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show. // (the syncEditor signal) was removed as dead — nothing ever read it.
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>( export function closeSyncEditor() {}
null,
);
export { syncEditor };
export function closeSyncEditor() {
setSyncEditor(null);
}
export function useSyncItems(): SettingItem[] { export function useSyncItems(): SettingItem[] {
return [ return [
@@ -49,9 +41,3 @@ export function useSyncItems(): SettingItem[] {
}, },
]; ];
} }
/** Renders the live sync status block (used by the Settings page header for the
* Sync section, when relevant). */
export function SyncStatusBlock() {
return <SyncStatus />;
}

View File

@@ -29,12 +29,14 @@ const defaultSettings: AppSettings = {
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
}; };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
autoJumpToPlayer: true,
}; };
const defaultState: AppState = { const defaultState: AppState = {
@@ -43,7 +45,7 @@ const defaultState: AppState = {
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
}; };
export function createAppStore() { function createAppStore() {
// Start with defaults; async load will update once ready // Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState); const [state, setState] = createSignal<AppState>(defaultState);

View File

@@ -36,7 +36,7 @@ const defaultNavState: AudioNavState = {
}; };
/** Create audio navigation store */ /** Create audio navigation store */
export function createAudioNavStore() { function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState); const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
/** Persist current navigation state to file (fire-and-forget) */ /** Persist current navigation state to file (fire-and-forget) */

View File

@@ -127,7 +127,6 @@ export function createDiscoverStore() {
return; return;
} }
// Build the podcast list from the manifest entries
const fetched = manifest.podcasts.map(entryToPodcast); const fetched = manifest.podcasts.map(entryToPodcast);
cachedAt = now; cachedAt = now;
setPodcasts(fetched); setPodcasts(fetched);
@@ -173,7 +172,6 @@ export function createDiscoverStore() {
const unsubscribe = (podcastId: string) => { const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId); const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) { if (podcast) {
// Remove the feed from the feed store
const feedStore = useFeedStore(); const feedStore = useFeedStore();
feedStore.removeFeedByUrl(podcast.feedUrl); feedStore.removeFeedByUrl(podcast.feedUrl);
} }

View File

@@ -38,7 +38,7 @@ interface QueueItem {
} }
/** Create download store */ /** Create download store */
export function createDownloadStore() { function createDownloadStore() {
const [downloads, setDownloads] = createSignal< const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode> Map<string, DownloadedEpisode>
>(new Map()); >(new Map());
@@ -48,7 +48,6 @@ export function createDownloadStore() {
/** Active AbortControllers keyed by episodeId */ /** Active AbortControllers keyed by episodeId */
const abortControllers = new Map<string, AbortController>(); const abortControllers = new Map<string, AbortController>();
// Load persisted downloads on init
(async () => { (async () => {
const loaded = await loadDownloads(); const loaded = await loadDownloads();
if (loaded.size > 0) setDownloads(loaded); if (loaded.size > 0) setDownloads(loaded);
@@ -153,7 +152,6 @@ export function createDownloadStore() {
const slotsAvailable = MAX_CONCURRENT - current; const slotsAvailable = MAX_CONCURRENT - current;
const toStart = q.slice(0, slotsAvailable); const toStart = q.slice(0, slotsAvailable);
// Remove started items from queue
if (toStart.length > 0) { if (toStart.length > 0) {
setQueue((prev) => prev.slice(toStart.length)); setQueue((prev) => prev.slice(toStart.length));
} }
@@ -250,7 +248,6 @@ export function createDownloadStore() {
return; // Already downloading or queued return; // Already downloading or queued
} }
// Create download entry
const entry: DownloadedEpisode = { const entry: DownloadedEpisode = {
episodeId: episode.id, episodeId: episode.id,
feedId, feedId,
@@ -269,7 +266,6 @@ export function createDownloadStore() {
return next; return next;
}); });
// Add to queue
const queueItem: QueueItem = { const queueItem: QueueItem = {
episodeId: episode.id, episodeId: episode.id,
feedId, feedId,
@@ -291,10 +287,8 @@ export function createDownloadStore() {
abortControllers.delete(episodeId); abortControllers.delete(episodeId);
} }
// Remove from queue
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId)); setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
// Update status
updateDownload(episodeId, { updateDownload(episodeId, {
status: DownloadStatus.NONE, status: DownloadStatus.NONE,
progress: 0, progress: 0,

View File

@@ -43,7 +43,7 @@ function saveSources(sources: PodcastSource[]): void {
} }
/** Create feed store */ /** Create feed store */
export function createFeedStore() { function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]); const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([ const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES, ...DEFAULT_SOURCES,
@@ -62,22 +62,18 @@ export function createFeedStore() {
let result = [...feeds()]; let result = [...feeds()];
const f = filter(); const f = filter();
// Filter by visibility
if (f.visibility && f.visibility !== "all") { if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility); result = result.filter((feed) => feed.visibility === f.visibility);
} }
// Filter by source
if (f.sourceId) { if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId); result = result.filter((feed) => feed.sourceId === f.sourceId);
} }
// Filter by pinned
if (f.pinnedOnly) { if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned); result = result.filter((feed) => feed.isPinned);
} }
// Filter by search query
if (f.searchQuery) { if (f.searchQuery) {
const query = f.searchQuery.toLowerCase(); const query = f.searchQuery.toLowerCase();
result = result.filter( result = result.filter(
@@ -88,7 +84,6 @@ export function createFeedStore() {
); );
} }
// Sort by selected field
const sortDir = f.sortDirection === "asc" ? 1 : -1; const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => { result.sort((a, b) => {
switch (f.sortBy) { switch (f.sortBy) {
@@ -111,7 +106,6 @@ export function createFeedStore() {
} }
}); });
// Pinned feeds always first
result.sort((a, b) => { result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1; if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1; if (!a.isPinned && b.isPinned) return 1;
@@ -224,25 +218,21 @@ export function createFeedStore() {
newEpisodes: Episode[], newEpisodes: Episode[],
count: number, count: number,
) => { ) => {
try { const dlStore = useDownloadStore();
const dlStore = useDownloadStore(); // Sort by pubDate descending (newest first)
// Sort by pubDate descending (newest first) const sorted = [...newEpisodes].sort(
const sorted = [...newEpisodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), );
); // count = 0 means download all new episodes
// count = 0 means download all new episodes const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
const toDownload = count > 0 ? sorted.slice(0, count) : sorted; for (const ep of toDownload) {
for (const ep of toDownload) { const status = dlStore.getDownloadStatus(ep.id);
const status = dlStore.getDownloadStatus(ep.id); if (
if ( status === DownloadStatus.NONE ||
status === DownloadStatus.NONE || status === DownloadStatus.FAILED
status === DownloadStatus.FAILED ) {
) { dlStore.startDownload(ep, feedId);
dlStore.startDownload(ep, feedId);
}
} }
} catch {
// Download store may not be available yet
} }
}; };

View File

@@ -4,7 +4,7 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search"; import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source"; import type { SearchResult } from "../types/source";
@@ -80,10 +80,18 @@ export function createSearchStore() {
setIsSearching(true); setIsSearching(true);
setError(null); setError(null);
// Add to history
addToHistory(q); addToHistory(q);
try { try {
// A query that is a direct RSS feed URL (e.g. a private feed that
// isn't in any public directory) resolves to that feed directly,
// independent of enabled search sources.
const urlResults = await searchByFeedUrl(q);
if (urlResults.length > 0) {
setResults(applySubscribedStatus(urlResults));
return;
}
const sources = feedStore.sources(); const sources = feedStore.sources();
const enabledSourceIds = sources const enabledSourceIds = sources
.filter((s) => s.enabled) .filter((s) => s.enabled)
@@ -122,7 +130,6 @@ export function createSearchStore() {
/** Add query to history */ /** Add query to history */
const addToHistory = (q: string) => { const addToHistory = (q: string) => {
setHistory((prev) => { setHistory((prev) => {
// Remove duplicates and add to front
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()); const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY); const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated); saveHistory(updated);

View File

@@ -1,12 +1,4 @@
import type { import type { ThemeColors } from "../types/settings"
DesktopTheme,
ThemeColors,
ThemeDefinition,
ThemeName,
ThemeToken,
ThemeVariant,
} from "../types/settings"
import type { ColorValue } from "./theme-schema"
// Base theme colors // Base theme colors
export const BASE_THEME_COLORS: ThemeColors = { export const BASE_THEME_COLORS: ThemeColors = {
@@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
layer2: "#161b22", layer2: "#161b22",
layer3: "#0d1117", layer3: "#0d1117",
} }
// Theme tokens
export const BASE_THEME_TOKENS: ThemeToken = {
"background": "transparent",
"surface": "#1b1f27",
"primary": "#6fa8ff",
"secondary": "#a9b1d6",
"accent": "#f6c177",
"text": "#e6edf3",
"muted": "#7d8590",
"warning": "#f0b429",
"error": "#f47067",
"success": "#3fb950",
"layer0": "transparent",
"layer1": "#1e222e",
"layer2": "#161b22",
"layer3": "#0d1117",
}
// Desktop theme structure
export const THEMES_DESKTOP: DesktopTheme = {
name: "PodTUI",
variants: [
{
name: "catppuccin",
colors: {
background: "transparent",
surface: "#1e1e2e",
primary: "#89b4fa",
secondary: "#cba6f7",
accent: "#f9e2af",
text: "#cdd6f4",
textPrimary: "#cdd6f4",
textSecondary: "#cba6f7",
textTertiary: "#7f849c",
textSelectedPrimary: "#1e1e2e",
textSelectedSecondary: "#cdd6f4",
textSelectedTertiary: "#cba6f7",
muted: "#7f849c",
warning: "#fab387",
error: "#f38ba8",
success: "#a6e3a1",
layerBackgrounds: {
layer0: "transparent",
layer1: "#181825",
layer2: "#11111b",
layer3: "#0a0a0f",
},
},
},
{
name: "gruvbox",
colors: {
background: "transparent",
surface: "#282828",
primary: "#fabd2f",
secondary: "#83a598",
accent: "#fe8019",
text: "#ebdbb2",
textPrimary: "#ebdbb2",
textSecondary: "#83a598",
textTertiary: "#928374",
textSelectedPrimary: "#282828",
textSelectedSecondary: "#ebdbb2",
textSelectedTertiary: "#83a598",
muted: "#928374",
warning: "#fabd2f",
error: "#fb4934",
success: "#b8bb26",
layerBackgrounds: {
layer0: "transparent",
layer1: "#32302a",
layer2: "#1d2021",
layer3: "#0d0c0c",
},
},
},
{
name: "tokyo",
colors: {
background: "transparent",
surface: "#1a1b26",
primary: "#7aa2f7",
secondary: "#bb9af7",
accent: "#e0af68",
text: "#c0caf5",
textPrimary: "#c0caf5",
textSecondary: "#bb9af7",
textTertiary: "#565f89",
textSelectedPrimary: "#1a1b26",
textSelectedSecondary: "#c0caf5",
textSelectedTertiary: "#bb9af7",
muted: "#565f89",
warning: "#e0af68",
error: "#f7768e",
success: "#9ece6a",
layerBackgrounds: {
layer0: "transparent",
layer1: "#16161e",
layer2: "#0f0f15",
layer3: "#08080b",
},
},
},
{
name: "nord",
colors: {
background: "transparent",
surface: "#2e3440",
primary: "#88c0d0",
secondary: "#81a1c1",
accent: "#ebcb8b",
text: "#eceff4",
textPrimary: "#eceff4",
textSecondary: "#81a1c1",
textTertiary: "#4c566a",
textSelectedPrimary: "#2e3440",
textSelectedSecondary: "#eceff4",
textSelectedTertiary: "#81a1c1",
muted: "#4c566a",
warning: "#ebcb8b",
error: "#bf616a",
success: "#a3be8c",
layerBackgrounds: {
layer0: "transparent",
layer1: "#3b4252",
layer2: "#242933",
layer3: "#1a1c23",
},
},
},
],
defaultVariant: "catppuccin",
tokens: BASE_THEME_TOKENS,
}
// Helper function to get theme by name
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
}
// Helper function to get default theme
export function getDefaultTheme(): ThemeVariant {
return THEMES_DESKTOP.variants.find(
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
)!
}
export type ThemeJsonFile = ThemeDefinition
export function isColorReference(value: ColorValue): value is string {
return typeof value === "string" && !value.startsWith("#")
}

View File

@@ -97,14 +97,6 @@ export interface FeedListOptions {
compact: boolean compact: boolean
} }
/** Default feed list options */
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
showEpisodeCount: true,
showLastUpdated: true,
showSource: false,
compact: false,
}
/** Feed statistics */ /** Feed statistics */
export interface FeedStats { export interface FeedStats {
/** Total feed count */ /** Total feed count */

View File

@@ -79,12 +79,16 @@ export type AppSettings = {
fontSize: number; fontSize: number;
playbackSpeed: number; playbackSpeed: number;
downloadPath: string; downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
visualizer: VisualizerSettings; visualizer: VisualizerSettings;
}; };
export type UserPreferences = { export type UserPreferences = {
showExplicit: boolean; showExplicit: boolean;
autoDownload: boolean; autoDownload: boolean;
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
}; };
export type AppState = { export type AppState = {

View File

@@ -1,24 +0,0 @@
export type SyncData = {
version: string
lastSyncedAt: string
feeds: {
id: string
title: string
url: string
isPrivate: boolean
}[]
sources: {
id: string
name: string
url: string
}[]
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

View File

@@ -1,28 +0,0 @@
export type SyncDataXML = {
version: string
lastSyncedAt: string
feeds: {
feed: {
id: string
title: string
url: string
isPrivate: boolean
}[]
}
sources: {
source: {
id: string
name: string
url: string
}[]
}
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

View File

@@ -13,10 +13,12 @@ export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeJson = { export type ThemeJson = {
$schema?: string $schema?: string
defs?: Record<string, HexColor | RefName> defs?: Record<string, HexColor | RefName>
theme: Record<string, ColorValue> & { theme: Record<string, ColorValue | boolean> & {
selectedListItemText?: ColorValue selectedListItemText?: ColorValue
backgroundMenu?: ColorValue backgroundMenu?: ColorValue
thinkingOpacity?: number thinkingOpacity?: number
/** Render the app background transparent (let the terminal's own bg show). */
transparent?: boolean
} }
} }

View File

@@ -85,7 +85,6 @@ function init() {
); );
const suspended = () => suspendCount() > 0; const suspended = () => suspendCount() > 0;
// Handle keybind shortcuts
useKeyboard((evt) => { useKeyboard((evt) => {
if (suspended()) return; if (suspended()) return;
if (dialog.isOpen) return; if (dialog.isOpen) return;
@@ -180,9 +179,8 @@ export function CommandProvider(props: ParentProps) {
const dialog = useDialog(); const dialog = useDialog();
const keybind = useKeybinds(); const keybind = useKeybinds();
// Open the command palette via the `command` keybind (bound to `:` in // Open the command palette via the `command` keybind (bound to `:` or `q`
// keybinds.jsonc). The old hardcoded "command_list" name was never a // in keybinds.jsonc; the Shell router owns the action and runs it first).
// canonical action, so the palette was unreachable dead code.
useKeyboard((evt) => { useKeyboard((evt) => {
if (value.suspended()) return; if (value.suspended()) return;
if (dialog.isOpen) return; if (dialog.isOpen) return;
@@ -258,7 +256,6 @@ function CommandDialog(props: {
return; return;
} }
// Handle text input
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
setFilter((f) => f + evt.name); setFilter((f) => f + evt.name);
return; return;

View File

@@ -12,7 +12,7 @@ export type DialogSize = "medium" | "large"
/** /**
* Dialog component that renders a modal overlay with content. * Dialog component that renders a modal overlay with content.
*/ */
export function Dialog( function Dialog(
props: ParentProps<{ props: ParentProps<{
size?: DialogSize size?: DialogSize
onClose: () => void onClose: () => void

View File

@@ -32,12 +32,14 @@ const defaultSettings: AppSettings = {
fontSize: 14, fontSize: 14,
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
transparentBackground: false,
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
}; };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
autoJumpToPlayer: true,
}; };
const defaultState: AppState = { const defaultState: AppState = {

View File

@@ -135,10 +135,8 @@ export class AudioStreamReader {
this.writePos = 0; this.writePos = 0;
this.totalSamplesWritten = 0; this.totalSamplesWritten = 0;
// Capture generation for this run
const myGeneration = this.generation; const myGeneration = this.generation;
// Start async reading loop
this.readLoop(myGeneration); this.readLoop(myGeneration);
// Detect process exit // Detect process exit

View File

@@ -1,103 +0,0 @@
/**
* Audio waveform analysis for PodTUI
*
* Extracts amplitude data from audio files using ffmpeg (when available)
* Results are cache in-memory keyed by audio URL.
*/
/** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>();
/**
* Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails.
*/
async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try {
if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn(
[
"ffmpeg",
"-i",
audioUrl,
"-t",
"300",
"-ac",
"1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-",
],
{ stdout: "pipe", stderr: "ignore" },
);
const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
if (output.byteLength === 0) return null;
const samples = new Int16Array(output);
if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [];
for (let i = 0; i < resolution; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs;
}
// Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3)));
}
return data;
} catch {
return null;
}
}
/**
* Get waveform data for an audio URL.
*
* Returns cached data if available, otherwise attempts ffmpeg extraction
*/
export async function getWaveformData(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey);
if (cached) return cached;
const real = await extractWithFfmpeg(audioUrl, resolution);
if (real) {
waveformCache.set(cacheKey, real);
return real;
} else {
console.error("generation failure");
return [];
}
}
export function clearWaveformCache(): void {
waveformCache.clear();
}

View File

@@ -1,57 +0,0 @@
type CacheEntry<T> = {
value: T
timestamp: number
}
const CACHE_KEY = "podtui_cache"
const DEFAULT_TTL = 1000 * 60 * 60
const loadCache = (): Record<string, CacheEntry<unknown>> => {
if (typeof localStorage === "undefined") return {}
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
} catch {
return {}
}
}
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
const cache = loadCache()
export const cacheValue = <T,>(key: string, value: T) => {
cache[key] = { value, timestamp: Date.now() }
saveCache(cache)
}
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
const entry = cache[key] as CacheEntry<T> | undefined
if (!entry) return null
if (Date.now() - entry.timestamp > ttl) {
delete cache[key]
saveCache(cache)
return null
}
return entry.value
}
export const invalidateCache = (prefix?: string) => {
if (!prefix) {
Object.keys(cache).forEach((key) => delete cache[key])
saveCache(cache)
return
}
Object.keys(cache)
.filter((key) => key.startsWith(prefix))
.forEach((key) => delete cache[key])
saveCache(cache)
}

View File

@@ -106,7 +106,7 @@ export namespace Clipboard {
/** /**
* Read text from the clipboard. * Read text from the clipboard.
*/ */
export async function readText(): Promise<string | undefined> { async function readText(): Promise<string | undefined> {
const os = platform() const os = platform()
if (os === "darwin") { if (os === "darwin") {

View File

@@ -13,7 +13,7 @@ import path from "path"
const APP_DIR_NAME = "podtui" const APP_DIR_NAME = "podtui"
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */ /** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
export function getXdgConfigHome(): string { function getXdgConfigHome(): string {
const xdg = process.env.XDG_CONFIG_HOME const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return xdg if (xdg) return xdg
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
} }
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */ /** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
export function getXdgDataHome(): string { function getXdgDataHome(): string {
const xdg = process.env.XDG_DATA_HOME const xdg = process.env.XDG_DATA_HOME
if (xdg) return xdg if (xdg) return xdg
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
} }
/** Get the application-specific data directory path */ /** Get the application-specific data directory path */
export function getDataDir(): string { function getDataDir(): string {
return path.join(getXdgDataHome(), APP_DIR_NAME) return path.join(getXdgDataHome(), APP_DIR_NAME)
} }
/** Get the downloads directory path */ /** Get the downloads directory path */
export function getDownloadsDir(): string { function getDownloadsDir(): string {
return path.join(getDataDir(), "downloads") return path.join(getDataDir(), "downloads")
} }

View File

@@ -1,150 +0,0 @@
/**
* Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single
*/
import { getConfigFilePath } from "./config-dir";
// --- Validation helpers ---
/** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
/** Validate AppState JSON structure */
export function validateAppState(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] };
}
// settings
if (data.settings !== undefined) {
if (!isObject(data.settings)) {
errors.push("settings must be an object");
} else {
const s = data.settings as Record<string, unknown>;
if (s.theme !== undefined && typeof s.theme !== "string")
errors.push("settings.theme must be a string");
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
errors.push("settings.fontSize must be a number");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
errors.push("settings.playbackSpeed must be a number");
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
errors.push("settings.downloadPath must be a string");
}
}
// preferences
if (data.preferences !== undefined) {
if (!isObject(data.preferences)) {
errors.push("preferences must be an object");
} else {
const p = data.preferences as Record<string, unknown>;
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
errors.push("preferences.showExplicit must be a boolean");
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
errors.push("preferences.autoDownload must be a boolean");
}
}
// customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object");
}
return { valid: errors.length === 0, errors };
}
/** Validate feeds JSON structure */
export function validateFeeds(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] };
}
for (let i = 0; i < data.length; i++) {
const feed = data[i];
if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`);
continue;
}
if (typeof feed.id !== "string")
errors.push(`feeds[${i}].id must be a string`);
if (!isObject(feed.podcast))
errors.push(`feeds[${i}].podcast must be an object`);
if (!Array.isArray(feed.episodes))
errors.push(`feeds[${i}].episodes must be an array`);
}
return { valid: errors.length === 0, errors };
}
/** Validate progress JSON structure */
export function validateProgress(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] };
}
for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`);
continue;
}
const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string")
errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.position !== "number")
errors.push(`progress["${key}"].position must be a number`);
if (typeof p.duration !== "number")
errors.push(`progress["${key}"].duration must be a number`);
}
return { valid: errors.length === 0, errors };
}
// --- Safe config file reading ---
/**
* Safely read and validate a config file.
* Returns the parsed data if valid, or null if the file is missing/corrupt.
*/
export async function safeReadConfigFile<T>(
filename: string,
validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> {
try {
const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath);
if (!(await file.exists())) {
return { data: null, errors: [] };
}
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { data: null, errors: [`${filename}: invalid JSON`] };
}
const result = validator(parsed);
if (!result.valid) {
return { data: null, errors: result.errors };
}
return { data: parsed as T, errors: [] };
} catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] };
}
}

View File

@@ -73,11 +73,6 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
}); });
} }
/** Await all pending config writes (used by sync/export flows). */
export async function flushConfig(): Promise<void> {
await writeChain;
}
/** Guards so migration runs exactly once per process. */ /** Guards so migration runs exactly once per process. */
let migrationDone = false; let migrationDone = false;
let migrationPromise: Promise<void> | null = null; let migrationPromise: Promise<void> | null = null;
@@ -98,7 +93,7 @@ async function migrateOnce(): Promise<void> {
* Safe to call on every startup — no-op once config.json exists (except for * Safe to call on every startup — no-op once config.json exists (except for
* backup cleanup, which runs unconditionally since those files are now dead). * backup cleanup, which runs unconditionally since those files are now dead).
*/ */
export async function migrateLegacyConfig(): Promise<void> { async function migrateLegacyConfig(): Promise<void> {
try { try {
await ensureConfigDir(); await ensureConfigDir();
const dir = getConfigDir(); const dir = getConfigDir();

View File

@@ -1,57 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import { cacheValue, getCachedValue } from "./cache"
import { fetchEpisodes } from "@/api/client"
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
const searchKey = (query: string) => `search:${query.toLowerCase()}`
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
const cached = getCachedValue<Feed>(feedKey(feedUrl))
if (cached) return cached
try {
const episodes = await fetchEpisodes(feedUrl)
const feed: Feed = {
id: feedUrl,
podcast: {
id: feedUrl,
title: feedUrl,
description: "",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "rss",
lastUpdated: new Date(),
isPinned: false,
}
cacheValue(feedKey(feedUrl), feed)
return feed
} catch {
return null
}
}
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
if (cached) return cached
const episodes = await fetchEpisodes(feedUrl)
cacheValue(episodesKey(feedUrl), episodes)
return episodes
}
export const searchWithCache = async (
query: string,
fetcher: () => Promise<Podcast[]>
): Promise<Podcast[]> => {
const cached = getCachedValue<Podcast[]>(searchKey(query))
if (cached) return cached
const results = await fetcher()
cacheValue(searchKey(query), results)
return results
}

View File

@@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
]); ]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */ /** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
export function tabByDigit(action: KeybindActionName): TABS | null { function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) { if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length)); const n = Number(action.slice("tab-goto-".length));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null; return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;

View File

@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
} }
// Singleton event bus instance // Singleton event bus instance
export const EventBus = createEventBus(); const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation"; import type { TABS } from "@/utils/navigation";
@@ -105,6 +105,8 @@ export type AppEvents = {
"player.play": { episodeId: string }; "player.play": { episodeId: string };
"player.pause": { episodeId: string }; "player.pause": { episodeId: string };
"player.stop": {}; "player.stop": {};
// Emitted when a NEW episode begins playback (not on resume).
"player.started": { episodeId: string };
"toast.show": { "toast.show": {
message: string; message: string;
variant: "info" | "success" | "warning" | "error"; variant: "info" | "success" | "warning" | "error";

View File

@@ -8,7 +8,7 @@
/** /**
* Remove JSONC comments from a string * Remove JSONC comments from a string
*/ */
export function stripComments(jsonString: string): string { function stripComments(jsonString: string): string {
const comments = [ const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" }, { pattern: /\/\/.*$/gm, replacement: "" },
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" }, { pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },

View File

@@ -53,9 +53,10 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
"tab-goto-4": ["4"], "tab-goto-4": ["4"],
"tab-goto-5": ["5"], "tab-goto-5": ["5"],
"tab-goto-6": ["6"], "tab-goto-6": ["6"],
// command / help / quit // command palette / help / quit
command: [":"], // q opens the palette (type q + Enter to quit there); Q is the quick quit.
quit: ["q", "ctrl-c"], command: [":", "q"],
quit: ["Q", "ctrl-c"],
help: ["~", "f1"], help: ["~", "f1"],
// list ops // list ops
search: ["s"], search: ["s"],
@@ -77,7 +78,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
try { try {
const targetPath = getConfigFilePath(KEYBINDS_FILE); const targetPath = getConfigFilePath(KEYBINDS_FILE);
// 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;

View File

@@ -57,13 +57,13 @@ export function rootFrameFor(
// terminal size — more robust than fixed percentages and exactly mirrors // 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). // yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
// //
// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT // Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
// touch these values. Task 02 re-tunes them to the remake target ratios // (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there. // give `current` the combined 4/5.
export const PANE_RATIO = { export const PANE_RATIO = {
parent: 1, parent: 1,
current: 3, current: 2,
preview: 3, preview: 2,
} as const; } as const;
// Number of *focusable* content panes per tab. The three visible columns // Number of *focusable* content panes per tab. The three visible columns

View File

@@ -1,6 +1,7 @@
import { searchSourceByType } from "./source-searcher"; import { searchSourceByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser";
import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source"; import type { PodcastSource, SearchResult } from "../types/source";
import type { Episode } from "../types/episode";
type SearchCacheEntry = { type SearchCacheEntry = {
timestamp: number; timestamp: number;
@@ -56,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
return Array.from(map.values()); return Array.from(map.values());
}; };
const FEED_URL_RE = /^https?:\/\/.+/i;
/**
* If the query is a direct RSS feed URL (useful for private feeds that aren't
* in public directories), fetch and parse it into a single search result.
* Returns an empty array when the query is not a URL so normal search proceeds.
*/
export const searchByFeedUrl = async (
query: string,
): Promise<SearchResult[]> => {
const trimmed = query.trim();
if (!FEED_URL_RE.test(trimmed)) return [];
try {
const response = await fetch(trimmed, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return [];
const xml = await response.text();
const podcast = parseRSSFeed(xml, trimmed);
return [
{
sourceId: "direct-rss",
sourceName: "RSS Feed",
sourceType: SourceType.RSS,
// parseRSSFeed marks feeds subscribed; a search result should start
// unsubscribed so the store can flag it correctly if already added.
podcast: { ...podcast, isSubscribed: false },
score: 1,
},
];
} catch {
return [];
}
};
export const searchPodcasts = async ( export const searchPodcasts = async (
query: string, query: string,
sourceIds: string[], sourceIds: string[],
@@ -114,61 +156,4 @@ export const searchPodcasts = async (
return sorted; return sorted;
}; };
type ItunesEpisodeResult = {
trackId?: number;
trackName?: string;
description?: string;
shortDescription?: string;
releaseDate?: string;
trackTimeMillis?: number;
episodeUrl?: string;
previewUrl?: string;
trackViewUrl?: string;
};
type ItunesEpisodeResponse = {
resultCount: number;
results: ItunesEpisodeResult[];
};
export const searchEpisodes = async (
query: string,
feedId: string,
): Promise<Episode[]> => {
const trimmed = query.trim();
if (!trimmed) return [];
const url = new URL("https://itunes.apple.com/search");
url.searchParams.set("term", trimmed);
url.searchParams.set("media", "podcast");
url.searchParams.set("entity", "podcastEpisode");
url.searchParams.set("country", "US");
url.searchParams.set("lang", "en_us");
const response = await fetch(url.toString());
if (!response.ok) return [];
const data = (await response.json()) as ItunesEpisodeResponse;
return data.results
.map((item) => {
if (!item.trackName) return null;
const id = item.trackId
? `episode-${item.trackId}`
: `episode-${item.trackName}`;
const audioUrl =
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
return {
id,
podcastId: feedId,
title: item.trackName,
description: item.description || item.shortDescription || "",
audioUrl,
duration: item.trackTimeMillis
? Math.round(item.trackTimeMillis / 1000)
: 0,
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
};
})
.filter((item): item is Episode => Boolean(item));
};

View File

@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
}) })
} }
export const searchRSSSource = async ( const searchRSSSource = async (
query: string, query: string,
source: PodcastSource source: PodcastSource
): Promise<SearcherResult> => { ): Promise<SearcherResult> => {
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
} }
} }
export const searchAPISource = async ( const searchAPISource = async (
query: string, query: string,
source: PodcastSource source: PodcastSource
): Promise<SearcherResult> => { ): Promise<SearcherResult> => {
@@ -173,7 +173,7 @@ export const searchAPISource = async (
})) }))
} }
export const searchCustomSource = async ( const searchCustomSource = async (
query: string, query: string,
source: PodcastSource source: PodcastSource
): Promise<SearcherResult> => { ): Promise<SearcherResult> => {

View File

@@ -1,25 +0,0 @@
import type { SyncData } from "../types/sync-json"
import type { SyncDataXML } from "../types/sync-xml"
import { syncFormats } from "../constants/sync-formats"
const isObject = (value: unknown): value is { [key: string]: unknown } =>
typeof value === "object" && value !== null
const hasVersion = (value: unknown): value is { version: string } =>
isObject(value) && typeof value.version === "string"
export function validateJSONSync(data: unknown): SyncData {
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
throw { message: "Unsupported sync format" }
}
return data as SyncData
}
export function validateXMLSync(data: unknown): SyncDataXML {
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
throw { message: "Unsupported sync format" }
}
return data as SyncDataXML
}

View File

@@ -1,60 +0,0 @@
import type { SyncData } from "../types/sync-json"
import type { SyncDataXML } from "../types/sync-xml"
import { validateJSONSync, validateXMLSync } from "./sync-validation"
import { syncFormats } from "../constants/sync-formats"
import { FeedVisibility } from "../types/feed"
export function exportToJSON(data: SyncData): string {
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
}
export function importFromJSON(json: string): SyncData {
const data = json
return validateJSONSync(data as unknown)
}
export function exportToXML(data: SyncDataXML): string {
const feedItems = ""
const sourceItems = ""
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<podcastSync version="${syncFormats.xml.version}">\n` +
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
` <feeds>\n` +
feedItems +
` </feeds>\n` +
` <sources>\n` +
sourceItems +
` </sources>\n` +
` <settings>\n` +
` <theme>${data.settings.theme}</theme>\n` +
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
` </settings>\n` +
` <preferences>\n` +
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
` </preferences>\n` +
`</podcastSync>`
}
export function importFromXML(xml: string): SyncDataXML {
const version = syncFormats.xml.version
const data = {
version,
lastSyncedAt: "",
feeds: { feed: [] },
sources: { source: [] },
settings: {
theme: "system",
playbackSpeed: 1,
downloadPath: "",
},
preferences: {
showExplicit: false,
autoDownload: false,
},
} as SyncDataXML
return validateXMLSync(data)
}

View File

@@ -13,13 +13,21 @@ export function clearPaletteCache() {
cached = null; cached = null;
} }
export function detectSystemTheme(colors: TerminalColors) { /** Relative luminance of a hex color (0 = black, 1 = white). */
const bg = RGBA.fromHex( function luminance(hex: string): number {
colors.defaultBackground ?? colors.palette[0] ?? "#000000", const c = RGBA.fromHex(hex);
); return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; }
const mode = luminance > 0.5 ? "light" : "dark";
return { mode, background: bg }; /**
* Infer the terminal's dark/light mode from its default background color
* (the OSC 11 query response). Returns null when no background is available.
*/
export function detectModeFromBackground(
background: string | null | undefined,
): "dark" | "light" | null {
if (!background) return null;
return luminance(background) < 0.5 ? "dark" : "light";
} }
export function generateSystemTheme( export function generateSystemTheme(
@@ -27,14 +35,18 @@ export function generateSystemTheme(
mode: "dark" | "light", mode: "dark" | "light",
): ThemeJson { ): ThemeJson {
cached = colors; cached = colors;
const isDark = mode === "dark";
const bg = RGBA.fromHex( const bg = RGBA.fromHex(
colors.defaultBackground ?? colors.palette[0] ?? "#000000", colors.defaultBackground ??
colors.palette[0] ??
(isDark ? "#000000" : "#ffffff"),
); );
const fg = RGBA.fromHex( const fg = RGBA.fromHex(
colors.defaultForeground ?? colors.palette[7] ?? "#ffffff", colors.defaultForeground ??
colors.palette[7] ??
(isDark ? "#ffffff" : "#000000"),
); );
const transparent = RGBA.fromInts(0, 0, 0, 0); const transparent = RGBA.fromInts(0, 0, 0, 0);
const isDark = mode === "dark";
const col = (i: number) => { const col = (i: number) => {
const value = colors.palette[i]; const value = colors.palette[i];
@@ -96,6 +108,7 @@ export function generateSystemTheme(
textSelectedTertiary: selectedTertiary, textSelectedTertiary: selectedTertiary,
selectedListItemText: bg, selectedListItemText: bg,
background: transparent, background: transparent,
transparent: true,
backgroundPanel: grays[2], backgroundPanel: grays[2],
backgroundElement: grays[3], backgroundElement: grays[3],
backgroundMenu: grays[3], backgroundMenu: grays[3],

View File

@@ -1,36 +0,0 @@
import { RGBA } from "@opentui/core"
import type { ColorValue } from "../types/theme-schema"
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
const r = Math.round(value.r * 255)
const g = Math.round(value.g * 255)
const b = Math.round(value.b * 255)
return `rgba(${r}, ${g}, ${b}, ${value.a})`
}
if (typeof value === "number") return `var(--ansi-${value})`
if (typeof value === "string") return value
return value.dark
}
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
const root = document.documentElement
for (const [key, value] of Object.entries(theme)) {
if (key === "layerBackgrounds" && typeof value === "object") {
const layers = value as Record<string, RGBA | ColorValue>
for (const [layer, color] of Object.entries(layers)) {
root.style.setProperty(`--color-${layer}`, toCss(color))
}
} else {
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
}
}
}
export function setThemeAttribute(themeName: string) {
document.documentElement.setAttribute("data-theme", themeName)
}
export function resolveColorReference(value: ColorValue) {
return toCss(value)
}

View File

@@ -1,42 +1,4 @@
import path from "path"
import type { ThemeJson } from "../types/theme-schema" import type { ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
export async function loadTheme(name: string) {
if (THEME_JSON[name]) return THEME_JSON[name]
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
return loadThemeFromPath(file)
}
export async function loadThemeFromPath(file: string) {
const json = (await Bun.file(file).json()) as ThemeJson
validateTheme(json, file)
return json
}
export async function getAllThemes() {
return { ...THEME_JSON, ...(await getCustomThemes()) }
}
export async function getCustomThemes() {
const dirs = [
path.join(process.env.HOME ?? "", ".config/podtui/themes"),
path.resolve(process.cwd(), ".podtui/themes"),
path.resolve(process.cwd(), "themes"),
]
const result: Record<string, ThemeJson> = {}
for (const dir of dirs) {
const glob = new Bun.Glob("*.json")
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
const name = path.basename(item, ".json")
const json = (await Bun.file(item).json()) as ThemeJson
validateTheme(json, item)
result[name] = json
}
}
return result
}
export function validateTheme(theme: ThemeJson, source?: string) { export function validateTheme(theme: ThemeJson, source?: string) {
if (!theme || typeof theme !== "object") { if (!theme || typeof theme !== "object") {

View File

@@ -7,40 +7,12 @@
* - Tracking theme change state * - Tracking theme change state
*/ */
import { emit, on, off, type EventHandler } from "./event-bus" import { emit } from "./event-bus"
/**
* Subscribe to theme reload events.
* These are triggered by SIGUSR2 signals.
*/
export function onThemeReload(handler: EventHandler<{}>): () => void {
return on("theme.reload", handler)
}
/**
* Subscribe to theme changed events.
* These are triggered when the theme selection changes.
*/
export function onThemeChanged(
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
): () => void {
return on("theme.changed", handler)
}
/**
* Subscribe to theme mode changed events.
* These are triggered when switching between dark/light mode.
*/
export function onThemeModeChanged(
handler: EventHandler<{ mode: "dark" | "light" }>
): () => void {
return on("theme.mode.changed", handler)
}
/** /**
* Emit a theme reload event. * Emit a theme reload event.
*/ */
export function emitThemeReload(): void { function emitThemeReload(): void {
emit("theme.reload", {}) emit("theme.reload", {})
} }
@@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void {
process.off("SIGUSR2", handler) process.off("SIGUSR2", handler)
} }
} }
/**
* Create a debounced theme change handler to prevent rapid consecutive updates.
*
* @param handler - The handler to debounce
* @param delay - Delay in milliseconds (default: 100ms)
*/
export function createDebouncedThemeHandler<T>(
handler: (event: T) => void,
delay: number = 100
): (event: T) => void {
let timeout: NodeJS.Timeout | null = null
return (event: T) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
handler(event)
timeout = null
}, delay)
}
}

View File

@@ -18,7 +18,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
if (value.startsWith("#")) return RGBA.fromHex(value) if (value.startsWith("#")) return RGBA.fromHex(value)
if (defs[value] != null) return resolveColor(defs[value]) if (defs[value] != null) return resolveColor(defs[value])
const ref = theme.theme[value] const ref = theme.theme[value]
if (ref != null) return resolveColor(ref) if (ref != null && typeof ref !== "boolean") return resolveColor(ref)
throw new Error(`Color reference "${value}" not found in defs or theme`) throw new Error(`Color reference "${value}" not found in defs or theme`)
} }
return resolveColor(value[mode]) return resolveColor(value[mode])
@@ -26,8 +26,15 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
const resolved = Object.fromEntries( const resolved = Object.fromEntries(
Object.entries(theme.theme) Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") .filter(
.map(([key, value]) => [key, resolveColor(value)]) (entry): entry is [string, ColorValue] =>
entry[0] !== "selectedListItemText" &&
entry[0] !== "backgroundMenu" &&
entry[0] !== "thinkingOpacity" &&
entry[0] !== "transparent" &&
typeof entry[1] !== "boolean",
)
.map(([key, value]) => [key, resolveColor(value)]),
) as Record<string, RGBA> ) as Record<string, RGBA>
const hasSelected = theme.theme.selectedListItemText !== undefined const hasSelected = theme.theme.selectedListItemText !== undefined
@@ -40,6 +47,7 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
: resolved.backgroundElement : resolved.backgroundElement
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
const transparent = theme.theme.transparent === true
const background = resolved.background const background = resolved.background
const backgroundPanel = resolved.backgroundPanel ?? background const backgroundPanel = resolved.backgroundPanel ?? background
@@ -58,5 +66,6 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
}, },
_hasSelectedListItemText: hasSelected, _hasSelectedListItemText: hasSelected,
thinkingOpacity, thinkingOpacity,
transparent,
} }
} }

View File

@@ -3,67 +3,13 @@
* Handles dynamic theme switching by updating CSS custom properties * Handles dynamic theme switching by updating CSS custom properties
*/ */
import { RGBA, type TerminalColors } from "@opentui/core"; import type { TerminalColors } from "@opentui/core";
import type { ThemeColors } from "../types/settings"; import type { ThemeJson } from "../types/theme-schema";
import type { ColorValue, ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes"; import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes"; import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver"; import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme"; import { generateSystemTheme } from "./system-theme";
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
const r = Math.round(value.r * 255);
const g = Math.round(value.g * 255);
const b = Math.round(value.b * 255);
return `rgba(${r}, ${g}, ${b}, ${value.a})`;
}
if (typeof value === "number") return `var(--ansi-${value})`;
if (typeof value === "string") return value;
return value.dark;
};
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.style.setProperty(
"--color-background",
toCss(theme.background as ColorValue),
);
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue));
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue));
root.style.setProperty(
"--color-secondary",
toCss(theme.secondary as ColorValue),
);
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue));
root.style.setProperty("--color-text", toCss(theme.text as ColorValue));
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue));
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue));
root.style.setProperty("--color-error", toCss(theme.error as ColorValue));
root.style.setProperty("--color-success", toCss(theme.success as ColorValue));
const layers = theme.layerBackgrounds as
| Record<string, ColorValue>
| undefined;
if (layers) {
root.style.setProperty("--color-layer0", toCss(layers.layer0));
root.style.setProperty("--color-layer1", toCss(layers.layer1));
root.style.setProperty("--color-layer2", toCss(layers.layer2));
root.style.setProperty("--color-layer3", toCss(layers.layer3));
}
}
/**
* Get theme mode from system preference
*/
export function getSystemThemeMode(): "dark" | "light" {
if (typeof window === "undefined") return "dark";
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? "dark" : "light";
}
/** /**
* Apply CSS variable data-theme attribute * Apply CSS variable data-theme attribute
*/ */
@@ -77,15 +23,6 @@ export async function loadThemes() {
return await getCustomThemes(); return await getCustomThemes();
} }
export async function loadTheme(name: string) {
const themes = await loadThemes();
return themes[name];
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
return resolveThemeJson(theme, mode);
}
export function resolveTerminalTheme( export function resolveTerminalTheme(
themes: Record<string, ThemeJson>, themes: Record<string, ThemeJson>,
name: string, name: string,

View File

@@ -1,54 +0,0 @@
# 01. Rearchitect nav model — remove the sidebar pane
meta:
id: yazi-remake-01
feature: yazi-remake
priority: P1
depends_on: []
tags: [implementation, nav-model, tests-required]
objective:
- Remove the always-on `SIDEBAR_PANE` concept from the navigation context so `activeTab` is plain tab state (not a pane), establishing clean parent|current|preview semantics for the yazi remake.
deliverables:
- `src/context/NavigationContext.tsx` — delete `SIDEBAR_PANE` constant and all references; `activeTab` is no longer a pane
- `src/utils/navigation.ts` — update `TabPaneCount` semantics; depth-tabs = 1 focusable pane (current), the 3 visible columns are a render concern not 3 panes
- Updated header/comment block describing the parent|current|preview model
- `swipe()` / `popDepth()` reworked: depth-tabs `l`=drill (`open`), `h`=pop (noop at depth 0); fixed-pane tabs `h/l` move between parent/current/preview
- Tab-enter resets focus to `DEPTH_CENTER_PANE` (current pane), not a sidebar
steps:
- Audit every reference to `SIDEBAR_PANE` across the codebase (grep)
- In `NavigationContext.tsx`: delete the `SIDEBAR_PANE = -1` export and the `focusedIndex`/`setFocusedIndex` SIDEBAR_PANE branch added previously
- Set the initial `activePane` signal and the tab-switch createEffect to reset to `DEPTH_CENTER_PANE` (the current pane), not `SIDEBAR_PANE`
- Rework `swipe()` to clamp to `[0, paneCount-1]` for fixed-pane tabs (the sidebar is no longer in the chain); depth-tabs don't use `swipe` for drill/pop (that lives in Shell dispatch)
- In `utils/navigation.ts`: confirm `TabPaneCount` reflects focusable content panes only (depth-tabs = 1, Search = 3, Player = 1); update `PANE_RATIO` leave-behind note (ratio change happens in task 02)
- Update the file header comment block to describe parent|current|preview
- Run `lens_diagnostics` on the two files
tests:
- Unit: `focusedIndex(DEPTH_CENTER_PANE)` on a depth-tab returns the top frame's focus; `setFocusedIndex` writes to the top frame (Arrange a tab with a 2-frame stack, Act by calling setFocusedIndex, Assert topFrame.focus updated)
- Integration: tab-switch effect sets `activePane` to `DEPTH_CENTER_PANE` (not -1); `swipe(-1, 3)` on a fixed tab clamps to 0 not -1
- e2e (harness): app boots with `nav.state.pane === 0` (current), not -1
acceptance_criteria:
- No symbol `SIDEBAR_PANE` exists anywhere in `src/`
- Initial `activePane` === `DEPTH_CENTER_PANE` (0)
- Tab-enter sets `activePane` to `DEPTH_CENTER_PANE`
- `swipe()` lower bound is 0 (no `-1`)
validation:
- `grep -rn "SIDEBAR_PANE" src/` returns nothing
- `bun run build` passes
- `lens_diagnostics` paths=[`src/context/NavigationContext.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
notes:
- This task unblocks 03/04/05/06. It must not delete `DEPTH_CENTER_PANE` — that constant is generalised to "the current pane" and retained
- `SIDEBAR_ACTIONS` (added in Shell in a prior turn) is removed in task 06 (the keybind rewrite), not here — but Shell will temporarily fail to compile after this task until 05/06 land; that's expected and the build command ignores type errors, so gate success on grep + targeted diagnostics, not the full build

View File

@@ -1,56 +0,0 @@
# 02. Build the reusable 3-pane layout primitive (1:3:3 ratio, stable parent slot)
meta:
id: yazi-remake-02
feature: yazi-remake
priority: P1
depends_on: []
tags: [implementation, layout, tests-required]
objective:
- Create one reusable `<YaziPaneRow>` primitive that renders three bordered columns (parent | current | preview) at a 1:3:3 grow ratio with a stable 1/7 parent slot even when blank, so every list tab shares an identical, layout-stable shell.
deliverables:
- `src/components/YaziPaneRow.tsx` — new component: props `parent`, `current`, `preview` (Solid JSX/accessors), `parentLabel`, `currentLabel`, `previewLabel`, `focused` (boolean, defaults to current)
- `src/utils/navigation.ts` — `PANE_RATIO` updated to `{ parent: 1, current: 3, preview: 3 }` (was `{ parent: 1, current: 4, preview: 3 }`)
- Each pane: bordered `scrollbox` + slim header label row (height=1)
- Parent pane keeps its 1/7 `flexGrow` slot even when empty (renders a muted placeholder, never `width:0`)
- Focus ring (border color = accent on current; muted `border` on parent & preview)
steps:
- Set `PANE_RATIO = { parent: 1, current: 3, preview: 3 }` in `utils/navigation.ts`
- Create `YaziPaneRow.tsx` exporting a component that lays out three `<box flexGrow={PANE_RATIO.x}>` columns in a row
- Each column: a height-1 header `<box>` with the label text, then a `<scrollbox height="100%" border borderColor=…>` rendering the passed children
- Thread a `theme` via `useTheme()` inside the primitive (don't require callers to pass colors)
- `focused` prop controls which column gets the accent border — default current; parent & preview always muted
- Ensure the parent column renders a muted placeholder box (e.g. a single `<text fg={muted}>—</text>` or empty) when its children are null, but critically keeps `flexGrow={PANE_RATIO.parent}` so width never collapses
- Add a JSDoc header describing the yazi 1:3:3 contract
- Run diagnostics on the new file
tests:
- Unit: the primitive renders three boxes with flexGrow 1/3/3 regardless of null children (Arrange null parent, render, Assert three columns present with correct flexGrow)
- Integration: toggling `focused` swaps the accent border onto the requested column
- e2e (harness): a page using the primitive shows three equal-ratio columns with the parent column visibly non-zero width even when blank
acceptance_criteria:
- `PANE_RATIO` is `{ parent: 1, current: 3, preview: 3 }`
- `YaziPaneRow` accepts parent/current/preview children + labels + focused
- Parent column width never collapses to 0 (stable 1/7 slot)
- Only the focused column shows the accent border
validation:
- `grep -n "PANE_RATIO" src/utils/navigation.ts` shows the new 1:3:3 values
- `lens_diagnostics` paths=[`src/components/YaziPaneRow.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
- Harness: render a throwaway page using `<YaziPaneRow>`; confirm 3 columns at 1:3:3 via the frame
notes:
- Independent of task 01 (no nav-state dependency) — can be built in parallel
- Callers (tasks 03/04) pass their own parent/current/preview JSX; the primitive is purely structural
- opentui scrollbox: use `focused` only on the current pane so scroll focus follows the cursor

View File

@@ -1,58 +0,0 @@
# 03. Convert Feed/MyShows/Discover/Settings to the shared parent|current|preview primitive
meta:
id: yazi-remake-03
feature: yazi-remake
priority: P2
depends_on: [yazi-remake-01, yazi-remake-02]
tags: [implementation, pages, tests-required]
objective:
- Rewrite the four depth-stack list tabs to render through `<YaziPaneRow>`, with the previous-depth list now visible in the parent pane (blank at depth 0), the current-depth list in current, and the hovered item in preview — eliminating per-page bespoke 3-column JSX.
deliverables:
- `src/pages/Feed/FeedPage.tsx` — rewritten to use `<YaziPaneRow>`; parent = previous-depth list, current = current-depth list, preview = hovered item detail
- `src/pages/MyShows/MyShowsPage.tsx` — same conversion
- `src/pages/Discover/DiscoverPage.tsx` — same conversion
- `src/pages/Settings/SettingsPage.tsx` — same conversion (sections → items → editor)
- Each page's `nav.action` handler retained but only acts on the current pane
- All per-page bespoke row/flexbox 3-column JSX removed
steps:
- For each of the four pages, read the current implementation to extract the parent/current/preview content builders
- Wrap the page body in `<YaziPaneRow parent={…} current={…} preview={…} focused={isActive} />`
- Parent pane: render the previous-depth frame's list (depth-1). At depth 0 the parent receives null/placeholder (the primitive keeps the slot)
- Current pane: the current-depth list, focusable, with `onMouseDown` row handlers calling `nav.setActivePane(DEPTH_CENTER_PANE)` + `nav.setDepthFocus(i, depth)`
- Preview pane: hovered-item detail derived from `focusedIndex(DEPTH_CENTER_PANE)` (unchanged logic, just relocated into the preview slot)
- Keep `pushDepth`/`popDepth` calls in the `open` action (drill) — behaviour unchanged, only layout changes
- Remove the old inline `<box flexGrow={PANE_RATIO.parent/current/preview}>` columns in favour of the primitive
- Verify each page's `nav.action` handler guards on `data.pane === DEPTH_CENTER_PANE && nav.activePane() === DEPTH_CENTER_PANE`
tests:
- Unit: each page's `open` action pushes a frame and the parent pane switches from blank to the previous list (Arrange depth 0, Act open, Assert stack length 2 and parent renders the old list)
- Integration: `h` (pop) returns parent to blank at depth 0; `l` (drill) populates parent with the previous list
- e2e (harness): Feed depth 0→1→2 shows parent blank → previous feeds list → previous episodes list; Settings sections→items→editor shows the chain in the parent pane
acceptance_criteria:
- All four pages render via `<YaziPaneRow>` (no bespoke 3-column JSX remains)
- Parent pane is blank at depth 0, populated at depth ≥ 1
- Drilling (l/Enter) populates the parent with the previous-depth list
- Popping (h) empties the parent back to blank at depth 0
- j/k move focus only within the current pane
validation:
- `grep -rn "YaziPaneRow" src/pages/` returns 4 files
- `lens_diagnostics` paths over the four page files severity=error → 0 findings
- Harness walk: `init` → navigate Feed → `l` (drill) → `l` (drill) → `h` (pop) → `h` (pop); confirm parent slot transitions blank→list→list→blank
notes:
- Depends on 01 (pane model) and 02 (the primitive) being merged
- The already-working `<Show when={item}>{(item) => (… item() …)}</Show>` accessor pattern for opentui `<Show>` callbacks must be preserved in preview panes
- Keep `LoadingIndicator` usages where they exist

View File

@@ -1,52 +0,0 @@
# 04. Fit Search and Player into the 3-pane (1:3:3) model
meta:
id: yazi-remake-04
feature: yazi-remake
priority: P2
depends_on: [yazi-remake-01, yazi-remake-02]
tags: [implementation, pages, tests-required]
objective:
- Bring the two fixed-layout tabs (Search, Player) into the same 1:3:3 parent|current|preview shell, deciding per-page whether to adopt the depth-stack or stay fixed-3-pane, while applying the new ratios throughout.
deliverables:
- `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`; parent = query input + recent-search history, current = results list, preview = focused-result detail
- `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`; current = now-playing transport, preview = episode description/notes, parent = blank placeholder (or compact episode list if available)
- Decision recorded in each file's header comment: depth-stack vs fixed-3-pane
steps:
- Read both pages to understand their current pane semantics
- Search: map INPUT→parent, RESULTS→current, DETAIL→preview inside `<YaziPaneRow>`. If the 1/7 parent slot is too narrow for the input box, widen parent for Search only by passing an override ratio OR move the query into current and results into parent — pick the option that keeps the input usable and document it
- Search: keep the `inputFocused` effect (Shell yields keys to `<input>` when current-pane focus is on the query) — adapt to whichever pane the input lives in
- Player: single content pane; parent = blank/placeholder (1/7), current = transport + progress + controls (3/7), preview = episode art/description/notes (3/7). If no preview data, render a muted placeholder but keep the slot
- Confirm fixed-pane tab swipe (h/l between parent/current/preview) still routes correctly for Search
- Run diagnostics
tests:
- Unit: Search's `handleSubmit` swipes to the results pane and sets focus index 0 (Arrange empty results, Act submit, Assert activePane === results pane & focusedIndex 0)
- Integration: Player renders with parent blank and the transport in current
- e2e (harness): Search shows query | results | detail at 1:3:3; Player shows blank | transport | notes at 1:3:3
acceptance_criteria:
- Both pages render via `<YaziPaneRow>` at 1:3:3
- Search input remains typeable (Shell yields keys when the query pane is focused)
- Player's transport is in the current pane with focus
- No layout collapse: parent & preview keep their slots even if blank
validation:
- `grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` returns 2 files
- `lens_diagnostics` paths over both files severity=error → 0 findings
- Harness: navigate to Search, type a query, press Enter, see results in current + detail in preview; navigate to Player, see transport + notes
notes:
- Depends on 01 (pane model — though Search is fixed-pane, the model cleanup affects `swipe` bounds) and 02 (the primitive)
- If Search input at 1/7 is genuinely too tight (~14 cols at 100w), prefer moving the query into the current pane for Search only and the results into parent — but confirm width with the harness before committing
- Player is single-content; the 1:3:3 with blanks is mostly cosmetic but keeps the layout globally consistent

View File

@@ -1,56 +0,0 @@
# 05. Rebuild Shell chrome — drop sidebar, add yazi bottom status/tab bar
meta:
id: yazi-remake-05
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-01]
tags: [implementation, shell-chrome, tests-required]
objective:
- Remove the always-on left tab sidebar entirely and replace it with a full-width page area above a slim yazi-style bottom bar that surfaces the active tab, depth/counts, selection, now-playing, and a discoverable tab strip.
deliverables:
- `src/components/Shell.tsx` — sidebar JSX deleted; render `LayerGraph[tab]()` full-width + a rebuilt bottom status/command bar
- Bottom bar (normal mode): mode label, `TAB_LABEL[tab] · depth N · i/len` (or `pane i/n` for fixed tabs), selection count `●N`, now-playing `♪ title`, pending-keybind hint, and a compact tab strip `[1]Feed [2]MyShows …` with the active tab marked
- Bottom bar (command mode): `:` prompt + buffer + error (unchanged, just relocated if needed)
- Help overlay kept; now-playing relocated from the old sidebar footer into the status bar
steps:
- Read `Shell.tsx` and delete the entire left tab sidebar `<box flexDirection="column" width={14}>…` block
- Replace the middle row with a single full-width `<box flexGrow={1}>{LayerGraph[nav.activeTab()]()}</box>`
- Rebuild the bottom bar: a height-1 `<box flexDirection="row">` with the fragments described above
- Tab strip: render `Object.values(TABS)` filtered to numbers; for each tab show `[N] Label` with the active tab inverted/highlighted (accent bg or `≡` marker)
- Status fragment: `nav.activePane() === DEPTH_CENTER_PANE ? (isDepthTab ? \`depth ${currentDepth()}\` : \`pane ${activePane()+1}/${count}\`) : 'tabs'` — but since the sidebar is gone, default to the depth/pane string (focus starts on current)
- Relocate `nowPlaying()` text from the sidebar footer into the bottom bar
- Keep `runCommand`, `handleCommandKey`, the help overlay, and `playEpisodeAndSwitch` untouched
- Run diagnostics
tests:
- Unit: `nowPlaying()` formats `♪ <truncated title>` (Arrange a current episode, Assert the string)
- Integration: switching tabs updates the tab strip's active marker and the status tab label
- e2e (harness): `init` shows no left sidebar, a full-width page, and a bottom bar containing the tab strip + `Feed · depth 0`; cycling tabs moves the strip's active marker
acceptance_criteria:
- No `width={14}` sidebar `<box>` remains in `Shell.tsx`
- The active page fills the full content width
- The bottom bar shows the active tab, depth, counts, selection, now-playing, and the tab strip
- The active tab is visually marked in the strip
validation:
- `grep -n "width={14}" src/components/Shell.tsx` returns nothing
- `grep -n "LayerGraph" src/components/Shell.tsx` shows the full-width render
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
- Harness: `init` frame has no sidebar column and shows the tab strip in the last row
notes:
- Depends on 01 (the pane model: focus starts on current, so the status fragment no longer needs the `SIDEBAR_PANE` branch)
- Task 06 rewrites the dispatch keybinds in this same file; do the chrome here and leave the dispatch `SIDEBAR_ACTIONS` branch for 06 to remove (or remove it here if 01 already deleted the constant — coordinate with 01)
- `playEpisodeAndSwitch` and the command bar must keep working

View File

@@ -1,56 +0,0 @@
# 06. Rewire keybinds — h/l drill+pop, digits switch tabs, focus starts on current
meta:
id: yazi-remake-06
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-01, yazi-remake-05]
tags: [implementation, keybinds, tests-required]
objective:
- Rewire the Shell dispatch so the sidebar's special-cased j/k branch is gone, h/l drill/pop on depth-tabs and swipe on fixed tabs, digit keys + `[ ]` are the sole tab switcher, and app focus starts on the current pane.
deliverables:
- `src/components/Shell.tsx` (dispatch) — `SIDEBAR_ACTIONS` set + the `if (nav.activePane() === SIDEBAR_PANE)` branch deleted
- `h`/`l` unified: depth-tabs `l`=current-drills (`open` emit), `h`=current-pops (noop at depth 0); fixed-pane tabs `h/l`=`swipe(∓1, count)`
- `1`-`6` / `tab-goto-*`, `tab-next`/`tab-prev` (`[`/`]`) — the only tab switchers
- Initial focus + tab-enter land on `DEPTH_CENTER_PANE`
- `keybinds.jsonc` reviewed (update labels/help only if needed)
steps:
- Read the current `dispatch()` (post task 01 it references a deleted `SIDEBAR_PANE` — fix the compile here)
- Remove the `SIDEBAR_ACTIONS` constant and its branch
- In the `default` case, implement: digit/tab-goto → `setActiveTab`; `swipe-prev` → (depth-tab & current & depth>0) `popDepth` else (depth-tab & current & depth==0) noop else `swipe(-1, count)`; `swipe-next` → (depth-tab & current) emit `open` else `swipe(1, count)`
- Move/list actions (`move-down/up`, `jump-*`, `page-*`, `goto-top/bottom`) flow to `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only
- Confirm `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches unchanged
- Verify the app boot path sets focus to current (task 01 set the signal; confirm dispatch doesn't override)
- Run diagnostics + harness key sequence
tests:
- Unit: `dispatch("move-down")` on a depth-tab current pane emits `nav.action {action:"move-down"}` (Arrange current pane, Act, Assert emit)
- Integration: `dispatch("swipe-next")` on a depth-tab at depth 0 emits `open` (drill); `dispatch("swipe-prev")` at depth 1 pops to depth 0; at depth 0 `swipe-prev` is a noop
- e2e (harness): `l` drills (depth 0→1, parent populates), `h` pops (1→0, parent blanks), `1`/`2`/`3` switch tabs, `j`/`k` move the current list cursor without changing depth
acceptance_criteria:
- No `SIDEBAR_PANE` or `SIDEBAR_ACTIONS` references in `Shell.tsx`
- `h` at depth 0 is a noop (does not error, does not change pane)
- `l` at current on a depth-tab drills (depth+1)
- Digit keys switch tabs; focus lands on current pane
- `j`/`k` move within current only
validation:
- `grep -n "SIDEBAR" src/components/Shell.tsx` returns nothing
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
- Harness: `init` (focus on current) → `l` (depth 1, parent filled) → `l` (depth 2) → `h` (depth 1) → `h` (depth 0, parent blank) → `3` (Discover tab, focus on current) → `j`/`k` move
notes:
- Depends on 01 (pane model: `swipe` bounds, no SIDEBAR) and 05 (dispatch lives in the rebuilt Shell)
- If `keybinds.jsonc` has a `tab-next`/`tab-prev` mapping conflict, resolve here
- The noop `h` at depth 0 should feel inert (yazi: at root, `h` does nothing)

View File

@@ -1,98 +0,0 @@
# 07-blocker — Task 04 (Search + Player) never converted to YaziPaneRow
meta:
id: yazi-remake-07-blocker
feature: yazi-remake
priority: P0
blocks: [yazi-remake-07]
blocked_by: [yazi-remake-04]
tags: [blocker, verification, tasks-required]
## Problem
Task 04 (`04-fit-search-and-player-panes.md`) is marked complete, but its
core deliverable was never implemented:
> - `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`
> - `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`
`grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` → **0 files**.
## Evidence (from task 07 harness walk-through, 100×30)
### Player ❌
`src/pages/Player/PlayerPage.tsx` renders a single full-width
`<scrollbox>` (now-playing transport + controls). No parent pane, no preview
pane. Frame `.harness/player-current.txt`:
```
│ Now Playing 0:00 / 0:00 (0%) │
...
│ │ │[Prev]│ │[Play]│ │[Next]│ Vol 70% Speed 1x ... │ │
```
Expected (task 04 + feature exit criteria): `blank | transport | notes` at
1/7 : 3/7 : 3/7.
### Search ⚠️ ratio wrong
`src/pages/Search/SearchPage.tsx` renders three custom `<box flexGrow={
PANE_RATIO.* }>` columns but **omits `flexBasis={0}`**, so Yoga distributes
space by natural content width (the input box is `width={28}`). Measured
column widths at 100 cols: **31 / 32 / 31** (equal thirds), NOT the target
**~14 / 43 / 43** (1:3:3).
`<YaziPaneRow>` exists precisely to set `flexBasis={0}` per column and force
the exact 1:3:3 ratio regardless of content (see its header comment). Routing
Search through it fixes the ratio for free.
## Why not patched in task 07
Task 07 is explicitly the verification gate. Its notes:
> - This is the gate for the whole feature — do not mark done if any criterion
> fails; open a blocker task instead
> - If the harness reveals a visual regression (e.g. parent collapses, ratios
> off), file it against the responsible task (02 or 03) rather than
> patching here
This is a missing implementation in task 04, not a regression in 02/03, so the
responsible task is 04. Patches belong there.
## Failing exit criteria
- "All tabs render three stable columns at 1/7 : 3/7 : 3/7" — Player fails
(not 3 columns); Search fails (wrong ratio).
- "`5` → Player: blank|transport|notes at 1:3:3" — fails.
- "`4` → Search: query|results|detail at 1:3:3" — 3 columns yes, ratio wrong.
## Fix plan (task 04 do-over)
1. `src/pages/Player/PlayerPage.tsx` — wrap the existing transport JSX in a
`<YaziPaneRow current={transport} parent={undefined} preview={notes} />`.
Parent should fall through to the primitive's muted `—` placeholder (it
already keeps its 1/7 slot when blank). Preview = episode description /
waveform (currently inline under "Now Playing"). Keep `PlayerPaneCount=1`
(the visible columns are a render concern; only current=0 is focusable).
2. `src/pages/Search/SearchPage.tsx` — replace the three custom `<box
flexGrow={PANE_RATIO.*}>` columns with a single `<YaziPaneRow
parent={queryInput+recent} current={resultsList} preview={detail}
focused={!inputFocused() ? /* results */ : false} />`. Keep the
`inputFocused` effect so the Shell yields keys to the native `<input>`
when the query pane is focused — note `inputFocused` is a Search-owned
signal; YaziPaneRow's `focused` prop only drives the accent ring + scroll
focus, which for Search can stay on the current (results) column.
3. Remove the now-dead custom ratio code from both files after the swap.
4. Re-run: `grep -rn YaziPaneRow src/pages/Search src/pages/Player` → 2 files;
`bun run build`; `bun test`; harness walk-through: Player shows 3 cols,
Search cols measure ~14/43/43.
## Verification gates (re-run task 07 after fix)
- `bun run build` → "Build complete"
- `bun test` → 0 fail
- harness:
- Player frame has 3 bordered columns (parent `—`, current transport,
preview notes/placeholder) at 1:3:3
- Search frame columns measure ~14/43/43

View File

@@ -1,61 +0,0 @@
# 07. Verify the remake — build + diagnostics + harness walk-through
meta:
id: yazi-remake-07
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
tags: [verification, tests-required]
status: BLOCKED # see .harness/verification-07.md + tasks/yazi-remake/07-blocker-task-04-player-search.md
objective:
- Confirm the yazi remake meets every exit criterion via a clean build, zero diagnostics, and a full harness walk-through of every tab and depth.
deliverables:
- A passing `bun run build`
- `lens_diagnostics mode=all` with zero errors across edited files
- Harness frames + state proving the parent|current|preview 1:3:3 layout, drill/pop behaviour, tab switching, and status bar across all six tabs
steps:
- Run `bun run build` — expect "Build complete"
- Run `lens_diagnostics mode=all severity=error` — expect 0 findings across all session-edited files
- Run the drive harness (`scripts/tui-harness.tsx`) walk-through:
- `init` → confirm no sidebar, 3 columns at 1:3:3, focus on current, bottom tab strip visible
- Feed: `l` (depth 0→1, parent fills) → `l` (1→2) → `h` (2→1) → `h` (1→0, parent blanks) ; `j`/`k` move current
- `2` → MyShows: drill show→episodes, parent reflects
- `3` → Discover: category→results, parent shows categories
- `6` → Settings: sections→items→editor, parent shows the previous list at each depth
- `4` → Search: query|results|detail at 1:3:3; type + Enter works
- `5` → Player: blank|transport|notes at 1:3:3
- Capture the status bar content (active tab + depth + counts + now-playing + tab strip) from a representative frame
tests:
- Build: `bun run build` exits 0 with "Build complete"
- Diagnostics: `lens_diagnostics` mode=all → 0 errors
- Harness (integration/e2e): the walk-through above produces the expected frames & state (parent blank at depth 0, populates on drill, blanks on pop; digits switch tabs; h noop at depth 0)
acceptance_criteria:
- `bun run build` passes
- `lens_diagnostics` mode=all reports zero errors
- All six tabs render 3 stable columns at 1:3:3
- Parent pane is blank at depth 0; drill fills it with the previous-depth list; pop empties it
- `h` is a noop at depth 0; `l` drills; `1-6`/`[`/`]` switch tabs; `j/k` move current only
- No sidebar; focus starts on current; bottom bar shows active tab + depth + counts + tab strip
validation:
- `bun run build 2>&1 | tail -3` → "Build complete"
- `lens_diagnostics` mode=all severity=error → "No error issues…"
- Harness `state nav` after `init` shows `pane === 0` (current), not -1
- Harness frames for Feed depth 0/1/2 show the parent slot transition blank→list→list
notes:
- This is the gate for the whole feature — do not mark done if any criterion fails; open a blocker task instead
- If the harness reveals a visual regression (e.g. parent collapses, ratios off), file it against the responsible task (02 or 03) rather than patching here
- Save a representative `.harness/last-frame.txt` snapshot if a visual reference is useful for future sessions

View File

@@ -1,40 +0,0 @@
# Yazi UI Remake
Objective: Remake the PodTUI shell into a yazi-pure parent|current|preview 3-pane layout (1:3:3 ratio) with a bottom tab strip and no always-on sidebar.
Status legend: [ ] todo, [~] in-progress, [x] done
## Tasks
- [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
- [x] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
- [x] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
- [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
- [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
- [x] 06 — rewire-keybinds → `06-rewire-keybinds.md`
- [x] 07 — verify-remake → `07-verify-remake.md`
## Dependencies
- 03 depends on 01
- 03 depends on 02
- 04 depends on 01
- 04 depends on 02
- 05 depends on 01
- 06 depends on 01
- 06 depends on 05
- 07 depends on 03
- 07 depends on 04
- 07 depends on 05
- 07 depends on 06
## Exit criteria
- The feature is complete when the left tab sidebar is gone; tabs switch only via digit keys `1-6` / `[ ]` and a bottom tab strip
- All tabs render three stable columns at 1/7 : 3/7 : 3/7 (parent | current | preview)
- The parent pane renders the previous-depth list and is blank (but keeps its 1/7 slot) at depth 0
- `h`/`l` drill (push) and pop depths on list tabs; `h` is a noop at depth 0
- `j`/`k` move within the current pane only; focus starts on the current pane
- Feed depth 0→1→2, MyShows, Discover, Settings (sections→items→editor), Search, and Player all render correctly via the drive harness
- `bun run build` passes and `lens_diagnostics` (mode=all) reports zero errors
- The bottom status bar shows active tab + depth + counts, selection count, now-playing, and the tab strip

View File

@@ -11,8 +11,8 @@
* *
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the * Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
* player process, injected into the (private) `proc` slot of an MpvBackend — * player process, injected into the (private) `proc` slot of an MpvBackend —
* mpv is the only real backend, and it uses the kill-on-dispose * mpv is the only real backend; its kill-on-dispose path exercises the
* exercising one is enough to guard the family. * whole family, so one test suffices to guard it.
*/ */
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
import { MpvBackend } from "../src/utils/audio-player"; import { MpvBackend } from "../src/utils/audio-player";

43
tests/auto-jump.test.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* auto-jump.test.ts — "auto jump to player on podcast start" feature.
*
* The Shell subscribes to the `player.started` event (emitted by useAudio when
* a NEW episode begins, not on resume) and, when `autoJumpToPlayer` is enabled,
* performs `nav.setActiveTab(TABS.PLAYER)` + `nav.enterTabContent()` to land in
* the Player content pane. These tests pin that jump contract against the real
* nav store (the exact two calls the Shell handler makes) and pin the default
* value of the new preference.
*/
import { test, expect } from "bun:test";
import { createRoot } from "solid-js";
import {
createNavigation,
type NavigationState,
} from "../src/context/navigation-store";
import { TABS } from "../src/utils/navigation";
import { loadAppStateFromFile } from "../src/utils/app-persistence";
function withNav(fn: (nav: NavigationState) => void) {
createRoot((dispose) => {
const nav = createNavigation();
fn(nav);
dispose();
});
}
test("auto-jump: PLAYER + enterTabContent lands in the Player content pane", () => {
withNav((nav) => {
// Exact call sequence the Shell handler runs on `player.started`.
nav.setActiveTab(TABS.PLAYER);
nav.enterTabContent();
expect(nav.activeTab()).toBe(TABS.PLAYER);
// Content entered: tab list is no longer the CURRENT pane.
expect(nav.atRootTab()).toBe(false);
});
});
test("auto-jump: preference defaults to true on a config without the field", async () => {
// Existing configs predate the field; load must merge in the default true.
const state = await loadAppStateFromFile();
expect(state.preferences.autoJumpToPlayer).toBe(true);
});

View File

@@ -11,8 +11,8 @@ const cfg = {
"audio-seek-forward": parseBindingSpec(["shift-."]), "audio-seek-forward": parseBindingSpec(["shift-."]),
"audio-seek-backward": parseBindingSpec(["shift-,"]), "audio-seek-backward": parseBindingSpec(["shift-,"]),
sort: parseBindingSpec([","]), sort: parseBindingSpec([","]),
quit: parseBindingSpec(["q"]), quit: parseBindingSpec(["Q"]),
command: parseBindingSpec([":"]), command: parseBindingSpec([":", "q"]),
"tab-next": parseBindingSpec(["]"]), "tab-next": parseBindingSpec(["]"]),
} as Record<string, ReturnType<typeof parseBindingSpec>>; } as Record<string, ReturnType<typeof parseBindingSpec>>;
@@ -95,7 +95,8 @@ check("down -> move-down", sim([E("down")]), "move-down");
check("gg -> goto-top", sim([E("g"), E("g")]), "goto-top"); check("gg -> goto-top", sim([E("g"), E("g")]), "goto-top");
check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom"); check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom");
check("space -> toggle-select", sim([E("space")]), "toggle-select"); check("space -> toggle-select", sim([E("space")]), "toggle-select");
check("q -> quit", sim([E("q")]), "quit"); check("q -> command", sim([E("q")]), "command");
check("Q -> quit (shift+q)", sim([E("q", { shift: true })]), "quit");
check(": -> command", sim([E(":")]), "command"); check(": -> command", sim([E(":")]), "command");
check("] -> tab-next", sim([E("]")]), "tab-next"); check("] -> tab-next", sim([E("]")]), "tab-next");
check( check(

View File

@@ -47,7 +47,7 @@ test("createNavigation starts on the tab root (atRootTab true)", () => {
// ── depth-tab focus: reads/writes the top frame's focus ─────────────────────── // ── depth-tab focus: reads/writes the top frame's focus ───────────────────────
test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => { test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => {
withNav((nav) => { withNav((nav) => {
// FeeD is a depth-tab; its root frame is a the top frame on the stack. // FEED is a depth-tab; its root frame is the top frame on the stack.
nav.setActiveTab(TABS.FEED); nav.setActiveTab(TABS.FEED);
expect(nav.isDepthTab()).toBe(true); expect(nav.isDepthTab()).toBe(true);
expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0); expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0);
@@ -214,6 +214,17 @@ test("direct tab switches re-sync the tab cursor", () => {
}); });
}); });
test("setTabCursor moves the cursor directly (mouse click support), activateTabCursor opens it", () => {
withNav((nav) => {
nav.setTabCursor(TABS.SETTINGS);
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
expect(nav.activeTab()).toBe(TABS.FEED); // cursor only — active tab untouched
nav.activateTabCursor();
expect(nav.activeTab()).toBe(TABS.SETTINGS);
expect(nav.atRootTab()).toBe(false);
});
});
test("tab-switch resets mode/visual/command state", () => { test("tab-switch resets mode/visual/command state", () => {
withNav((nav) => { withNav((nav) => {
nav.enterVisual(); nav.enterVisual();

Some files were not shown because too many files have changed in this diff Show More