Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac1ec1162 | |||
| 4a94ff5910 | |||
| 2e69868ffc | |||
| 491a736c32 | |||
| 12bd6be4bc | |||
| d2f6c5c525 | |||
| f758b53336 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -66,12 +66,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
|
||||||
run: |
|
run: |
|
||||||
# The embedded runtime reads the launching process's CWD bunfig.toml.
|
# The binary is compiled with bunfig autoload disabled
|
||||||
# This repo's bunfig lists a preload the standalone can't resolve
|
# (autoloadBunfig: false in build.ts), so it must boot even from a
|
||||||
# ("preload not found"), so kicking the binary from the workspace root
|
# directory holding a bunfig.toml with a top-level preload the
|
||||||
# would falsely fail every build. cd into a clean dir first.
|
# standalone can't resolve. Plant one to make this a real regression
|
||||||
|
# test for "preload not found".
|
||||||
SMOKE_DIR=$(mktemp -d)
|
SMOKE_DIR=$(mktemp -d)
|
||||||
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
|
||||||
|
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
||||||
cd "$SMOKE_DIR"
|
cd "$SMOKE_DIR"
|
||||||
./podtui-*/podtui --version
|
./podtui-*/podtui --version
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
|
|||||||
| `bun run lint` | Type-check |
|
| `bun run lint` | Type-check |
|
||||||
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
|
||||||
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
| `make dist` | Compile the standalone binary + make the current platform's tarball |
|
||||||
|
| `make dist-mac` / `make dist-linux` | Aliases for `dist` on their platform (CI runs these) |
|
||||||
| `make clean` | Remove `dist/` |
|
| `make clean` | Remove `dist/` |
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
@@ -90,19 +91,21 @@ Cavacore smoke test: `bun tests/cavacore-smoke.ts`
|
|||||||
|
|
||||||
## Gotchas (read before touching anything)
|
## Gotchas (read before touching anything)
|
||||||
|
|
||||||
1. **Never add a top-level `preload` to `bunfig.toml`.**
|
1. **The compiled binary must keep bunfig autoload disabled.**
|
||||||
A compiled PodTui binary's embedded runtime reads the *launching process's*
|
`build.ts` compiles the standalone with `autoloadBunfig: false`, so its
|
||||||
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
|
embedded runtime *never* reads the launching CWD's `bunfig.toml`. Without
|
||||||
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
|
that flag, a top-level `preload` in the CWD bunfig (common in Bun project
|
||||||
`preload not found`. This is why `bunfig.toml` has **no** top-level
|
dirs) resolves against the CWD rather than the binary and kills startup
|
||||||
`preload`; dev-mode preloading happens via explicit `--preload` flags in
|
with `preload not found`. Don't remove the flag. Preloads for dev/test
|
||||||
`package.json`. The `[test]` section *does* keep a preload — that only
|
belong in the explicit `--preload` flags in `package.json` and the
|
||||||
affects `bun test`.
|
`[test]` section of `bunfig.toml` — not as a top-level entry.
|
||||||
|
|
||||||
2. **Smoke-test the compiled binary from a bunfig-free dir.**
|
2. **Smoke-test the binary from a dir with a poisoned bunfig.**
|
||||||
Because of (1), `./dist/podtui --version` run from the repo root launched
|
The CI smoke test unpacks the tarball into a `mktemp` dir, drops a
|
||||||
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
|
`bunfig.toml` containing an unresolvable top-level `preload` next to it,
|
||||||
before booting. Do the same when testing a release build locally.
|
and boots the binary — proving bunfig autoload stayed disabled. `./dist/
|
||||||
|
podtui --version` must work from any directory, including the repo root;
|
||||||
|
do the same check when testing a release build locally.
|
||||||
|
|
||||||
3. **Homebrew's dylib-repair warning is benign.**
|
3. **Homebrew's dylib-repair warning is benign.**
|
||||||
`brew install` may print “load commands do not fit in the header … needs
|
`brew install` may print “load commands do not fit in the header … needs
|
||||||
@@ -166,7 +169,7 @@ Releases are built and published from **tags**
|
|||||||
test: `brew install mikefreno/tap/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's Installation section). On each release, keep the AUR sources in sync
|
||||||
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
|
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
|
||||||
entries, keep the `LICENSE` asset source (the workflow above uploads
|
entries, keep the `LICENSE` asset source (the workflow above uploads
|
||||||
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
|
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
|
||||||
@@ -190,13 +193,36 @@ make dist # builds the binary + tarball for THIS machine only
|
|||||||
|
|
||||||
Bun cannot cross-compile — the other platforms come from CI.
|
Bun cannot cross-compile — the other platforms come from CI.
|
||||||
|
|
||||||
|
## Distribution & packaging
|
||||||
|
|
||||||
|
A release tarball is three files sitting side by side: the `podtui` binary
|
||||||
|
plus its two FFI libraries (`libopentui.<dylib|so>`,
|
||||||
|
`libcavacore.<dylib|so>`). The sibling rule above is why they ship together.
|
||||||
|
|
||||||
|
PodTui deliberately ships **no** `.deb`, `.rpm`, Flatpak, or Snap packages:
|
||||||
|
for a terminal app that's overwhelmingly installed through repositories or
|
||||||
|
archives, those formats add desktop-sandboxing overhead and a packaging tax
|
||||||
|
with little benefit. Instead:
|
||||||
|
|
||||||
|
- **GitHub Release tarballs** are the universal path — one upload per
|
||||||
|
OS/arch, works on any distro with `curl` + `tar`.
|
||||||
|
- **AUR (`podtui-bin`)** covers Arch/Manjaro with the same binary through the
|
||||||
|
native package manager.
|
||||||
|
- **Nix / cross-distro** users build from source (or a Nix flake can be added
|
||||||
|
later).
|
||||||
|
|
||||||
|
This keeps maintenance to a single build per OS/arch while still reaching the
|
||||||
|
vast majority of desktop Linux users. The AUR PKGBUILD lives in
|
||||||
|
`packaging/aur/` and can be built locally to test before publication:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packaging/aur && makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open items / things to sort out
|
## Open items / things to sort out
|
||||||
|
|
||||||
- **LICENSE**: `README.md` says "TBD — choose and document a license before
|
|
||||||
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
|
|
||||||
README footer.
|
|
||||||
- **Native libs in `dist/` still need committing?** No — they're built from
|
- **Native libs in `dist/` still need committing?** No — they're built from
|
||||||
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
|
||||||
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
|
||||||
|
|||||||
9
LICENSE
9
LICENSE
@@ -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.
|
|
||||||
5
Makefile
5
Makefile
@@ -47,9 +47,8 @@ native:
|
|||||||
scripts/build-cavacore.sh
|
scripts/build-cavacore.sh
|
||||||
|
|
||||||
## Standalone binary + native-libs tarball for the current platform.
|
## Standalone binary + native-libs tarball for the current platform.
|
||||||
## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
|
## Built with bunfig autoload disabled (build.ts sets autoloadBunfig: false),
|
||||||
## the launching process's CWD bunfig.toml, so smoke tests must run the binary
|
## so the embedded runtime ignores any bunfig.toml in the launching directory.
|
||||||
## from a bunfig-free dir (see release.yml).
|
|
||||||
dist:
|
dist:
|
||||||
bun run build.ts --compile
|
bun run build.ts --compile
|
||||||
|
|
||||||
|
|||||||
228
README.md
228
README.md
@@ -1,7 +1,6 @@
|
|||||||
# PodTui
|
# PodTui
|
||||||
|
|
||||||
A keyboard-first, yazi-style terminal podcast client written in TypeScript and
|
A keyboard-first, terminal podcast client built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
||||||
built on [OpenTUI](https://github.com/opentui/opentui). Subscribe to RSS feeds,
|
|
||||||
browse episodes in a three-pane file-manager layout, and play audio through an
|
browse episodes in a three-pane file-manager layout, and play audio through an
|
||||||
external player with full transport control — all from your terminal.
|
external player with full transport control — all from your terminal.
|
||||||
|
|
||||||
@@ -11,8 +10,7 @@ external player with full transport control — all from your terminal.
|
|||||||
`Enter` to open, `1–6` / `[` `]` to switch tabs. The tab list is the app root:
|
`Enter` to open, `1–6` / `[` `]` to switch tabs. The tab list is the app root:
|
||||||
at launch it fills the current pane, and drilling into a tab's contents slides
|
at launch it fills the current pane, and drilling into a tab's contents slides
|
||||||
it into the parent pane.
|
it into the parent pane.
|
||||||
- **Three-pane view** — parent / current / preview (Up | Current | Preview),
|
- **Three-pane view** — parent / current / preview (Up | Current | Preview).
|
||||||
mirroring yazi's pane model.
|
|
||||||
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
- **Podcast feeds** — add feeds, browse episodes, and manage your library
|
||||||
(My Shows, Discover, Feed tabs).
|
(My Shows, Discover, Feed tabs).
|
||||||
- **Search** across your subscribed shows.
|
- **Search** across your subscribed shows.
|
||||||
@@ -22,22 +20,28 @@ external player with full transport control — all from your terminal.
|
|||||||
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||||
a system audio player.
|
a system audio player.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Install PodTui ([Installation](#installation)) and make sure **mpv** is in
|
||||||
|
your `PATH`.
|
||||||
|
2. Run `podtui` in your terminal.
|
||||||
|
3. Press `3` to open **Discover** (or `4` to open **Search**, then `s`), drill
|
||||||
|
in with `Enter`, and press `Enter` on a show to subscribe.
|
||||||
|
4. Press `1` (**Feed**) or `2` (**My Shows**), open an episode with `Enter`,
|
||||||
|
and use `P` to play/pause, `N`/`B` for next/previous, and `shift-.` /
|
||||||
|
`shift-,` to seek.
|
||||||
|
|
||||||
|
Press `~` any time for in-app help. All keys are remappable — see
|
||||||
|
[Keybindings](#keybindings).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- 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.).
|
Ghostty, tmux 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) — see
|
||||||
|----------|----------------|:----:|:-----:|:------------------|
|
[Troubleshooting](#troubleshooting).
|
||||||
| `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,13 +51,9 @@ Linux (arm64/x64). Pick whichever fits your platform.
|
|||||||
### 1. Homebrew (macOS)
|
### 1. Homebrew (macOS)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
brew install mikefreno/tap/podtui # requires mpv: brew install mpv
|
brew install mikefreno/tap/podtui
|
||||||
```
|
```
|
||||||
|
|
||||||
> The formula installs the standalone binary plus its two native libraries
|
|
||||||
> side by side (see [Packaging model](#packaging-model)). It does **not**
|
|
||||||
> depend on Bun.
|
|
||||||
|
|
||||||
### 2. Standalone tarball (all platforms)
|
### 2. Standalone tarball (all platforms)
|
||||||
|
|
||||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
||||||
@@ -71,69 +71,29 @@ sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
|
|||||||
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||||
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the
|
||||||
> binary alone), or the native FFI libraries won't load.
|
> binary alone), or the native FFI libraries won't load.
|
||||||
>
|
|
||||||
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
|
|
||||||
> you launch from. If that file has a `preload` entry (as Bun project
|
|
||||||
> directories often do), startup fails with `preload not found`. Launching
|
|
||||||
> from a normal directory (home, `~/bin`, …) works fine.
|
|
||||||
|
|
||||||
### 3. Arch Linux (AUR)
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Status: PKGBUILD ready, not yet on the AUR (see note below)
|
|
||||||
yay -S podtui-bin # once published
|
yay -S podtui-bin # once published
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
Requires an AUR helper ([paru](https://github.com/morgan/paru)); the package
|
||||||
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and
|
pulls in `mpv` as a dependency.
|
||||||
its two FFI sibling libraries into `/usr/lib/podtui/` with a `/usr/bin/podtui`
|
|
||||||
symlink, and pulls in `mpv` (the sole audio backend) as a dependency.
|
|
||||||
|
|
||||||
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready
|
> **Not yet on the AUR.** The `podtui-bin` package is staged and awaiting
|
||||||
> in `packaging/aur/` and can be built locally today:
|
> publication (AUR account registrations are currently suspended). Until it
|
||||||
>
|
> lands, use the standalone tarball above.
|
||||||
> ```bash
|
|
||||||
> cd packaging/aur && makepkg -si
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> Publishing is on hold until [AUR account registrations](https://aur.archlinux.org)
|
|
||||||
> reopen (suspended while the AUR team works on suspicious-package
|
|
||||||
> moderation). Once a key can be registered, push `PKGBUILD` + `.SRCINFO`
|
|
||||||
> with `git push ssh://aur@aur.archlinux.org/podtui-bin` and update this note.
|
|
||||||
|
|
||||||
### 4. From source
|
### 4. From source
|
||||||
|
|
||||||
Requires [Bun](https://bun.sh) ≥ 1.2.
|
PodTui is written in TypeScript and runs on [Bun](https://bun.sh). To build
|
||||||
|
from source (development, distro packaging, unreleased versions), see
|
||||||
```bash
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
git clone https://github.com/mikefreno/podtui.git
|
|
||||||
cd podtui
|
|
||||||
bun install
|
|
||||||
bun run build:native # build the cavacore FFI lib from C source
|
|
||||||
bun run dev # run with hot reload, or: bun start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Linux distribution notes
|
|
||||||
|
|
||||||
PodTUI deliberately does **not** ship `.deb`, `.rpm`, Flatpak, or Snap
|
|
||||||
packages. For a terminal application that's overwhelmingly installed through
|
|
||||||
repositories or archives, those formats add desktop-sandboxing overhead and a
|
|
||||||
packaging tax with little benefit. Instead:
|
|
||||||
|
|
||||||
- **GitHub Release tarballs** are the universal path — one upload, works on
|
|
||||||
any distro with `curl` + `tar`.
|
|
||||||
- **AUR (`podtui-bin`)** covers Arch. Anyone on Arch/Manjaro gets the same
|
|
||||||
binary through their native package manager.
|
|
||||||
- **Nix / cross-distro** users can build from source (or a Nix flake can be
|
|
||||||
added later).
|
|
||||||
|
|
||||||
This keeps maintenance to a single build per OS/arch and still reaches the
|
|
||||||
vast majority of desktop Linux users through their preferred path.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Launch `podtui` (or `bun src/index.tsx` from the source tree). Press `~`
|
Launch `podtui`. Press `~` for the in-app help.
|
||||||
for the in-app help.
|
|
||||||
|
|
||||||
### Command-line flags
|
### Command-line flags
|
||||||
|
|
||||||
@@ -145,30 +105,70 @@ for the in-app help.
|
|||||||
|
|
||||||
### Keybindings
|
### Keybindings
|
||||||
|
|
||||||
All keys are remappable — edit `~/.config/podtui/keybinds.jsonc`.
|
All keys are remappable — edit `keybinds.jsonc` in your config directory
|
||||||
|
(see [Configuration](#configuration)).
|
||||||
|
|
||||||
|
**Movement**
|
||||||
|
|
||||||
| Keys | Action |
|
| Keys | Action |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `j` / `k` | Move cursor down / up |
|
| `j` / `k` (or `down` / `up`) | Move down / up |
|
||||||
| `J` / `K` | Jump 5 lines |
|
| `J` / `K` | Jump 5 lines down / up |
|
||||||
| `ctrl-d` / `ctrl-u` | Page down / up |
|
| `ctrl-d` / `ctrl-u` | Page down / up |
|
||||||
|
| `ctrl-f` / `ctrl-b` | Full page down / up |
|
||||||
| `gg` / `G` | Go to top / bottom |
|
| `gg` / `G` | Go to top / bottom |
|
||||||
| `h` / `l` | Swipe to parent pane / preview pane |
|
|
||||||
|
**Panes**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `h` / `l` (or `left` / `right`) | Focus parent pane / preview pane |
|
||||||
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
|
| `Enter` | Open the item under the cursor (a tab, episode, show…) |
|
||||||
| `Space` | Select / toggle selection |
|
| `shift-enter` | Open with the interactive variant |
|
||||||
|
|
||||||
|
**Selection**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `Space` | Toggle selection |
|
||||||
| `v` | Visual mode (multi-select) |
|
| `v` | Visual mode (multi-select) |
|
||||||
|
| `ctrl-a` | Select / deselect all |
|
||||||
|
| `ctrl-r` | Invert selection |
|
||||||
|
| `Esc` | Cancel / escape |
|
||||||
|
|
||||||
|
**Tabs**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
| `1`–`6` | Jump to tab 1–6 (Feed, My Shows, Discover, Search, Player, Settings) |
|
||||||
| `[` / `]` | Previous / next tab |
|
| `[` / `]` | Previous / next tab |
|
||||||
| `P` (shift) | Play / pause |
|
|
||||||
|
**Commands, help, quit**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `:` or `q` | Open the command palette (type `q` + `Enter` there to quit) |
|
||||||
|
| `Q` or `ctrl-c` | Quit |
|
||||||
|
| `~` or `f1` | In-app help |
|
||||||
|
|
||||||
|
**Lists**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `s` | Search |
|
||||||
|
| `f` | Filter |
|
||||||
|
| `,` | Sort |
|
||||||
|
| `.` | Toggle hidden |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `x` | Unsubscribe the focused show (My Shows) |
|
||||||
|
|
||||||
|
**Audio**
|
||||||
|
|
||||||
|
| Keys | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `P` | Play / pause |
|
||||||
| `N` / `B` | Next / previous episode |
|
| `N` / `B` | Next / previous episode |
|
||||||
| `shift-.` / `shift-,` | Seek forward / backward |
|
| `shift-.` / `shift-,` | Seek forward / backward |
|
||||||
| `s` | Search (in a list) |
|
|
||||||
| `f` | Filter |
|
|
||||||
| `r` | Refresh |
|
|
||||||
| `:` | Command bar |
|
|
||||||
| `~`, `f1` | Help |
|
|
||||||
| `q`, `ctrl-c` | Quit |
|
|
||||||
| `Esc` | Escape / cancel |
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -177,56 +177,40 @@ 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 |
|
||||||
|
|
||||||
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`. Startup also reads
|
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
|
||||||
the same OpenTUI environment variables.
|
into `config.json` on first run.
|
||||||
|
|
||||||
## Development
|
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`.
|
||||||
|
|
||||||
```bash
|
## Troubleshooting
|
||||||
bun install # install dependencies
|
|
||||||
bun run dev # run with hot reload
|
|
||||||
bun test # run the test suite
|
|
||||||
bun run build # bundle JS + copy native libs into dist/
|
|
||||||
make native # rebuild cavacore from C source
|
|
||||||
make lint # type-check (tsc)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Releasing
|
**`preload not found` at startup** — this used to happen when the binary was
|
||||||
|
launched from a Bun project directory whose `bunfig.toml` had a `preload`
|
||||||
|
entry. Releases are compiled with bunfig autoload disabled
|
||||||
|
(`autoloadBunfig: false`), so current binaries ignore the CWD's `bunfig.toml`
|
||||||
|
entirely. If you still hit it, you're on an old release — upgrade.
|
||||||
|
|
||||||
Tag a release (e.g. `v0.1.0`); CI builds and uploads the per-platform tarballs
|
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
|
||||||
to your GitHub Release automatically:
|
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
|
||||||
|
|
||||||
```bash
|
**Homebrew prints a dylib warning** — “load commands do not fit in the header
|
||||||
make dist # build the standalone binary + tarball for THIS platform
|
… needs `-headerpad`” is benign: the app loads its libraries by path, the
|
||||||
make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
|
install completes, and the app boots normally.
|
||||||
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
`make dist` emits a config-independent binary: Bun does not bake bunfig
|
**The app won't start / no spectrum after moving files** — `podtui` loads its
|
||||||
settings into `--compile` output, and the solid JSX transform is registered in
|
two native libraries relative to the binary, so keep `podtui`,
|
||||||
`build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
|
`libopentui.*`, and `libcavacore.*` together in the same directory (the
|
||||||
it from any normal directory.
|
tarball unpacks them side by side).
|
||||||
|
|
||||||
## Packaging model
|
## Building from source / contributing
|
||||||
|
|
||||||
A release tarball is three files sitting side by side:
|
Development setup, the test suite, packaging, and the release process are
|
||||||
|
documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
```
|
|
||||||
podtui # standalone compiled binary (embeds the Bun runtime)
|
|
||||||
libopentui.<dylib|so> # OpenTUI native renderer FFI library
|
|
||||||
libcavacore.<dylib|so> # cavacore spectrum FFI library (built from C)
|
|
||||||
```
|
|
||||||
|
|
||||||
PodTui loads its native libraries relative to the binary, so **keep them in
|
|
||||||
the same directory**. The compiled binary embeds the Bun runtime, so it runs
|
|
||||||
with no Bun installed. Each release builds one tarball per OS/arch in CI; there
|
|
||||||
is no cross-compilation.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
6
build.ts
6
build.ts
@@ -82,6 +82,12 @@ if (COMPILE) {
|
|||||||
plugins: [solidPlugin],
|
plugins: [solidPlugin],
|
||||||
compile: {
|
compile: {
|
||||||
outfile,
|
outfile,
|
||||||
|
// Don't let the embedded runtime autoload the launching CWD's
|
||||||
|
// bunfig.toml. A top-level `preload` there (common in Bun project
|
||||||
|
// dirs) resolves against the CWD, not the binary, so startup dies
|
||||||
|
// with "preload not found". With autoload disabled, the binary is
|
||||||
|
// config-independent and boots from any directory.
|
||||||
|
autoloadBunfig: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`Compiled standalone binary: ${outfile}`);
|
console.log(`Compiled standalone binary: ${outfile}`);
|
||||||
|
|||||||
11
bunfig.toml
11
bunfig.toml
@@ -1,9 +1,8 @@
|
|||||||
# NO top-level `preload` here — intentional. A compiled PodTUI binary's
|
# No top-level `preload` here — dev/test get the solid JSX transform via the
|
||||||
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a
|
# explicit `--preload` flags in package.json and the [test] section below.
|
||||||
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the
|
# Releases don't read this file at all: build.ts compiles the standalone with
|
||||||
# standalone cannot resolve) makes the binary die at startup with
|
# `autoloadBunfig: false`, so its embedded runtime ignores any bunfig.toml in
|
||||||
# "preload not found". Dev/test still get the solid transform via explicit
|
# the launching directory — no more "preload not found" from CWD bunfigs.
|
||||||
# `--preload` flags in package.json and the [test] section below.
|
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
preload = "@opentui/solid/preload"
|
preload = "@opentui/solid/preload"
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { createSignal, createMemo, onCleanup } from "solid-js";
|
import { createSignal, createMemo, Show, onCleanup } from "solid-js";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
export function LoadingIndicator() {
|
/**
|
||||||
|
* Animated braille spinner with an optional label (e.g. "Refreshing…").
|
||||||
|
* The spinner is rendered in the theme primary color; the label in muted.
|
||||||
|
*/
|
||||||
|
export function LoadingIndicator(props: { label?: string }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [index, setIndex] = createSignal(0);
|
const [index, setIndex] = createSignal(0);
|
||||||
|
|
||||||
@@ -16,8 +20,11 @@ export function LoadingIndicator() {
|
|||||||
const currentChar = createMemo(() => spinnerChars[index()]);
|
const currentChar = createMemo(() => spinnerChars[index()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
|
<box flexDirection="row" gap={1} alignItems="flex-start">
|
||||||
<text fg={theme.primary} content={currentChar()} />
|
<text fg={theme.primary} content={currentChar()} />
|
||||||
|
<Show when={props.label}>
|
||||||
|
<text fg={theme.muted || theme.text} content={props.label} />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* 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, 2, 2]` contract: three bordered columns
|
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
||||||
* grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list
|
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
||||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
||||||
* so the ratio is exact regardless of content width — a column's content can
|
* 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/5 slot when blank (never collapses to width 0).
|
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
||||||
|
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||||
|
* header row: the CURRENT column's title renders top-left in the
|
||||||
|
* parent's slot (the panes above current/preview were removed).
|
||||||
* 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.
|
* is the ONLY bordered column — left/right edges only, always
|
||||||
* preview — detail of the hovered item in `current`; always muted border.
|
* muted (no active-border highlight, focused or not).
|
||||||
|
* preview — detail of the hovered item in `current`. Borderless, no header.
|
||||||
*
|
*
|
||||||
* The primitive is purely structural: callers pass their own JSX per column
|
* The primitive is purely structural: callers pass their own JSX per column
|
||||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
* (static elements or accessors) plus the current-column title. Theme colors
|
||||||
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
* are resolved internally via `useTheme()`. Only the current column's
|
||||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
|
||||||
|
* stay in the current pane).
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* <PaneRow
|
* <PaneRow
|
||||||
* parent={parentList}
|
* parent={parentList}
|
||||||
* current={currentList}
|
* current={currentList}
|
||||||
* preview={detail}
|
* preview={detail}
|
||||||
* parentLabel="Up"
|
|
||||||
* currentLabel="List · 42"
|
* currentLabel="List · 42"
|
||||||
* previewLabel="Detail"
|
|
||||||
* focused={isActive}
|
* focused={isActive}
|
||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, Show } from "solid-js";
|
import { createMemo, Show } from "solid-js";
|
||||||
import type { JSX } from "solid-js";
|
import type { JSX } from "solid-js";
|
||||||
import type { RGBA } from "@opentui/core";
|
import type { RGBA, BorderSides } from "@opentui/core";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
@@ -50,12 +53,12 @@ export type PaneRowProps = {
|
|||||||
/** Preview column content (detail of the hovered item). Omit/undefined
|
/** Preview column content (detail of the hovered item). Omit/undefined
|
||||||
* together with `panes={2}` to render a 2-pane parent|current row. */
|
* together with `panes={2}` to render a 2-pane parent|current row. */
|
||||||
preview?: PaneContent;
|
preview?: PaneContent;
|
||||||
parentLabel?: PaneLabel;
|
/** Title of the current column — rendered once, top-left in the parent
|
||||||
|
* pane's header slot (the per-pane Up/Detail headers are gone). */
|
||||||
currentLabel?: PaneLabel;
|
currentLabel?: PaneLabel;
|
||||||
previewLabel?: PaneLabel;
|
/** Whether the current column's `<scrollbox>` receives scroll focus. Defaults to
|
||||||
/** Whether the current column carries the active-border focus ring. Defaults to
|
* true; pass `false` (or a signal) when the row is inactive. Does NOT change
|
||||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
* border colors — the current column's border is always muted. */
|
||||||
* preview columns always render muted borders. */
|
|
||||||
focused?: boolean | (() => boolean);
|
focused?: boolean | (() => boolean);
|
||||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
@@ -96,16 +99,15 @@ function Pane(props: {
|
|||||||
grow: number;
|
grow: number;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
content: () => JSX.Element | undefined;
|
content: () => JSX.Element | undefined;
|
||||||
borderColor: () => RGBA;
|
border: boolean | BorderSides[];
|
||||||
scrollFocused: () => boolean;
|
scrollFocused: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
const themeContext = useTheme();
|
const themeContext = useTheme();
|
||||||
const theme = themeContext.theme;
|
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 the scroll-focus accessor result so the prop expression below
|
||||||
// when the underlying signals (e.g. `focused`) change.
|
// stays reactive when the underlying signal (e.g. `focused`) changes.
|
||||||
const borderColor = createMemo(() => props.borderColor());
|
|
||||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,7 +117,8 @@ function Pane(props: {
|
|||||||
flexBasis={0}
|
flexBasis={0}
|
||||||
height="100%"
|
height="100%"
|
||||||
>
|
>
|
||||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
{/* ── title row: rendered only when the pane carries a label ────────── */}
|
||||||
|
<Show when={props.label() !== ""}>
|
||||||
<box
|
<box
|
||||||
height={1}
|
height={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -127,12 +130,19 @@ function Pane(props: {
|
|||||||
>
|
>
|
||||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||||
</box>
|
</box>
|
||||||
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
</Show>
|
||||||
|
{/* ── scrollbox; border always muted (focused or not) ──────────────── */}
|
||||||
<scrollbox
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={scrollFocused()}
|
focused={scrollFocused()}
|
||||||
border
|
border={props.border}
|
||||||
borderColor={borderColor()}
|
// Only supply colors when a border is requested — opentui flips a
|
||||||
|
// borderless box to bordered when borderColor/focusedBorderColor
|
||||||
|
// are passed, which would frame the parent/preview panes too.
|
||||||
|
borderColor={props.border === false ? undefined : theme.border}
|
||||||
|
focusedBorderColor={
|
||||||
|
props.border === false ? undefined : theme.border
|
||||||
|
}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
themeContext.transparentBackground()
|
themeContext.transparentBackground()
|
||||||
? "transparent"
|
? "transparent"
|
||||||
@@ -147,9 +157,7 @@ function Pane(props: {
|
|||||||
|
|
||||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||||
export function PaneRow(props: PaneRowProps) {
|
export function PaneRow(props: PaneRowProps) {
|
||||||
const { theme } = useTheme();
|
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||||
|
|
||||||
/** true → the current column gets the active-border focus ring. */
|
|
||||||
const focused = createMemo(() => {
|
const focused = createMemo(() => {
|
||||||
const f = props.focused;
|
const f = props.focused;
|
||||||
return typeof f === "function" ? f() : (f ?? true);
|
return typeof f === "function" ? f() : (f ?? true);
|
||||||
@@ -161,9 +169,9 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
const currentContent = normalizeContent(props.current);
|
const currentContent = normalizeContent(props.current);
|
||||||
const previewContent = normalizeContent(props.preview);
|
const previewContent = normalizeContent(props.preview);
|
||||||
|
|
||||||
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
|
// The single title: the CURRENT column's label, rendered in the parent
|
||||||
|
// pane's header slot (top-left). Current/preview panes have no headers.
|
||||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
|
||||||
|
|
||||||
// 2-pane mode (parent|current) grows the current column to fill the
|
// 2-pane mode (parent|current) grows the current column to fill the
|
||||||
// preview slot. Defaults to 3 (parent|current|preview).
|
// preview slot. Defaults to 3 (parent|current|preview).
|
||||||
@@ -176,29 +184,29 @@ 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/5) — previous-depth list; always muted ─────────────── */}
|
{/* ── parent (1/5) — previous-depth list; title row top-left ───────── */}
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={parentLabel}
|
label={currentLabel}
|
||||||
content={parentContent}
|
content={parentContent}
|
||||||
borderColor={() => theme.border}
|
border={false}
|
||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
{/* ── current — the focused list; left/right borders only ─────────── */}
|
||||||
<Pane
|
<Pane
|
||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={currentLabel}
|
label={() => ""}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
border={["left", "right"]}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (2/5) — hovered-item detail; no border, no header ────── */}
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
label={previewLabel}
|
label={() => ""}
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
borderColor={() => theme.border}
|
border={false}
|
||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -23,20 +23,11 @@ import { useAppStore } from "@/stores/app";
|
|||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { emit, on } 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 } from "@/utils/navigation";
|
||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
|
||||||
[TABS.FEED]: "Feed",
|
|
||||||
[TABS.MYSHOWS]: "My Shows",
|
|
||||||
[TABS.DISCOVER]: "Discover",
|
|
||||||
[TABS.SEARCH]: "Search",
|
|
||||||
[TABS.PLAYER]: "Player",
|
|
||||||
[TABS.SETTINGS]: "Settings",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const t = theme.theme;
|
const t = theme.theme;
|
||||||
@@ -271,9 +262,7 @@ export function Shell() {
|
|||||||
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel="Tabs"
|
currentLabel="Tabs"
|
||||||
previewLabel=""
|
|
||||||
focused
|
focused
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -296,15 +285,6 @@ export function Shell() {
|
|||||||
<text fg={t.accent} paddingLeft={1}>
|
<text fg={t.accent} paddingLeft={1}>
|
||||||
{modeLabel()}
|
{modeLabel()}
|
||||||
</text>
|
</text>
|
||||||
<text fg={t.textMuted} paddingLeft={1}>
|
|
||||||
{nav.atRootTab()
|
|
||||||
? "Tabs · root"
|
|
||||||
: `${TAB_LABEL[nav.activeTab()]} · ${
|
|
||||||
nav.isDepthTab()
|
|
||||||
? `depth ${nav.currentDepth()}`
|
|
||||||
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
|
||||||
}`}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.selectedIds().length > 0}>
|
<Show when={nav.selectedIds().length > 0}>
|
||||||
<text fg={t.warning} paddingLeft={1}>
|
<text fg={t.warning} paddingLeft={1}>
|
||||||
● {nav.selectedIds().length}
|
● {nav.selectedIds().length}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.3.0";
|
const VERSION = "0.3.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { on, off } from "@/utils/event-bus";
|
|||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const DiscoverPaneCount = 1;
|
export const DiscoverPaneCount = 1;
|
||||||
@@ -226,7 +227,14 @@ function DiscoverPage() {
|
|||||||
when={podcasts().length > 0}
|
when={podcasts().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
|
<Show
|
||||||
|
when={discoverStore.isLoading()}
|
||||||
|
fallback={
|
||||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Discovering…" />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -274,6 +282,11 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={discoverStore.isLoading()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator label="Refreshing…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
@@ -376,9 +389,7 @@ function DiscoverPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
* everything over `nav.action`; this page only handles list/preview data.
|
* everything over `nav.action`; this page only handles list/preview data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
@@ -56,18 +57,48 @@ function FeedPage() {
|
|||||||
const episodes = createMemo<EpItem[]>(
|
const episodes = createMemo<EpItem[]>(
|
||||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
|
// reaching the bottom row fetches automatically (see the effect below).
|
||||||
|
const app = useAppStore();
|
||||||
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
||||||
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
|
// Total navigable rows: episodes + the optional Fetch More row.
|
||||||
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focus = () => nav.depthFocus(0);
|
const focus = () => nav.depthFocus(0);
|
||||||
|
const focusedRow = () =>
|
||||||
|
rowCount() === 0 ? 0 : Math.min(focus(), rowCount() - 1);
|
||||||
|
const focusedOnMore = () =>
|
||||||
|
showFetchMore() && focusedRow() === episodes().length;
|
||||||
|
// -1 while the Fetch More row is focused so no episode row renders the
|
||||||
|
// cursor/highlight (the button is the focused row, not the last episode).
|
||||||
const focusedEpIdx = () =>
|
const focusedEpIdx = () =>
|
||||||
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
|
focusedOnMore()
|
||||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
? -1
|
||||||
const curLen = () => episodes().length;
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
|
const focusedItem = (): EpItem | undefined =>
|
||||||
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
const curLen = () => rowCount();
|
||||||
|
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (episodes().length > 0 && focus() >= episodes().length)
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
nav.setDepthFocus(episodes().length - 1, 0);
|
nav.setDepthFocus(rowCount() - 1, 0);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
|
// Auto mode: reaching the bottom row loads the next batch. Guarded by
|
||||||
|
// isLoadingMore so concurrent loads never stack.
|
||||||
|
createEffect(() => {
|
||||||
|
if (fetchMoreMode() !== "auto") return;
|
||||||
|
if (!showFetchMore()) return;
|
||||||
|
if (feedStore.isLoadingMore()) return;
|
||||||
|
if (focusedRow() < rowCount() - 1) return;
|
||||||
|
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(
|
nav.registerResolver(
|
||||||
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
|
||||||
@@ -118,6 +149,10 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── open ───────────────────────────────────────────────────────────────────
|
// ── open ───────────────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
|
if (focusedOnMore()) {
|
||||||
|
feedStore.loadMoreAllFeeds().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
playEpisode(focusedItem());
|
playEpisode(focusedItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +220,16 @@ function FeedPage() {
|
|||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text>
|
<Show
|
||||||
|
when={feedStore.isLoadingFeeds()}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
No feeds. Subscribe from Discover/Search.
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Refreshing…" />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -240,16 +284,60 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={showFetchMore()}>
|
||||||
|
<box
|
||||||
|
ref={moreRef}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(episodes().length, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||||
|
{focusedOnMore() ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<Show
|
||||||
|
when={!feedStore.isLoadingMore()}
|
||||||
|
fallback={<LoadingIndicator label="Fetching…" />}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||||
|
[Fetch More]
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator label="Refreshing…" />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-episode detail ───────────────────────────────────
|
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||||
const previewContent = () => (
|
const previewContent = () => (
|
||||||
|
<>
|
||||||
|
<Show when={focusedOnMore()}>
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>[Fetch More]</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{feedStore.isLoadingMore()
|
||||||
|
? "Loading the next batch of episodes…"
|
||||||
|
: fetchMoreMode() === "auto"
|
||||||
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
|
: "Load the next batch of older episodes across all feeds (Enter)."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
when={focusedItem()}
|
when={focusedItem()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -294,6 +382,8 @@ function FeedPage() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -301,9 +391,7 @@ function FeedPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ export function MyShowsPage() {
|
|||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={feedStore.isLoadingMore()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator label="Loading more…" />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -432,9 +432,7 @@ export function MyShowsPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export function PlayerPage() {
|
|||||||
<PaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel="Player"
|
currentLabel="Player"
|
||||||
panes={2}
|
panes={2}
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
|||||||
import type { SearchResult } from "@/types/source";
|
import type { SearchResult } from "@/types/source";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const SearchPaneCount = 1;
|
export const SearchPaneCount = 1;
|
||||||
@@ -241,7 +242,7 @@ function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<Show when={searchStore.isSearching()}>
|
<Show when={searchStore.isSearching()}>
|
||||||
<text fg={theme.warning}>Searching...</text>
|
<LoadingIndicator label="Searching…" />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={searchStore.error()}>
|
<Show when={searchStore.error()}>
|
||||||
<text fg={theme.error}>{searchStore.error()}</text>
|
<text fg={theme.error}>{searchStore.error()}</text>
|
||||||
@@ -298,11 +299,18 @@ function SearchPage() {
|
|||||||
when={results().length > 0}
|
when={results().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
|
<Show
|
||||||
|
when={searchStore.isSearching()}
|
||||||
|
fallback={
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{searchStore.query()
|
{searchStore.query()
|
||||||
? "No results found"
|
? "No results found"
|
||||||
: "Enter a search term to find podcasts"}
|
: "Enter a search term to find podcasts"}
|
||||||
</text>
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoadingIndicator label="Searching…" />
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -431,9 +439,7 @@ function SearchPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={() => (depth() >= 1 ? "Query" : "Up")}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,5 +115,19 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "fetchMore",
|
||||||
|
label: "Fetch More",
|
||||||
|
kind: "select",
|
||||||
|
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||||
|
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
||||||
|
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||||
|
app.updatePreferences({ fetchMoreMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,12 +267,6 @@ export function SettingsPage() {
|
|||||||
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
||||||
return editorItem()?.label ?? "Editor";
|
return editorItem()?.label ?? "Editor";
|
||||||
};
|
};
|
||||||
const parentLabel = () => {
|
|
||||||
const d = depth();
|
|
||||||
if (d === 1) return "Sections";
|
|
||||||
if (d === 2) return sectionForDepth1()?.label ?? "";
|
|
||||||
return "Up";
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
||||||
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
||||||
@@ -384,9 +378,7 @@ export function SettingsPage() {
|
|||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
parentLabel={parentLabel}
|
|
||||||
currentLabel={currentLabel}
|
currentLabel={currentLabel}
|
||||||
previewLabel="Detail"
|
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
|
fetchMoreMode: "manual",
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -399,15 +399,13 @@ function createFeedStore() {
|
|||||||
return loaded < cached.length;
|
return loaded < cached.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for a feed from the cache.
|
/** Load the next chunk of episodes for one feed from the cache.
|
||||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||||
const loadMoreEpisodes = async (feedId: string) => {
|
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||||
if (isLoadingMore()) return;
|
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId);
|
const feed = getFeed(feedId);
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
|
|
||||||
setIsLoadingMore(true);
|
|
||||||
try {
|
|
||||||
let cached = fullEpisodeCache.get(feedId);
|
let cached = fullEpisodeCache.get(feedId);
|
||||||
|
|
||||||
// If no cache, re-fetch and parse the full feed
|
// If no cache, re-fetch and parse the full feed
|
||||||
@@ -445,6 +443,35 @@ function createFeedStore() {
|
|||||||
saveFeeds(updated);
|
saveFeeds(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Load the next chunk of episodes for a feed from the cache.
|
||||||
|
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||||
|
const loadMoreEpisodes = async (feedId: string) => {
|
||||||
|
if (isLoadingMore()) return;
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
await loadMoreEpisodesForFeed(feedId);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True if any feed still has cached episodes beyond its loaded window. */
|
||||||
|
const hasMoreAcrossAll = (): boolean => {
|
||||||
|
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
||||||
|
* still has cached episodes — powers the Feed page's "[Fetch More]". */
|
||||||
|
const loadMoreAllFeeds = async () => {
|
||||||
|
if (isLoadingMore()) return;
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
||||||
|
for (const feed of pending) {
|
||||||
|
await loadMoreEpisodesForFeed(feed.id);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}
|
}
|
||||||
@@ -487,6 +514,8 @@ function createFeedStore() {
|
|||||||
refreshFeed,
|
refreshFeed,
|
||||||
refreshAllFeeds,
|
refreshAllFeeds,
|
||||||
loadMoreEpisodes,
|
loadMoreEpisodes,
|
||||||
|
loadMoreAllFeeds,
|
||||||
|
hasMoreAcrossAll,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
|
|||||||
@@ -84,11 +84,16 @@ export type AppSettings = {
|
|||||||
visualizer: VisualizerSettings;
|
visualizer: VisualizerSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
|
||||||
|
export type FetchMoreMode = "manual" | "auto";
|
||||||
|
|
||||||
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) */
|
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||||
autoJumpToPlayer: boolean;
|
autoJumpToPlayer: boolean;
|
||||||
|
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
||||||
|
fetchMoreMode: FetchMoreMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
|
fetchMoreMode: "manual",
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
*
|
*
|
||||||
* Verified through the opentui test renderer's captured frames (the same
|
* Verified through the opentui test renderer's captured frames (the same
|
||||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||||
* observable as rendered column widths and border colors.
|
* observable as rendered column widths.
|
||||||
*
|
*
|
||||||
* • Unit: three columns render at 1:2:2 (e.g. 20/40/40 of 100) even when the
|
* • Unit: three columns render at 1:2:2 (e.g. 20/40/40 of 100) even when the
|
||||||
* parent and preview children are null, and the blank parent keeps its
|
* parent and preview children are null, and the blank parent keeps its
|
||||||
* slot with a muted placeholder.
|
* slot with a muted placeholder.
|
||||||
* • Integration: toggling `focused` moves the accent focus ring onto/off the
|
* • Integration: the current pane renders muted left/right border edges
|
||||||
* current column; parent & preview borders stay muted either way.
|
* only (no full box, no accent ring) — `focused` toggles scroll-following
|
||||||
|
* but never changes the border; parent and preview stay borderless.
|
||||||
*
|
*
|
||||||
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
|
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
|
||||||
* in bunfig.toml registers the solid JSX transform for the test runner, so
|
* in bunfig.toml registers the solid JSX transform for the test runner, so
|
||||||
@@ -21,52 +22,40 @@ import { testRender } from "@opentui/solid";
|
|||||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
import { PaneRow } from "../src/components/PaneRow";
|
import { PaneRow } from "../src/components/PaneRow";
|
||||||
|
|
||||||
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
type Span = { text: string };
|
||||||
type Frame = { lines: { spans: Span[] }[] };
|
type Frame = { cols: number; lines: { spans: Span[] }[] };
|
||||||
|
|
||||||
// ── Frame introspection helpers ─────────────────────────────────────────────
|
/** Positions of all `│` border glyphs in the first body line that has any. */
|
||||||
function hexOf(fg: Span["fg"]): string | null {
|
function borderColumns(frame: Frame): number[] {
|
||||||
if (!fg?.buffer) return null;
|
const line = frame.lines.find((l) =>
|
||||||
const b = fg.buffer;
|
l.spans.some((s) => s.text.includes("│")),
|
||||||
if (b[3] === 0) return null;
|
|
||||||
return (
|
|
||||||
"#" +
|
|
||||||
[0, 1, 2]
|
|
||||||
.map((i) =>
|
|
||||||
Math.max(0, Math.min(255, Math.round(b[i] * 255)))
|
|
||||||
.toString(16)
|
|
||||||
.padStart(2, "0"),
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/** Column border colors, scanned from the top border row (`┌───┐…`). */
|
|
||||||
function columnBorders(spans: Frame): string[] {
|
|
||||||
const line = spans.lines[1];
|
|
||||||
if (!line) return [];
|
if (!line) return [];
|
||||||
const out: string[] = [];
|
const cols: number[] = [];
|
||||||
|
let col = 0;
|
||||||
for (const sp of line.spans) {
|
for (const sp of line.spans) {
|
||||||
for (const ch of sp.text) {
|
for (const ch of sp.text) {
|
||||||
if (ch === "┌") out.push(hexOf(sp.fg) ?? "default");
|
if (ch === "│") cols.push(col);
|
||||||
|
col++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Column widths (including borders), from the top border row. */
|
/**
|
||||||
|
* Column widths, measured from the current pane's left/right border glyphs:
|
||||||
|
* the parent runs from column 0 to the left border, the current pane spans
|
||||||
|
* both borders, the preview runs from the right border to the frame's edge.
|
||||||
|
*/
|
||||||
function columnWidths(spans: Frame): number[] {
|
function columnWidths(spans: Frame): number[] {
|
||||||
const line = spans.lines[1];
|
const [a, b] = borderColumns(spans);
|
||||||
if (!line) return [];
|
if (b === undefined) return [];
|
||||||
const widths: number[] = [];
|
return [a, b - a + 1, spans.cols - b - 1];
|
||||||
for (const sp of line.spans) {
|
}
|
||||||
for (const ch of sp.text) {
|
|
||||||
if (ch === "┌") widths.push(0);
|
/** Entire frame as plain text — used to assert no border glyphs remain. */
|
||||||
else if (widths.length && ch === "─") widths[widths.length - 1]++;
|
function frameText(spans: Frame): string {
|
||||||
else if (widths.length && ch === "┐") widths[widths.length - 1] += 2;
|
return spans.lines.map((l) => l.spans.map((s) => s.text).join("")).join("\n");
|
||||||
}
|
|
||||||
}
|
|
||||||
return widths;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element children must be accessors (`() => JSX`): JSX elements are only
|
// Element children must be accessors (`() => JSX`): JSX elements are only
|
||||||
@@ -92,20 +81,25 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
parent={props.parent as any}
|
parent={props.parent as any}
|
||||||
current={props.current as any}
|
current={props.current as any}
|
||||||
preview={props.preview as any}
|
preview={props.preview as any}
|
||||||
parentLabel="Up"
|
|
||||||
currentLabel="List"
|
currentLabel="List"
|
||||||
previewLabel="Detail"
|
|
||||||
focused={props.focused as any}
|
focused={props.focused as any}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
),
|
),
|
||||||
{ width: props.width ?? 100, height: props.height ?? 8, useThread: false },
|
{ width: props.width ?? 100, height: props.height ?? 8, useThread: false },
|
||||||
);
|
);
|
||||||
for (let i = 0; i < 6; i++) {
|
// ThemeProvider only mounts its children once the theme resolves (async
|
||||||
|
// palette/theme loading). Poll the title row until it renders, so the
|
||||||
|
// captured frame below is actually a mounted PaneRow.
|
||||||
|
let spans: Frame | null = null;
|
||||||
|
for (let i = 0; i < 40 && !spans; i++) {
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
await new Promise((r) => setTimeout(r, 40));
|
const frame = setup.captureSpans() as unknown as Frame;
|
||||||
|
const head = frame.lines[0]?.spans.map((s) => s.text).join("") ?? "";
|
||||||
|
if (head.includes("List")) spans = frame;
|
||||||
|
else await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
const spans = setup.captureSpans() as unknown as Frame;
|
if (!spans) throw new Error("PaneRow did not render before timeout");
|
||||||
return {
|
return {
|
||||||
spans,
|
spans,
|
||||||
destroy: async () => {
|
destroy: async () => {
|
||||||
@@ -138,7 +132,7 @@ describe("PaneRow layout", () => {
|
|||||||
const widths = columnWidths(spans);
|
const widths = columnWidths(spans);
|
||||||
expect(widths).toHaveLength(3);
|
expect(widths).toHaveLength(3);
|
||||||
const [p, c, v] = widths;
|
const [p, c, v] = widths;
|
||||||
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5, borders included).
|
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5).
|
||||||
expect(p).toBe(20);
|
expect(p).toBe(20);
|
||||||
expect(c).toBe(40);
|
expect(c).toBe(40);
|
||||||
expect(v).toBe(40);
|
expect(v).toBe(40);
|
||||||
@@ -171,9 +165,14 @@ describe("PaneRow layout", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Integration: focused toggles the accent ring on the current column ─────
|
// ── Integration: the current pane carries muted left/right borders only ────
|
||||||
describe("PaneRow focus ring", () => {
|
describe("PaneRow current-pane borders", () => {
|
||||||
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
// The current column renders left/right edge glyphs (│) only — never a
|
||||||
|
// full box. `focused` gates scroll-following but never changes the border
|
||||||
|
// (always muted — no accent ring), and parent/preview stay borderless.
|
||||||
|
const boxGlyphs = /[┌┐└┘─]/;
|
||||||
|
|
||||||
|
test("focused=true renders left/right borders on the current pane only", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
@@ -182,14 +181,13 @@ describe("PaneRow focus ring", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
|
||||||
const [parent, current, preview] = columnBorders(spans);
|
// 100-wide row splits as 20 / 40 / 40: the current pane's edges sit at
|
||||||
// parent & preview are muted; current is the (different) accent color.
|
// columns 20 and 59. No horizontal or corner glyphs — edges only.
|
||||||
expect(parent).toBe(preview);
|
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||||
expect(current).not.toBe(parent);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
expect(current).not.toBe("default");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("focused=false mutes the current column (no accent ring anywhere)", async () => {
|
test("focused=false renders the same muted borders (no accent ring)", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
@@ -198,9 +196,8 @@ describe("PaneRow focus ring", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
|
||||||
const [parent, current, preview] = columnBorders(spans);
|
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||||
expect(current).toBe(parent);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
expect(preview).toBe(parent);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts an accessor for focused (reactive boolean)", async () => {
|
test("accepts an accessor for focused (reactive boolean)", async () => {
|
||||||
@@ -211,9 +208,8 @@ describe("PaneRow focus ring", () => {
|
|||||||
focused: () => true,
|
focused: () => true,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||||
const [parent, current] = columnBorders(spans);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
expect(current).not.toBe(parent); // accessor resolves true → accent ring
|
|
||||||
|
|
||||||
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
@@ -222,18 +218,18 @@ describe("PaneRow focus ring", () => {
|
|||||||
focused: () => false,
|
focused: () => false,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy2);
|
cleanups.push(destroy2);
|
||||||
const [p2, c2] = columnBorders(spans2);
|
expect(borderColumns(spans2)).toEqual([20, 59]);
|
||||||
expect(c2).toBe(p2); // accessor resolves false → muted
|
expect(frameText(spans2)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defaults to focused (current column carries the accent ring)", async () => {
|
test("defaults to focused (same muted borders)", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
preview: null,
|
preview: null,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
const [parent, current] = columnBorders(spans);
|
expect(borderColumns(spans)).toEqual([20, 59]);
|
||||||
expect(current).not.toBe(parent);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user