diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1103fb1..1dd4268 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,8 +24,10 @@ jobs:
bun-version: latest
- name: Install Dependencies
run: bun install
- - name: Lint & Format
+ - name: Lint
run: bun run lint
+ - name: Format check
+ run: bunx oxfmt --check .
- name: Type Check
run: bun run tsc --noEmit
- name: Run Tests
diff --git a/AGENTS.md b/AGENTS.md
index 37d348d..5d3c7e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,152 +1,7 @@
-# Agentic Development Guidelines: Pod Repository
+# AGENTS.md
-This document serves as a comprehensive guide for AI coding assistants and autonomous agents working within the `pod` repository. It outlines the technical stack, architectural patterns, and stylistic preferences to ensure consistency and high-quality contributions.
-
----
-
-## 1. Environment & Setup
-
-The project is built on **Bun** and uses **TypeScript** with **SolidJS** for the TUI (`@opentui/solid`). We use **Just** as a command runner.
-
-### Prerequisites
-
-- **Runtime**: Bun (latest stable)
-- **Language**: TypeScript 5+
-- **Framework**: SolidJS 1.9+
-- **UI Library**: `@opentui/solid`
-- **Tooling**: Oxlint/Oxfmt (Linting & Formatting), Just (Command Runner)
-
-### Initialization
-
-```bash
-bun install
-```
-
----
-
-## 2. Build & Test Commands
-
-We use `just` for task automation. See `justfile` for all commands.
-
-### Running the Application
-
-The app is a TUI requiring a specific preload script.
-
-- **Start (Dev)**: `just start` (or `just s`)
- - Runs: `bun run --watch src/index.tsx`
-- **Debug**: `just debug` (or `just d`)
- - Runs with `DEBUG=true`
-
-### Testing
-
-Tests are in `src/__tests__/`. Use `just test` (or `just t`).
-
-- **Run All Tests**:
- ```bash
- just test
- ```
-- **Run Specific Test File**:
- ```bash
- just test src/__tests__/drive.test.ts
- ```
-- **Run Specific Test Case**:
- ```bash
- just test -t "should render drive list"
- ```
-- **Watch Mode**:
- ```bash
- just test-watch
- ```
-
-### Linting & Formatting
-
-- **Check**: `just check` (or `just c`) - Runs `tsc --noEmit`
-- **Lint**: `just lint` (or `just l`)
-- **Format**: `just fmt` (or `just f`)
-- **Full Check**: `just full-check` (or `just fc`) - Runs check + test
-
----
-
-## 3. Code Style Guidelines
-
-Enforced by `.oxlintrc.json`, `.oxfmt.json`, and `tsconfig.json`.
-
-### Naming Conventions
-
-- **Components**: `PascalCase` (e.g., `DriveItem.tsx`).
-- **Functions**: `camelCase` (e.g., `fetchPodcasts`).
-- **Variables**: `camelCase`; `UPPER_SNAKE_CASE` for constants.
-- **Types/Interfaces**: `PascalCase`. No `I` prefix.
-
-### TypeScript
-
-- **Strict Mode**: Enabled.
-- **No Any**: Avoid `any`. Use explicit types.
-- **Return Types**: Explicitly annotate public function return types.
-- **Path Aliases**: Use `@/` for `src/` (e.g., `import { theme } from "@/theme"`).
-
-### Import Order
-
-1. Built-in (fs, path)
-2. External (solid-js, @opentui/\*)
-3. Internal Alias (@/components/\*)
-4. Relative (./utils)
-
----
-
-## 4. Component Design (SolidJS + TUI)
-
-### Reactive State
-
-- **Signals**: `createSignal` for primitives.
-- **Stores**: `createStore` for objects/arrays (e.g., file lists).
-- **Effects**: `createEffect` for side effects (logging, file I/O).
-
-### TUI Constraints
-
-- **Rendering**: Use `@opentui/solid` components (Box, Text).
-- **Layout**: Flexbox-like. Use `flexDirection="column"` for stacks.
-- **Input**: Handle `onKeyPress` events. Manage focus manually if needed.
-- **No DOM**: There is no HTML DOM. Do not use `div`, `span`, or web APIs.
-
----
-
-## 5. Error Handling & Debugging
-
-- **No Console Log**: `console.log` breaks the TUI. Use `console.error` (redirected) or file logging.
-- **Error Boundaries**: Wrap major sections in `ErrorBoundary` to prevent crash-to-shell.
-- **Debug Mode**: Use `just debug` to enable verbose logging to file.
-
----
-
-## 6. Directory Structure
-
-```
-src/
-├── __tests__/ # Unit tests
-├── components/ # UI Components (DriveList, Header)
-├── data/ # Mock data
-├── hooks/ # Custom SolidJS hooks
-├── services/ # Core logic (fs, podcasts)
-├── theme/ # Colors and styles
-├── types/ # TS Interfaces
-├── utils/ # Helpers (formatting, math)
-└── index.tsx # Entry point
-```
-
----
-
-## 7. Instructions for Agents
-
-1. **Discovery**: Use `ls` and `grep` to find relevant files. Read `AGENTS.md` first.
-2. **Task Runner**: Always prefer `just` commands over raw `bun` commands.
-3. **Verification**:
- - Run `just lint` to fix formatting.
- - Run `just test` to ensure no regressions.
- - Run `just check` to verify types.
-4. **Implementation**:
- - Write small, testable functions.
- - Follow the "No DOM" rule strictly.
-5. **Commits**: Write semantic commit messages (e.g., `feat: add drive selection`).
-
----
+- Bun-only runtime (`src/index.tsx` exits under Node). Run via `just`, not raw `bun` (see `justfile`).
+- No `createEffect`. Setup/cleanup in `onSettled`; keep state sync in event handlers/store actions.
+- `console.log` breaks the TUI. Use `console.error` or file logging.
+- Wrap major sections in `Errored` to avoid crash-to-shell (see `Root` in `src/index.tsx`).
+- Verify with `just check`, `just test`, `just lint`, `just fmt-check`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d74749b..82a4cb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,15 +2,13 @@
## [0.3.0](https://github.com/joncrangle/podapple/compare/v0.2.1...v0.3.0) (2026-07-07)
-
### Features
-* improve drive detection stability, reliability, and event handling ([359a5e3](https://github.com/joncrangle/podapple/commit/359a5e369202386dba153664f7f937068b588353))
-
+- improve drive detection stability, reliability, and event handling ([359a5e3](https://github.com/joncrangle/podapple/commit/359a5e369202386dba153664f7f937068b588353))
### Bug Fixes
-* delete empty show directories when manually removing episodes from drive ([0ae3cd2](https://github.com/joncrangle/podapple/commit/0ae3cd20cb8804a9576ed7ff0f9a488b500ad0a8))
+- delete empty show directories when manually removing episodes from drive ([0ae3cd2](https://github.com/joncrangle/podapple/commit/0ae3cd20cb8804a9576ed7ff0f9a488b500ad0a8))
## [0.2.1](https://github.com/joncrangle/podapple/compare/v0.2.0...v0.2.1) (2026-04-29)
diff --git a/bun.lock b/bun.lock
index 424be3d..7c99a0e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,28 +5,30 @@
"": {
"name": "pod",
"dependencies": {
- "@effect/schema": "^0.75.5",
- "@opentui/core": "^0.5.11",
- "@opentui/solid": "^0.5.11",
- "effect": "^3.22.1",
+ "@opentui/core": "0.5.14",
+ "@opentui/solid": "0.5.14",
+ "effect": "4.0.1",
"node-id3": "^0.2.9",
- "solid-js": "^1.9.15",
+ "solid-js": "2.0.0-rc.13",
},
"devDependencies": {
- "@effect/language-service": "^0.87.2",
- "@types/bun": "^1.4.1",
- "oxfmt": "^0.67.0",
- "oxlint": "^1.82.0",
+ "@effect/language-service": "^0.87.4",
+ "@types/bun": "^1.4.2",
+ "oxfmt": "^0.72.0",
+ "oxlint": "^1.87.0",
},
"optionalDependencies": {
- "@opentui/core-darwin-arm64": "^0.5.11",
- "@opentui/core-darwin-x64": "^0.5.11",
+ "@opentui/core-darwin-arm64": "0.5.14",
+ "@opentui/core-darwin-x64": "0.5.14",
},
"peerDependencies": {
"typescript": "^6.0.3",
},
},
},
+ "patchedDependencies": {
+ "@opentui/solid@0.5.14": "patches/@opentui%2Fsolid@0.5.14.patch",
+ },
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -86,9 +88,7 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
- "@effect/language-service": ["@effect/language-service@0.87.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-CfiSoaVQO8pZgaMZGw5VvMvRGybsJ2LaSDoLYaqiVGvo/YYPYVR2GzUKY0h1NtIweq/+SQ5XLrvtzPIttWQ0GQ=="],
-
- "@effect/schema": ["@effect/schema@0.75.5", "", { "dependencies": { "fast-check": "^3.21.0" }, "peerDependencies": { "effect": "^3.9.2" } }, "sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw=="],
+ "@effect/language-service": ["@effect/language-service@0.87.4", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-AfFn4K45l84LtbQSwoPWc06nrg7OzfV3uJOnIUqimSOWTCIsY1IKBXZfjSsVTq7jDuL9Jtoz8//bZp9+rkEihg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -98,105 +98,105 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
- "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="],
+ "@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="],
- "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="],
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="],
- "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="],
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="],
- "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="],
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="],
- "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="],
+ "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="],
- "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="],
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="],
- "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="],
+ "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="],
- "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="],
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="],
- "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="],
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="],
- "@opentui/solid": ["@opentui/solid@0.5.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-u8RJ4UMwzi+r9M1sopZdnT+7XFSHmLzVmhPhc6N2LfBrA9Db4lZ9sQA+ywgdSDkzdANpAOwGLNUQlY/F1OOCLA=="],
+ "@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="],
- "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.67.0", "", { "os": "android", "cpu": "arm" }, "sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q=="],
+ "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw=="],
- "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.67.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw=="],
+ "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.72.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA=="],
- "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg=="],
+ "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.72.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA=="],
- "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg=="],
+ "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.72.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw=="],
- "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.67.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg=="],
+ "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.72.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ=="],
- "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw=="],
+ "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA=="],
- "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg=="],
+ "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg=="],
- "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ=="],
+ "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g=="],
- "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw=="],
+ "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ=="],
- "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.67.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w=="],
+ "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.72.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg=="],
- "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A=="],
+ "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw=="],
- "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.67.0", "", { "os": "linux", "cpu": "none" }, "sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA=="],
+ "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ=="],
- "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.67.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg=="],
+ "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.72.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ=="],
- "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w=="],
+ "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ=="],
- "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w=="],
+ "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ=="],
- "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.67.0", "", { "os": "none", "cpu": "arm64" }, "sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA=="],
+ "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.72.0", "", { "os": "none", "cpu": "arm64" }, "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA=="],
- "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w=="],
+ "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.72.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw=="],
- "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.67.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA=="],
+ "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.72.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A=="],
- "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg=="],
+ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.72.0", "", { "os": "win32", "cpu": "x64" }, "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw=="],
- "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.82.0", "", { "os": "android", "cpu": "arm" }, "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA=="],
+ "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.87.0", "", { "os": "android", "cpu": "arm" }, "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ=="],
- "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.82.0", "", { "os": "android", "cpu": "arm64" }, "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw=="],
+ "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.87.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA=="],
- "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.82.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg=="],
+ "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.87.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ=="],
- "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.82.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ=="],
+ "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.87.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ=="],
- "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.82.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g=="],
+ "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.87.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg=="],
- "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw=="],
+ "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.87.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA=="],
- "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ=="],
+ "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.87.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ=="],
- "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.82.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA=="],
+ "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.87.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg=="],
- "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.82.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw=="],
+ "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.87.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg=="],
- "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.82.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw=="],
+ "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.87.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA=="],
- "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.82.0", "", { "os": "linux", "cpu": "none" }, "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw=="],
+ "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.87.0", "", { "os": "linux", "cpu": "none" }, "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q=="],
- "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.82.0", "", { "os": "linux", "cpu": "none" }, "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg=="],
+ "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.87.0", "", { "os": "linux", "cpu": "none" }, "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA=="],
- "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.82.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog=="],
+ "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.87.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ=="],
- "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.82.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg=="],
+ "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.87.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow=="],
- "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.82.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg=="],
+ "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.87.0", "", { "os": "linux", "cpu": "x64" }, "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA=="],
- "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.82.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ=="],
+ "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.87.0", "", { "os": "none", "cpu": "arm64" }, "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g=="],
- "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.82.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ=="],
+ "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.87.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ=="],
- "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.82.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A=="],
+ "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.87.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw=="],
- "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="],
+ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.87.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw=="],
- "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.13", "", {}, "sha512-4+pRdrAHtfyE9BUJWBup3TpzJojJjgW2mV1vm/Jik4tWa5epxXB/YrLkwqP1v8+S9XjyKKZu5BSLqmcpswMYeQ=="],
- "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="],
+ "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
@@ -218,7 +218,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
- "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
+ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
@@ -230,7 +230,7 @@
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
- "effect": ["effect@3.22.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ=="],
+ "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.413", "", {}, "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw=="],
@@ -242,8 +242,6 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
- "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
-
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
@@ -288,9 +286,9 @@
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
- "oxfmt": ["oxfmt@0.67.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.67.0", "@oxfmt/binding-android-arm64": "0.67.0", "@oxfmt/binding-darwin-arm64": "0.67.0", "@oxfmt/binding-darwin-x64": "0.67.0", "@oxfmt/binding-freebsd-x64": "0.67.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.67.0", "@oxfmt/binding-linux-arm-musleabihf": "0.67.0", "@oxfmt/binding-linux-arm64-gnu": "0.67.0", "@oxfmt/binding-linux-arm64-musl": "0.67.0", "@oxfmt/binding-linux-ppc64-gnu": "0.67.0", "@oxfmt/binding-linux-riscv64-gnu": "0.67.0", "@oxfmt/binding-linux-riscv64-musl": "0.67.0", "@oxfmt/binding-linux-s390x-gnu": "0.67.0", "@oxfmt/binding-linux-x64-gnu": "0.67.0", "@oxfmt/binding-linux-x64-musl": "0.67.0", "@oxfmt/binding-openharmony-arm64": "0.67.0", "@oxfmt/binding-win32-arm64-msvc": "0.67.0", "@oxfmt/binding-win32-ia32-msvc": "0.67.0", "@oxfmt/binding-win32-x64-msvc": "0.67.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA=="],
+ "oxfmt": ["oxfmt@0.72.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.72.0", "@oxfmt/binding-android-arm64": "0.72.0", "@oxfmt/binding-darwin-arm64": "0.72.0", "@oxfmt/binding-darwin-x64": "0.72.0", "@oxfmt/binding-freebsd-x64": "0.72.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", "@oxfmt/binding-linux-arm64-gnu": "0.72.0", "@oxfmt/binding-linux-arm64-musl": "0.72.0", "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-musl": "0.72.0", "@oxfmt/binding-linux-s390x-gnu": "0.72.0", "@oxfmt/binding-linux-x64-gnu": "0.72.0", "@oxfmt/binding-linux-x64-musl": "0.72.0", "@oxfmt/binding-openharmony-arm64": "0.72.0", "@oxfmt/binding-win32-arm64-msvc": "0.72.0", "@oxfmt/binding-win32-ia32-msvc": "0.72.0", "@oxfmt/binding-win32-x64-msvc": "0.72.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g=="],
- "oxlint": ["oxlint@1.82.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.82.0", "@oxlint/binding-android-arm64": "1.82.0", "@oxlint/binding-darwin-arm64": "1.82.0", "@oxlint/binding-darwin-x64": "1.82.0", "@oxlint/binding-freebsd-x64": "1.82.0", "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", "@oxlint/binding-linux-arm-musleabihf": "1.82.0", "@oxlint/binding-linux-arm64-gnu": "1.82.0", "@oxlint/binding-linux-arm64-musl": "1.82.0", "@oxlint/binding-linux-ppc64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-musl": "1.82.0", "@oxlint/binding-linux-s390x-gnu": "1.82.0", "@oxlint/binding-linux-x64-gnu": "1.82.0", "@oxlint/binding-linux-x64-musl": "1.82.0", "@oxlint/binding-openharmony-arm64": "1.82.0", "@oxlint/binding-win32-arm64-msvc": "1.82.0", "@oxlint/binding-win32-ia32-msvc": "1.82.0", "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ=="],
+ "oxlint": ["oxlint@1.87.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.87.0", "@oxlint/binding-android-arm64": "1.87.0", "@oxlint/binding-darwin-arm64": "1.87.0", "@oxlint/binding-darwin-x64": "1.87.0", "@oxlint/binding-freebsd-x64": "1.87.0", "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", "@oxlint/binding-linux-arm-musleabihf": "1.87.0", "@oxlint/binding-linux-arm64-gnu": "1.87.0", "@oxlint/binding-linux-arm64-musl": "1.87.0", "@oxlint/binding-linux-ppc64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-musl": "1.87.0", "@oxlint/binding-linux-s390x-gnu": "1.87.0", "@oxlint/binding-linux-x64-gnu": "1.87.0", "@oxlint/binding-linux-x64-musl": "1.87.0", "@oxlint/binding-openharmony-arm64": "1.87.0", "@oxlint/binding-win32-arm64-msvc": "1.87.0", "@oxlint/binding-win32-ia32-msvc": "1.87.0", "@oxlint/binding-win32-x64-msvc": "1.87.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
@@ -310,8 +308,6 @@
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
- "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
-
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
@@ -322,11 +318,11 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
+ "seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="],
- "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
+ "seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="],
- "solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
+ "solid-js": ["solid-js@2.0.0-rc.13", "", { "dependencies": { "@solidjs/signals": "^2.0.0-rc.13", "csstype": "^3.1.0", "seroval": "~1.6.7", "seroval-plugins": "~1.6.7" } }, "sha512-62bYOI4JZ15KOqL5eReKyWSwAXrGb0fbX8SDnHsbJk2UX+SzSyy1gobas2cxW/0BXcobGfQ1V6ffPyQkMIBdoQ=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -334,7 +330,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
- "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="],
+ "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
diff --git a/justfile b/justfile
index 17a56e5..efe34c5 100644
--- a/justfile
+++ b/justfile
@@ -46,6 +46,10 @@ fmt:
bun run format
alias f := fmt
+# Check formatting without writing
+fmt-check:
+ bunx oxfmt --check .
+
# Clean build artifacts
clean:
rm -rf node_modules/.cache
diff --git a/package.json b/package.json
index 4ba06c6..cb4328a 100644
--- a/package.json
+++ b/package.json
@@ -14,24 +14,26 @@
"build": "bun scripts/build.ts"
},
"dependencies": {
- "@effect/schema": "^0.75.5",
- "@opentui/core": "^0.5.11",
- "@opentui/solid": "^0.5.11",
- "effect": "^3.22.1",
+ "@opentui/core": "0.5.14",
+ "@opentui/solid": "0.5.14",
+ "effect": "4.0.1",
"node-id3": "^0.2.9",
- "solid-js": "^1.9.15"
+ "solid-js": "2.0.0-rc.13"
},
"devDependencies": {
- "@effect/language-service": "^0.87.2",
- "@types/bun": "^1.4.1",
- "oxfmt": "^0.67.0",
- "oxlint": "^1.82.0"
+ "@effect/language-service": "^0.87.4",
+ "@types/bun": "^1.4.2",
+ "oxfmt": "^0.72.0",
+ "oxlint": "^1.87.0"
},
"peerDependencies": {
"typescript": "^6.0.3"
},
"optionalDependencies": {
- "@opentui/core-darwin-arm64": "^0.5.11",
- "@opentui/core-darwin-x64": "^0.5.11"
+ "@opentui/core-darwin-arm64": "0.5.14",
+ "@opentui/core-darwin-x64": "0.5.14"
+ },
+ "patchedDependencies": {
+ "@opentui/solid@0.5.14": "patches/@opentui%2Fsolid@0.5.14.patch"
}
}
diff --git a/patches/@opentui%2Fsolid@0.5.14.patch b/patches/@opentui%2Fsolid@0.5.14.patch
new file mode 100644
index 0000000..9af68c0
--- /dev/null
+++ b/patches/@opentui%2Fsolid@0.5.14.patch
@@ -0,0 +1,582 @@
+diff --git a/index.bun.js b/index.bun.js
+index a68be404614cb4811e16e439325b9f46e39653ef..c54b5407889b9ac581aa94b2041a0f1f6c8a4784 100644
+--- a/index.bun.js
++++ b/index.bun.js
+@@ -117,7 +117,7 @@ import {
+ engine,
+ Timeline
+ } from "@opentui/core";
+-import { createContext, createSignal, onCleanup, onMount, useContext } from "solid-js";
++import { createContext, createSignal, onSettled, useContext } from "solid-js";
+ var RendererContext = createContext();
+ var useRenderer = () => {
+ const renderer = useContext(RendererContext);
+@@ -128,11 +128,9 @@ var useRenderer = () => {
+ };
+ var onResize = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("resize", callback);
+- });
+- onCleanup(() => {
+- renderer.off("resize", callback);
++ return () => renderer.off("resize", callback);
+ });
+ };
+ var useTerminalDimensions = () => {
+@@ -147,73 +145,65 @@ var useTerminalDimensions = () => {
+ var useKeyboard = (callback, options) => {
+ const renderer = useRenderer();
+ const keyHandler = renderer.keyInput;
+- onMount(() => {
++ onSettled(() => {
+ keyHandler.on("keypress", callback);
+ if (options?.release) {
+ keyHandler.on("keyrelease", callback);
+ }
+- });
+- onCleanup(() => {
+- keyHandler.off("keypress", callback);
+- if (options?.release) {
+- keyHandler.off("keyrelease", callback);
+- }
++ return () => {
++ keyHandler.off("keypress", callback);
++ if (options?.release) {
++ keyHandler.off("keyrelease", callback);
++ }
++ };
+ });
+ };
+ var usePaste = (callback) => {
+ const renderer = useRenderer();
+ const keyHandler = renderer.keyInput;
+- onMount(() => {
++ onSettled(() => {
+ keyHandler.on("paste", callback);
+- });
+- onCleanup(() => {
+- keyHandler.off("paste", callback);
++ return () => keyHandler.off("paste", callback);
+ });
+ };
+ var useKeyHandler = useKeyboard;
+ var onFocus = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("focus", callback);
+- });
+- onCleanup(() => {
+- renderer.off("focus", callback);
++ return () => renderer.off("focus", callback);
+ });
+ };
+ var onBlur = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("blur", callback);
+- });
+- onCleanup(() => {
+- renderer.off("blur", callback);
++ return () => renderer.off("blur", callback);
+ });
+ };
+ var useSelectionHandler = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("selection", callback);
+- });
+- onCleanup(() => {
+- renderer.off("selection", callback);
++ return () => renderer.off("selection", callback);
+ });
+ };
+ var useTimeline = (options = {}) => {
+ const timeline = new Timeline(options);
+- onMount(() => {
++ onSettled(() => {
+ if (options.autoplay !== false) {
+ timeline.play();
+ }
+ engine.register(timeline);
+- });
+- onCleanup(() => {
+- timeline.pause();
+- engine.unregister(timeline);
++ return () => {
++ timeline.pause();
++ engine.unregister(timeline);
++ };
+ });
+ return timeline;
+ };
+ // src/elements/extras.ts
+-import { createEffect, createMemo as createMemo2, getOwner, onCleanup as onCleanup3, runWithOwner, splitProps, untrack as untrack2 } from "solid-js";
++import { createMemo as createMemo2, createRenderEffect as createRenderEffect2, getOwner, runWithOwner, untrack as untrack2 } from "solid-js";
+
+ // src/reconciler.ts
+ import {
+@@ -239,7 +229,47 @@ import { decodeHTMLStrict } from "entities";
+ import { onCleanup as onCleanup2, useContext as useContext2 } from "solid-js";
+
+ // src/renderer/universal.js
+-import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js";
++import { createRoot, createTrackedEffect, createMemo, createComponent, untrack } from "solid-js";
++
++// Solid 2 removed these convenience helpers from the public runtime. Keep the
++// renderer's legacy API surface while preserving reactive property access.
++function mergeProps(...sources) {
++ const result = {};
++ for (const source of sources) {
++ if (!source) continue;
++ for (const key of Reflect.ownKeys(source)) {
++ Object.defineProperty(result, key, {
++ configurable: true,
++ enumerable: true,
++ get: () => source[key]
++ });
++ }
++ }
++ return result;
++}
++function splitProps(props, keys) {
++ const selected = {};
++ const rest = {};
++ const selectedKeys = new Set(keys);
++ for (const key of Reflect.ownKeys(props)) {
++ const target = selectedKeys.has(key) ? selected : rest;
++ Object.defineProperty(target, key, {
++ configurable: true,
++ enumerable: true,
++ get: () => props[key]
++ });
++ }
++ return [selected, rest];
++}
++// OpenTUI's bundled reconciler still calls this with Solid 1's
++// `(compute, previousValue)` signature. Keep that adapter isolated here until
++// OpenTUI ships a Solid 2-native renderer.
++function createRenderEffect(compute, initial) {
++ let previous = initial;
++ return createTrackedEffect(() => {
++ previous = compute(previous);
++ });
++}
+ var memo = (fn) => createMemo(() => fn());
+ function createRenderer({
+ createElement,
+@@ -499,10 +529,9 @@ function createRenderer({
+ }
+
+ // src/renderer/index.ts
+-import { mergeProps as mergeProps2 } from "solid-js";
+ function createRenderer2(options) {
+ const renderer = createRenderer(options);
+- renderer.mergeProps = mergeProps2;
++ renderer.mergeProps = mergeProps;
+ return renderer;
+ }
+
+@@ -843,28 +872,32 @@ function Portal(props) {
+ const renderer = useRenderer();
+ const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner();
+ let content;
+- createEffect(() => {
+- content || (content = runWithOwner(owner, () => createMemo2(() => props.children)));
+- const el = mount();
+- const container = createElement("box"), renderRoot = container;
+- Object.defineProperty(container, "_$host", {
+- get() {
+- return marker.parent;
+- },
+- configurable: true
+- });
+- insert(renderRoot, content);
+- el.add(container);
+- props.ref && props.ref(container);
+- onCleanup3(() => {
+- el.remove(container);
+- process.nextTick(() => {
+- if (!container.parent) {
+- container.destroyRecursively();
+- }
++ createRenderEffect2(
++ () => props.children,
++ () => {
++ content || (content = runWithOwner(owner, () => createMemo2(() => props.children)));
++ const el = mount();
++ const container = createElement("box"), renderRoot = container;
++ Object.defineProperty(container, "_$host", {
++ get() {
++ return marker.parent;
++ },
++ configurable: true
+ });
+- });
+- }, undefined, { render: true });
++ insert(renderRoot, content);
++ el.add(container);
++ props.ref && props.ref(container);
++ return () => {
++ el.remove(container);
++ process.nextTick(() => {
++ if (!container.parent) {
++ container.destroyRecursively();
++ }
++ });
++ };
++ },
++ { render: true }
++ );
+ return marker;
+ }
+ function createDynamic(component, props) {
+@@ -1310,7 +1343,7 @@ function createScrollbackWriter(node, options = {}) {
+ dispose?.();
+ };
+ try {
+- dispose = _render(() => createComponent2(RendererContext.Provider, {
++ dispose = _render(() => createComponent2(RendererContext, {
+ get value() {
+ return snapshotRenderer.renderer;
+ },
+@@ -1351,7 +1384,7 @@ var TimeToFirstDraw = (props) => {
+ };
+ // src/plugins/slot.tsx
+ import { createSlotRegistry } from "@opentui/core";
+-import { children, createMemo as createMemo3, createSignal as createSignal3, ErrorBoundary, For, onCleanup as onCleanup4, splitProps as splitProps2 } from "solid-js";
++import { children, createMemo as createMemo3, createSignal as createSignal3, Errored, For, onCleanup as onCleanup4 } from "solid-js";
+ var EMPTY_ENTRY_IDS = [];
+ function createSolidSlotRegistry(renderer, context, options = {}) {
+ return createSlotRegistry(renderer, "solid:slot-registry", context, options);
+@@ -1367,7 +1400,7 @@ function createSlot(registry, options = {}) {
+ };
+ }
+ function Slot(props) {
+- const [local, slotProps] = splitProps2(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]);
++ const [local, slotProps] = splitProps(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]);
+ const registry = () => local.registry;
+ const pluginFailurePlaceholder = () => local.pluginFailurePlaceholder;
+ const [version, setVersion] = createSignal3(0);
+@@ -1453,14 +1486,14 @@ function Slot(props) {
+ if (!hasInitialOutput) {
+ return resolveFallback(fallbackOnError);
+ }
+- return createComponent2(ErrorBoundary, {
++ return createComponent2(Errored, {
+ fallback: (error) => {
+ const failure = registry().reportPluginError({
+ pluginId: entry.id,
+ slot: slotName(),
+ phase: "render",
+ source: "solid",
+- error
++ error: error()
+ });
+ return renderPluginFailurePlaceholder(failure, fallbackOnError);
+ },
+@@ -1547,7 +1580,7 @@ var mountSolidRoot = (renderer, node) => {
+ originalDestroy();
+ };
+ try {
+- dispose = _render(() => createComponent2(RendererContext.Provider, {
++ dispose = _render(() => createComponent2(RendererContext, {
+ get value() {
+ return renderer;
+ },
+diff --git a/index.js b/index.js
+index 61e64c1a01e23da4243db0a45abef2ebddd246f0..4d28aa3f93115b4a68143033d804ca2d13b8db9a 100644
+--- a/index.js
++++ b/index.js
+@@ -100,7 +100,7 @@ function getComponentCatalogue() {
+ }
+ // src/elements/hooks.ts
+ import { engine, Timeline } from "@opentui/core";
+-import { createContext, createSignal, onCleanup, onMount, useContext } from "solid-js/dist/solid.js";
++import { createContext, createSignal, onSettled, useContext } from "solid-js/dist/solid.js";
+ var RendererContext = createContext();
+ var useRenderer = () => {
+ const renderer = useContext(RendererContext);
+@@ -111,11 +111,9 @@ var useRenderer = () => {
+ };
+ var onResize = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("resize", callback);
+- });
+- onCleanup(() => {
+- renderer.off("resize", callback);
++ return () => renderer.off("resize", callback);
+ });
+ };
+ var useTerminalDimensions = () => {
+@@ -136,73 +134,65 @@ var useTerminalDimensions = () => {
+ var useKeyboard = (callback, options) => {
+ const renderer = useRenderer();
+ const keyHandler = renderer.keyInput;
+- onMount(() => {
++ onSettled(() => {
+ keyHandler.on("keypress", callback);
+ if (options?.release) {
+ keyHandler.on("keyrelease", callback);
+ }
+- });
+- onCleanup(() => {
+- keyHandler.off("keypress", callback);
+- if (options?.release) {
+- keyHandler.off("keyrelease", callback);
+- }
++ return () => {
++ keyHandler.off("keypress", callback);
++ if (options?.release) {
++ keyHandler.off("keyrelease", callback);
++ }
++ };
+ });
+ };
+ var usePaste = (callback) => {
+ const renderer = useRenderer();
+ const keyHandler = renderer.keyInput;
+- onMount(() => {
++ onSettled(() => {
+ keyHandler.on("paste", callback);
+- });
+- onCleanup(() => {
+- keyHandler.off("paste", callback);
++ return () => keyHandler.off("paste", callback);
+ });
+ };
+ var useKeyHandler = useKeyboard;
+ var onFocus = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("focus", callback);
+- });
+- onCleanup(() => {
+- renderer.off("focus", callback);
++ return () => renderer.off("focus", callback);
+ });
+ };
+ var onBlur = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("blur", callback);
+- });
+- onCleanup(() => {
+- renderer.off("blur", callback);
++ return () => renderer.off("blur", callback);
+ });
+ };
+ var useSelectionHandler = (callback) => {
+ const renderer = useRenderer();
+- onMount(() => {
++ onSettled(() => {
+ renderer.on("selection", callback);
+- });
+- onCleanup(() => {
+- renderer.off("selection", callback);
++ return () => renderer.off("selection", callback);
+ });
+ };
+ var useTimeline = (options = {}) => {
+ const timeline = new Timeline(options);
+- onMount(() => {
++ onSettled(() => {
+ if (options.autoplay !== false) {
+ timeline.play();
+ }
+ engine.register(timeline);
+- });
+- onCleanup(() => {
+- timeline.pause();
+- engine.unregister(timeline);
++ return () => {
++ timeline.pause();
++ engine.unregister(timeline);
++ };
+ });
+ return timeline;
+ };
+ // src/elements/extras.ts
+-import { createEffect, createMemo as createMemo2, getOwner, onCleanup as onCleanup3, runWithOwner, splitProps, untrack as untrack2 } from "solid-js/dist/solid.js";
++import { createMemo as createMemo2, createRenderEffect as createRenderEffect2, getOwner, runWithOwner, untrack as untrack2 } from "solid-js/dist/solid.js";
+
+ // src/reconciler.ts
+ import { BaseRenderable, createTextAttributes, ImageRenderable as ImageRenderable2, InputRenderable as InputRenderable2, InputRenderableEvents, isTextNodeRenderable, isEditBufferRenderable, parseColor, Renderable, RootTextNodeRenderable, ScrollBoxRenderable as ScrollBoxRenderable2, SelectRenderable as SelectRenderable2, SelectRenderableEvents, TabSelectRenderable as TabSelectRenderable2, TabSelectRenderableEvents, TextNodeRenderable as TextNodeRenderable2, TextRenderable as TextRenderable2 } from "@opentui/core";
+@@ -210,7 +200,47 @@ import { decodeHTMLStrict } from "entities";
+ import { onCleanup as onCleanup2, useContext as useContext2 } from "solid-js/dist/solid.js";
+
+ // src/renderer/universal.js
+-import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js/dist/solid.js";
++import { createRoot, createTrackedEffect, createMemo, createComponent, untrack } from "solid-js/dist/solid.js";
++
++// Solid 2 removed these convenience helpers from the public runtime. Keep the
++// renderer's legacy API surface while preserving reactive property access.
++function mergeProps(...sources) {
++ const result = {};
++ for (const source of sources) {
++ if (!source) continue;
++ for (const key of Reflect.ownKeys(source)) {
++ Object.defineProperty(result, key, {
++ configurable: true,
++ enumerable: true,
++ get: () => source[key]
++ });
++ }
++ }
++ return result;
++}
++function splitProps(props, keys) {
++ const selected = {};
++ const rest = {};
++ const selectedKeys = new Set(keys);
++ for (const key of Reflect.ownKeys(props)) {
++ const target = selectedKeys.has(key) ? selected : rest;
++ Object.defineProperty(target, key, {
++ configurable: true,
++ enumerable: true,
++ get: () => props[key]
++ });
++ }
++ return [selected, rest];
++}
++// OpenTUI's bundled reconciler still calls this with Solid 1's
++// `(compute, previousValue)` signature. Keep that adapter isolated here until
++// OpenTUI ships a Solid 2-native renderer.
++function createRenderEffect(compute, initial) {
++ let previous = initial;
++ return createTrackedEffect(() => {
++ previous = compute(previous);
++ });
++}
+ var memo = (fn) => createMemo(() => fn());
+ function createRenderer({
+ createElement,
+@@ -470,10 +500,9 @@ function createRenderer({
+ }
+
+ // src/renderer/index.ts
+-import { mergeProps as mergeProps2 } from "solid-js/dist/solid.js";
+ function createRenderer2(options) {
+ const renderer = createRenderer(options);
+- renderer.mergeProps = mergeProps2;
++ renderer.mergeProps = mergeProps;
+ return renderer;
+ }
+
+@@ -820,30 +849,32 @@ function Portal(props) {
+ const renderer = useRenderer();
+ const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner();
+ let content;
+- createEffect(() => {
+- content || (content = runWithOwner(owner, () => createMemo2(() => props.children)));
+- const el = mount();
+- const container = createElement("box"), renderRoot = container;
+- Object.defineProperty(container, "_$host", {
+- get() {
+- return marker.parent;
+- },
+- configurable: true
+- });
+- insert(renderRoot, content);
+- el.add(container);
+- props.ref && props.ref(container);
+- onCleanup3(() => {
+- el.remove(container);
+- process.nextTick(() => {
+- if (!container.parent) {
+- container.destroyRecursively();
+- }
++ createRenderEffect2(
++ () => props.children,
++ () => {
++ content || (content = runWithOwner(owner, () => createMemo2(() => props.children)));
++ const el = mount();
++ const container = createElement("box"), renderRoot = container;
++ Object.defineProperty(container, "_$host", {
++ get() {
++ return marker.parent;
++ },
++ configurable: true
+ });
+- });
+- }, undefined, {
+- render: true
+- });
++ insert(renderRoot, content);
++ el.add(container);
++ props.ref && props.ref(container);
++ return () => {
++ el.remove(container);
++ process.nextTick(() => {
++ if (!container.parent) {
++ container.destroyRecursively();
++ }
++ });
++ };
++ },
++ { render: true }
++ );
+ return marker;
+ }
+ function createDynamic(component, props) {
+@@ -1284,7 +1315,7 @@ function createScrollbackWriter(node, options = {}) {
+ dispose?.();
+ };
+ try {
+- dispose = _render(() => createComponent2(RendererContext.Provider, {
++ dispose = _render(() => createComponent2(RendererContext, {
+ get value() {
+ return snapshotRenderer.renderer;
+ },
+@@ -1325,7 +1356,7 @@ var TimeToFirstDraw = (props) => {
+ };
+ // src/plugins/slot.tsx
+ import { createSlotRegistry } from "@opentui/core";
+-import { children, createMemo as createMemo3, createSignal as createSignal3, ErrorBoundary, For, onCleanup as onCleanup4, splitProps as splitProps2 } from "solid-js/dist/solid.js";
++import { children, createMemo as createMemo3, createSignal as createSignal3, Errored, For, onCleanup as onCleanup4 } from "solid-js/dist/solid.js";
+ var EMPTY_ENTRY_IDS = [];
+ function createSolidSlotRegistry(renderer, context, options = {}) {
+ return createSlotRegistry(renderer, "solid:slot-registry", context, options);
+@@ -1341,7 +1372,7 @@ function createSlot(registry, options = {}) {
+ };
+ }
+ function Slot(props) {
+- const [local, slotProps] = splitProps2(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]);
++ const [local, slotProps] = splitProps(props, ["registry", "name", "mode", "children", "pluginFailurePlaceholder"]);
+ const registry = () => local.registry;
+ const pluginFailurePlaceholder = () => local.pluginFailurePlaceholder;
+ const [version, setVersion] = createSignal3(0);
+@@ -1427,14 +1458,14 @@ function Slot(props) {
+ if (!hasInitialOutput) {
+ return resolveFallback(fallbackOnError);
+ }
+- return createComponent2(ErrorBoundary, {
++ return createComponent2(Errored, {
+ fallback: (error) => {
+ const failure = registry().reportPluginError({
+ pluginId: entry.id,
+ slot: slotName(),
+ phase: "render",
+ source: "solid",
+- error
++ error: error()
+ });
+ return renderPluginFailurePlaceholder(failure, fallbackOnError);
+ },
+@@ -1521,7 +1552,7 @@ var mountSolidRoot = (renderer, node) => {
+ originalDestroy();
+ };
+ try {
+- dispose = _render(() => createComponent2(RendererContext.Provider, {
++ dispose = _render(() => createComponent2(RendererContext, {
+ get value() {
+ return renderer;
+ },
diff --git a/src/__tests__/drive_selection.test.ts b/src/__tests__/drive_selection.test.ts
new file mode 100644
index 0000000..829bccf
--- /dev/null
+++ b/src/__tests__/drive_selection.test.ts
@@ -0,0 +1,22 @@
+import { beforeEach, describe, expect, it, mock } from "bun:test";
+import { actions, state } from "@/store";
+import type { Drive } from "@/types/drive";
+import { selectDrive } from "@/utils/driveSelection";
+
+describe("drive selection", () => {
+ beforeEach(() => actions.resetState());
+
+ it("clears a stale error for selector-driven selection", () => {
+ const drive = { id: "drive-1", name: "Drive", mountPoint: "/Volumes/Drive" } as Drive;
+ const loadDrivePodcasts = mock(() => {});
+
+ actions.setErrorMsg("No drive selected");
+ actions.setAppView("driveSelection");
+ selectDrive(drive, loadDrivePodcasts);
+
+ expect(state.errorMsg).toBe("");
+ expect(state.currentDrive).toBe(drive);
+ expect(state.appView).toBe("normal");
+ expect(loadDrivePodcasts).toHaveBeenCalledWith(drive);
+ });
+});
diff --git a/src/__tests__/effects.test.ts b/src/__tests__/effects.test.ts
index d8c24b6..1d490fa 100644
--- a/src/__tests__/effects.test.ts
+++ b/src/__tests__/effects.test.ts
@@ -7,7 +7,7 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "bun:test";
-import { Effect, Layer, Stream } from "effect";
+import { Cause, Effect, Layer, Result, Stream } from "effect";
import {
createDriveDetectionTest,
DriveDetection,
@@ -88,13 +88,13 @@ describe("FileSystem Service", () => {
});
const result = await Effect.runPromise(
- Effect.either(Effect.provide(program, createFileSystemTest())),
+ Effect.result(Effect.provide(program, createFileSystemTest())),
);
- expect(result._tag).toBe("Left");
- if (result._tag === "Left") {
- expect(result.left).toBeInstanceOf(FileNotFoundError);
- expect((result.left as FileNotFoundError).path).toBe("/missing.txt");
+ expect(result._tag).toBe("Failure");
+ if (result._tag === "Failure") {
+ expect(result.failure).toBeInstanceOf(FileNotFoundError);
+ expect((result.failure as FileNotFoundError).path).toBe("/missing.txt");
}
});
});
@@ -139,12 +139,12 @@ describe("FileSystem Service", () => {
});
const result = await Effect.runPromise(
- Effect.either(Effect.provide(program, createFileSystemTest())),
+ Effect.result(Effect.provide(program, createFileSystemTest())),
);
- expect(result._tag).toBe("Left");
- if (result._tag === "Left") {
- expect(result.left).toBeInstanceOf(FileSystemCopyError);
+ expect(result._tag).toBe("Failure");
+ if (result._tag === "Failure") {
+ expect(result.failure).toBeInstanceOf(FileSystemCopyError);
}
});
});
@@ -354,6 +354,7 @@ describe("SyncEngine Service", () => {
{
id: "ep-1",
title: "Episode 1",
+ author: "Host A",
duration: 3600,
published: new Date("2024-01-01"),
onDrive: false,
@@ -363,6 +364,7 @@ describe("SyncEngine Service", () => {
{
id: "ep-2",
title: "Episode 2",
+ author: "Host A",
duration: 1800,
published: new Date("2024-01-08"),
onDrive: true,
@@ -876,9 +878,10 @@ describe("DriveScan Service", () => {
const exit = yield* podcastService.loadMacPodcasts.pipe(Effect.exit);
expect(exit._tag).toBe("Failure");
if (exit._tag === "Failure") {
- expect(exit.cause._tag).toBe("Fail");
- if (exit.cause._tag === "Fail") {
- expect(exit.cause.error).toBeInstanceOf(DatabaseNotFoundError);
+ const error = Cause.findError(exit.cause);
+ expect(Result.isSuccess(error)).toBe(true);
+ if (Result.isSuccess(error)) {
+ expect(error.success).toBeInstanceOf(DatabaseNotFoundError);
}
}
});
diff --git a/src/__tests__/formatting.test.ts b/src/__tests__/formatting.test.ts
index 88dffce..c06b344 100644
--- a/src/__tests__/formatting.test.ts
+++ b/src/__tests__/formatting.test.ts
@@ -180,6 +180,7 @@ describe("Formatting Utils", () => {
const mockEpisode: PodcastEpisode = {
id: "1",
title: "Ep 1",
+ author: "Host A",
showName: "Show A",
filePath: "/path/to/ep1.mp3",
published: new Date("2023-01-01"),
@@ -208,6 +209,46 @@ describe("Formatting Utils", () => {
expect(showB).toBeDefined();
expect(showB?.episodes).toHaveLength(1);
});
+
+ test("threads the episode author onto the grouped podcast", () => {
+ const episodes = [
+ { ...mockEpisode, id: "1", title: "Ep 1", author: "Dan Carlin" },
+ { ...mockEpisode, id: "2", title: "Ep 2", author: "Dan Carlin" },
+ ];
+
+ const grouped = groupEpisodesByPodcast(episodes);
+ const showA = grouped.find((p) => p.title === "Show A");
+
+ expect(showA?.author).toBe("Dan Carlin");
+ // The author is carried down onto each episode too.
+ expect(showA?.episodes.map((e) => e.author)).toEqual(["Dan Carlin", "Dan Carlin"]);
+ });
+
+ test("falls back to a non-blank author when no episode has one", () => {
+ const episodes = [
+ { ...mockEpisode, id: "1", title: "Ep 1", author: "" },
+ { ...mockEpisode, id: "2", title: "Ep 2", author: " " },
+ ];
+
+ const grouped = groupEpisodesByPodcast(episodes);
+ const showA = grouped.find((p) => p.title === "Show A");
+
+ // Never the empty string: a blank ID3 artist tag is the bug being fixed.
+ expect(showA?.author).not.toBe("");
+ expect(showA?.author).toBe("Unknown");
+ });
+
+ test("uses the first non-empty author when authors are mixed", () => {
+ const episodes = [
+ { ...mockEpisode, id: "1", title: "Ep 1", author: "" },
+ { ...mockEpisode, id: "2", title: "Ep 2", author: "David Senra" },
+ ];
+
+ const grouped = groupEpisodesByPodcast(episodes);
+ const showA = grouped.find((p) => p.title === "Show A");
+
+ expect(showA?.author).toBe("David Senra");
+ });
});
describe("mapPodcastsToEpisodes", () => {
@@ -222,6 +263,7 @@ describe("Formatting Utils", () => {
{
id: "1",
title: "Ep 1",
+ author: "Author",
duration: 3600,
published: new Date("2023-01-01"),
onDrive: true,
diff --git a/src/__tests__/id3_tagging.test.ts b/src/__tests__/id3_tagging.test.ts
new file mode 100644
index 0000000..6b8fb57
--- /dev/null
+++ b/src/__tests__/id3_tagging.test.ts
@@ -0,0 +1,100 @@
+import { afterAll, describe, expect, it } from "bun:test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Effect, Layer } from "effect";
+import nodeID3 from "node-id3";
+import { FileSystemLive } from "@/services/effects/FileSystem";
+import { LoggerLive } from "@/services/effects/Logger";
+import { MetadataEditor, MetadataEditorLive } from "@/services/effects/MetadataEditor";
+import { groupEpisodesByPodcast } from "@/utils/formatting";
+import type { PodcastEpisode } from "@/types/podcast";
+
+const dir = await mkdtemp(join(tmpdir(), "podapple-id3-"));
+
+afterAll(async () => {
+ await rm(dir, { recursive: true, force: true });
+});
+
+/** A byte stream big enough for node-id3 to parse as a real audio file. */
+const fakeMp3 = (): Uint8Array => {
+ const bytes = new Uint8Array(1024);
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) % 251;
+ return bytes;
+};
+
+const writeTag = (file: string, artist: string, album: string) =>
+ Effect.runPromise(
+ Effect.provide(
+ Effect.gen(function* () {
+ const editor = yield* MetadataEditor;
+ return yield* editor.write(file, {
+ title: "Ep 1",
+ artist,
+ album,
+ genre: "Podcast",
+ year: "2024",
+ comment: "Published: 2024-01-01",
+ });
+ }),
+ Layer.mergeAll(MetadataEditorLive, FileSystemLive).pipe(Layer.provide(LoggerLive)),
+ ),
+ );
+
+describe("ID3 tagging, end to end", () => {
+ it("puts the podcast author in the file's ID3 artist frame", async () => {
+ const file = join(dir, "ep1.mp3");
+ await Bun.write(file, fakeMp3());
+
+ // Exactly what db.worker now produces from ZMTPODCAST.ZAUTHOR.
+ const episode: PodcastEpisode = {
+ id: "1",
+ title: "Ep 1",
+ author: "Dan Carlin",
+ duration: 60,
+ published: new Date("2024-01-01"),
+ onDrive: false,
+ filePath: file,
+ fileSize: 1024,
+ selected: true,
+ showName: "Hardcore History",
+ };
+
+ const podcast = groupEpisodesByPodcast([episode])[0]!;
+ expect(podcast.author).toBe("Dan Carlin");
+
+ await writeTag(file, podcast.author, podcast.title);
+
+ // Read the file back off disk, rather than trusting what was passed in.
+ // This is the assertion the whole ZAUTHOR chain exists to satisfy: every
+ // file podapple ever synced carried a blank artist before this.
+ const tags = nodeID3.read(file);
+ expect(tags.artist).toBe("Dan Carlin");
+ expect(tags.album).toBe("Hardcore History");
+ });
+
+ it("never writes a blank artist, whatever the episode carries", async () => {
+ const file = join(dir, "ep2.mp3");
+ await Bun.write(file, fakeMp3());
+
+ // No ZAUTHOR recorded, and a whitespace-only one, both have to fall back.
+ for (const author of ["", " "]) {
+ const episode: PodcastEpisode = {
+ id: "2",
+ title: "Ep 2",
+ author,
+ duration: 60,
+ published: new Date("2024-01-01"),
+ onDrive: false,
+ filePath: file,
+ fileSize: 1024,
+ selected: true,
+ showName: "Show",
+ };
+ const podcast = groupEpisodesByPodcast([episode])[0]!;
+ expect(podcast.author).not.toBe("");
+ await writeTag(file, podcast.author, podcast.title);
+ expect(nodeID3.read(file).artist).not.toBe("");
+ }
+ });
+});
diff --git a/src/__tests__/keyboard.test.ts b/src/__tests__/keyboard.test.ts
index 3ab2b9f..36a3d13 100644
--- a/src/__tests__/keyboard.test.ts
+++ b/src/__tests__/keyboard.test.ts
@@ -1,36 +1,7 @@
import { describe, expect, test } from "bun:test";
-import { getFooterShortcuts, isNavigationKey } from "@/utils/keyboard";
+import { getFooterShortcuts } from "@/utils/keyboard";
describe("keyboard utilities", () => {
- describe("isNavigationKey", () => {
- test("should return true for j key", () => {
- expect(isNavigationKey("j")).toBe(true);
- });
-
- test("should return true for k key", () => {
- expect(isNavigationKey("k")).toBe(true);
- });
-
- test("should return true for up arrow", () => {
- expect(isNavigationKey("up")).toBe(true);
- });
-
- test("should return true for down arrow", () => {
- expect(isNavigationKey("down")).toBe(true);
- });
-
- test("should return false for tab (no longer navigation key)", () => {
- expect(isNavigationKey("tab")).toBe(false);
- });
-
- test("should return false for non-navigation keys", () => {
- expect(isNavigationKey("q")).toBe(false);
- expect(isNavigationKey("s")).toBe(false);
- expect(isNavigationKey("enter")).toBe(false);
- expect(isNavigationKey("escape")).toBe(false);
- });
- });
-
describe("getFooterShortcuts", () => {
test("should return two lines for normal view", () => {
const shortcuts = getFooterShortcuts("normal");
diff --git a/src/__tests__/keyboard_integration.test.ts b/src/__tests__/keyboard_integration.test.ts
index cd1e65f..dfd4fd5 100644
--- a/src/__tests__/keyboard_integration.test.ts
+++ b/src/__tests__/keyboard_integration.test.ts
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { useAppKeyboard } from "@/hooks/useAppKeyboard";
import type { useAppLogic } from "@/hooks/useAppLogic";
import { actions, state } from "@/store";
+import type { Drive } from "@/types/drive";
import type { PodcastEpisode } from "@/types/podcast";
interface MockKeyboardEvent {
@@ -85,6 +86,17 @@ describe("Keyboard Integration", () => {
expect(mockLogic.scanForDrives).toHaveBeenCalled();
});
+ it("should clear a stale drive error when selecting a drive with enter", () => {
+ actions.setDrives([{ id: "drive-1", name: "Drive", mountPoint: "/Volumes/Drive" } as Drive]);
+ actions.setErrorMsg("No drive selected");
+ actions.setAppView("driveSelection");
+
+ pressKey("return");
+
+ expect(state.errorMsg).toBe("");
+ expect(state.currentDrive?.id).toBe("drive-1");
+ });
+
it("should toggle selection with 'space'", () => {
actions.setMacPodcasts([{ id: "1", selected: false } as PodcastEpisode]);
actions.setMacIndex(0);
diff --git a/src/__tests__/opentui_runtime.test.ts b/src/__tests__/opentui_runtime.test.ts
new file mode 100644
index 0000000..3fd49c8
--- /dev/null
+++ b/src/__tests__/opentui_runtime.test.ts
@@ -0,0 +1,31 @@
+import { createComponent } from "solid-js";
+import { Portal, testRender } from "@opentui/solid";
+import { describe, expect, it } from "bun:test";
+import { Spinner } from "@/components/Spinner";
+
+describe("OpenTUI Solid 2 runtime", () => {
+ it("mounts and disposes a renderer root", async () => {
+ const setup = await testRender(() => null);
+
+ expect(setup.renderer).toBeDefined();
+ setup.renderer.destroy();
+ });
+
+ it("mounts and disposes components with settle-phase cleanup", async () => {
+ const setup = await testRender(() => createComponent(Spinner, { variant: "bouncingBall" }));
+
+ expect(setup.renderer).toBeDefined();
+ setup.renderer.destroy();
+ });
+
+ it("mounts and disposes portals with render-phase cleanup", async () => {
+ const setup = await testRender(() =>
+ createComponent(Portal, {
+ children: createComponent(Spinner, { variant: "bouncingBall" }),
+ }),
+ );
+
+ expect(setup.renderer).toBeDefined();
+ setup.renderer.destroy();
+ });
+});
diff --git a/src/__tests__/plist.test.ts b/src/__tests__/plist.test.ts
index 2c04b26..46eef7d 100644
--- a/src/__tests__/plist.test.ts
+++ b/src/__tests__/plist.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
-import { Either } from "effect";
+import { Result } from "effect";
import {
getPlistBoolean,
getPlistNumber,
@@ -19,7 +19,7 @@ describe("Plist Parser", () => {
WALKMAN
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
expect(getPlistString(result, "VolumeName")).toBe("WALKMAN");
});
@@ -33,7 +33,7 @@ describe("Plist Parser", () => {
16000000000
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
expect(getPlistNumber(result, "TotalSize")).toBe(32000000000);
expect(getPlistNumber(result, "FreeSpace")).toBe(16000000000);
});
@@ -48,7 +48,7 @@ describe("Plist Parser", () => {
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
expect(getPlistBoolean(result, "Internal")).toBe(false);
expect(getPlistBoolean(result, "Removable")).toBe(true);
});
@@ -66,7 +66,7 @@ describe("Plist Parser", () => {
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
const driveInfo = result.DriveInfo as PlistDict;
expect(driveInfo).toBeDefined();
expect(getPlistString(driveInfo, "Name")).toBe("USB Drive");
@@ -84,7 +84,7 @@ describe("Plist Parser", () => {
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
const volumes = result.Volumes as string[];
expect(volumes).toHaveLength(2);
expect(volumes[0]).toBe("WALKMAN");
@@ -116,7 +116,7 @@ describe("Plist Parser", () => {
WALKMAN
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
expect(getPlistString(result, "BusProtocol")).toBe("USB");
expect(getPlistString(result, "VolumeName")).toBe("WALKMAN");
@@ -136,7 +136,7 @@ describe("Plist Parser", () => {
Tom & Jerry's Drive
`;
- const result = Either.getOrThrow(parsePlist(xml)) as PlistDict;
+ const result = Result.getOrThrow(parsePlist(xml)) as PlistDict;
expect(getPlistString(result, "Name")).toBe("Tom & Jerry's Drive");
});
});
diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts
index 03bac6a..9aab9cf 100644
--- a/src/__tests__/store.test.ts
+++ b/src/__tests__/store.test.ts
@@ -54,6 +54,44 @@ describe("store actions", () => {
expect(state.transferProgress.totalFiles).toBe(10);
});
+ it("should clear a pending error when a success is set", () => {
+ // Seed the error first, otherwise the assertion below cannot fail.
+ actions.setErrorMsg("something broke");
+ expect(state.errorMsg).toBe("something broke");
+
+ actions.setSuccessMsg("Sync complete");
+ expect(state.successMsg).toBe("Sync complete");
+ // A success message must not leave the error channel populated.
+ expect(state.errorMsg).toBe("");
+
+ actions.setSuccessMsg("");
+ });
+
+ it("should clear successMsg when an error is set", () => {
+ actions.setSuccessMsg("Sync complete");
+ // Seed the success first, otherwise the assertion below cannot fail.
+ expect(state.successMsg).toBe("Sync complete");
+
+ // Only one status line at a time: a fresh error must not render
+ // alongside a stale success.
+ actions.setErrorMsg("something broke");
+ expect(state.errorMsg).toBe("something broke");
+ expect(state.successMsg).toBe("");
+
+ actions.setErrorMsg("");
+ });
+
+ it("should clear errorMsg when a success is set", () => {
+ actions.setErrorMsg("something broke");
+ expect(state.errorMsg).toBe("something broke");
+
+ actions.setSuccessMsg("Sync complete");
+ expect(state.successMsg).toBe("Sync complete");
+ expect(state.errorMsg).toBe("");
+
+ actions.setSuccessMsg("");
+ });
+
it("should add debug messages", () => {
actions.clearDebugMessages();
actions.addDebugMessage("Test message", "info");
diff --git a/src/__tests__/sync.test.ts b/src/__tests__/sync.test.ts
index e0db1e0..af397b9 100644
--- a/src/__tests__/sync.test.ts
+++ b/src/__tests__/sync.test.ts
@@ -8,12 +8,21 @@
* - Progress streaming
*/
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { describe, expect, it } from "bun:test";
import { Effect, Layer, Stream } from "effect";
import { EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher";
-import { createFileSystemTest } from "@/services/effects/FileSystem";
+import { createFileSystemTest, FileSystemLive } from "@/services/effects/FileSystem";
+import { LoggerLive } from "@/services/effects/Logger";
import { createMetadataEditorTest } from "@/services/effects/MetadataEditor";
-import { createSyncEngineTest, formatDestPath, SyncEngine } from "@/services/effects/SyncEngine";
+import {
+ createSyncEngineTest,
+ formatDestPath,
+ SyncEngine,
+ SyncEngineLive,
+} from "@/services/effects/SyncEngine";
import type { Podcast } from "@/types/podcast";
import { sanitizeFilename } from "@/utils/formatting";
@@ -29,6 +38,7 @@ describe("SyncEngine", () => {
{
id: "ep-1",
title: "Episode 1: Getting Started",
+ author: "Host A",
duration: 3600,
published: new Date("2024-01-15"),
onDrive: false,
@@ -38,6 +48,7 @@ describe("SyncEngine", () => {
{
id: "ep-2",
title: "Episode 2: Deep Dive",
+ author: "Host A",
duration: 1800,
published: new Date("2024-01-22"),
onDrive: true, // Already synced - should be skipped
@@ -47,6 +58,7 @@ describe("SyncEngine", () => {
{
id: "ep-3",
title: "Episode 3: Q&A Session",
+ author: "Host A",
duration: 2400,
published: new Date("2024-01-29"),
onDrive: false,
@@ -64,6 +76,7 @@ describe("SyncEngine", () => {
{
id: "ep-4",
title: "The Universe Explained",
+ author: "Host B",
duration: 4200,
published: new Date("2024-02-01"),
onDrive: false,
@@ -359,6 +372,7 @@ describe("SyncEngine", () => {
{
id: "e1",
title: "Ep",
+ author: "A",
duration: 100,
published: new Date("2024-01-01"),
onDrive: false,
@@ -398,6 +412,31 @@ describe("SyncEngine", () => {
});
describe("copyFileWithProgress", () => {
+ it("copies files using live file handles", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "podapple-sync-"));
+ const sourcePath = join(directory, "source.mp3");
+ const destinationPath = join(directory, "destination.mp3");
+ const content = new Uint8Array([1, 2, 3, 4, 5]);
+
+ try {
+ await Bun.write(sourcePath, content);
+
+ const program = Effect.gen(function* () {
+ const engine = yield* SyncEngine;
+ yield* engine.copyFileWithProgress(sourcePath, destinationPath, () => {});
+ });
+
+ const layer = Layer.mergeAll(SyncEngineLive, FileSystemLive).pipe(
+ Layer.provide(LoggerLive),
+ );
+ await Effect.runPromise(Effect.provide(program, layer));
+
+ expect(await Bun.file(destinationPath).bytes()).toEqual(content);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
it("copies file and reports progress", async () => {
const content = new Uint8Array([1, 2, 3, 4, 5]);
const mockFiles = new Map([["/src/file.mp3", content]]);
@@ -438,7 +477,7 @@ describe("SyncEngine", () => {
});
const result = await Effect.runPromise(
- Effect.either(
+ Effect.result(
Effect.provide(
program,
Layer.mergeAll(createSyncEngineTest(), EpisodeMatcherLive, createFileSystemTest()),
@@ -446,7 +485,7 @@ describe("SyncEngine", () => {
),
);
- expect(result._tag).toBe("Left");
+ expect(result._tag).toBe("Failure");
});
});
diff --git a/src/__tests__/syncEngineLive.test.ts b/src/__tests__/syncEngineLive.test.ts
new file mode 100644
index 0000000..8d58d73
--- /dev/null
+++ b/src/__tests__/syncEngineLive.test.ts
@@ -0,0 +1,232 @@
+/**
+ * SyncEngineLive execute tests.
+ *
+ * These exercise the real engine (not the test double) so that the
+ * currentIndex contract and the tag-failure cleanup are pinned against
+ * production code paths.
+ */
+
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "bun:test";
+import { Effect, Layer, Stream } from "effect";
+import { EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher";
+import { FileSystemLive } from "@/services/effects/FileSystem";
+import { LoggerLive } from "@/services/effects/Logger";
+import {
+ createMetadataEditorTest,
+ MetadataEditor,
+ MetadataError,
+ type PodcastMetadata,
+} from "@/services/effects/MetadataEditor";
+import { SyncEngine, SyncEngineLive } from "@/services/effects/SyncEngine";
+import type { Podcast } from "@/types/podcast";
+
+const makePodcast = (root: string, count: number): Podcast[] => [
+ {
+ id: "live-1",
+ title: "Live Show",
+ author: "Dan Carlin",
+ episodeCount: count,
+ episodes: Array.from({ length: count }, (_, i) => ({
+ id: `live-ep-${i + 1}`,
+ title: `Live Episode ${i + 1}`,
+ author: "Dan Carlin",
+ duration: 60,
+ published: new Date("2024-01-01"),
+ onDrive: false,
+ filePath: join(root, `ep${i + 1}.mp3`),
+ fileSize: 0,
+ })),
+ },
+];
+
+/** A MetadataEditor whose write always fails, to exercise the tag-failure path. */
+const failingMetadataEditor = () =>
+ Layer.succeed(MetadataEditor, {
+ write: (path) => Effect.fail(new MetadataError({ path, cause: new Error("tag boom") })),
+ });
+
+/** A MetadataEditor that records what it was asked to write. */
+const recordingMetadataEditor = (sink: PodcastMetadata[]) =>
+ Layer.succeed(MetadataEditor, {
+ write: (_path, metadata) =>
+ Effect.sync(() => {
+ sink.push(metadata);
+ }),
+ });
+
+describe("SyncEngineLive.execute", () => {
+ it("emits 1-based currentIndex", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "podapple-live-"));
+ try {
+ for (let i = 1; i <= 3; i++) {
+ await Bun.write(join(directory, `ep${i}.mp3`), new Uint8Array([1, 2, 3]));
+ }
+
+ const program = Effect.gen(function* () {
+ const engine = yield* SyncEngine;
+ const plan = yield* engine.createPlan(
+ makePodcast(directory, 3),
+ join(directory, "out"),
+ new Map(),
+ );
+ const seen: number[] = [];
+ const discarded: number[] = [];
+ yield* Stream.runForEach(engine.execute(plan, join(directory, "out")), (prog) =>
+ Effect.sync(() => {
+ seen.push(prog.currentIndex);
+ discarded.push(prog.discarded);
+ }),
+ );
+ return { seen, discarded };
+ });
+
+ const { seen, discarded } = await Effect.runPromise(
+ Effect.provide(
+ program,
+ Layer.mergeAll(
+ SyncEngineLive,
+ EpisodeMatcherLive,
+ FileSystemLive,
+ createMetadataEditorTest(),
+ ).pipe(Layer.provide(LoggerLive)),
+ ),
+ );
+
+ expect(seen.length).toBeGreaterThan(0);
+ // currentIndex is a 1-based position, emitted before the copy starts.
+ expect(seen.every((v) => v >= 1)).toBe(true);
+ // The first file in flight reports 1, not 0.
+ expect(seen[0]).toBe(1);
+ // Nothing was discarded: every file tagged cleanly. Compared against a
+ // same-length array of zeros so an empty run cannot pass vacuously.
+ expect(discarded).toEqual(seen.map(() => 0));
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ it("writes the podcast author as the ID3 artist", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "podapple-artist-"));
+ const destDir = join(directory, "out");
+ try {
+ await Bun.write(join(directory, "ep1.mp3"), new Uint8Array([1, 2, 3]));
+
+ const written: PodcastMetadata[] = [];
+ const program = Effect.gen(function* () {
+ const engine = yield* SyncEngine;
+ const plan = yield* engine.createPlan(makePodcast(directory, 1), destDir, new Map());
+ yield* Stream.runForEach(engine.execute(plan, destDir), () => Effect.void);
+ });
+
+ await Effect.runPromise(
+ Effect.provide(
+ program,
+ Layer.mergeAll(
+ SyncEngineLive,
+ EpisodeMatcherLive,
+ FileSystemLive,
+ recordingMetadataEditor(written),
+ ).pipe(Layer.provide(LoggerLive)),
+ ),
+ );
+
+ // The author is what lands in the ID3 artist frame; a blank here is the bug.
+ expect(written).toHaveLength(1);
+ expect(written[0]?.artist).toBe("Dan Carlin");
+ expect(written[0]?.artist?.trim()).not.toBe("");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ it("unlinks the copied file when tagging fails, and still completes", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "podapple-tagfail-"));
+ const destDir = join(directory, "out");
+ try {
+ await Bun.write(join(directory, "ep1.mp3"), new Uint8Array([1, 2, 3]));
+
+ const program = Effect.gen(function* () {
+ const engine = yield* SyncEngine;
+ const plan = yield* engine.createPlan(makePodcast(directory, 1), destDir, new Map());
+ const statuses: string[] = [];
+ const counts: number[] = [];
+ yield* Stream.runForEach(engine.execute(plan, destDir), (prog) =>
+ Effect.sync(() => {
+ statuses.push(prog.status);
+ counts.push(prog.discarded);
+ }),
+ );
+ return { statuses, counts, destPath: plan.toCopy[0]?.destPath };
+ });
+
+ const result = await Effect.runPromise(
+ Effect.provide(
+ program,
+ Layer.mergeAll(
+ SyncEngineLive,
+ EpisodeMatcherLive,
+ FileSystemLive,
+ failingMetadataEditor(),
+ ).pipe(Layer.provide(LoggerLive)),
+ ),
+ );
+
+ // A failed tag never fails the sync.
+ expect(result.statuses[result.statuses.length - 1]).toBe("complete");
+ // The half-tagged file must not linger on the drive.
+ expect(result.destPath).toBeDefined();
+ expect(await Bun.file(result.destPath as string).exists()).toBe(false);
+ // The discard is counted, so the success message can report it
+ // instead of claiming everything landed.
+ expect(result.counts[result.counts.length - 1]).toBe(1);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ it("counts one discard per file across a multi-file run", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "podapple-count-"));
+ const destDir = join(directory, "out");
+ try {
+ for (let i = 1; i <= 3; i++) {
+ await Bun.write(join(directory, `ep${i}.mp3`), new Uint8Array([1, 2, 3]));
+ }
+
+ const program = Effect.gen(function* () {
+ const engine = yield* SyncEngine;
+ const plan = yield* engine.createPlan(makePodcast(directory, 3), destDir, new Map());
+ const counts: number[] = [];
+ yield* Stream.runForEach(engine.execute(plan, destDir), (prog) =>
+ Effect.sync(() => {
+ counts.push(prog.discarded);
+ }),
+ );
+ return counts;
+ });
+
+ const counts = await Effect.runPromise(
+ Effect.provide(
+ program,
+ Layer.mergeAll(
+ SyncEngineLive,
+ EpisodeMatcherLive,
+ FileSystemLive,
+ failingMetadataEditor(),
+ ).pipe(Layer.provide(LoggerLive)),
+ ),
+ );
+
+ // Three files, three tag failures, three discards. This is what a
+ // once-per-run or double-counted implementation would get wrong.
+ expect(counts[counts.length - 1]).toBe(3);
+ // The count is monotonic, and never exceeds the number of files.
+ expect(counts.every((v, i) => (i === 0 ? v <= 1 : v >= counts[i - 1]!))).toBe(true);
+ expect(Math.max(...counts)).toBeLessThanOrEqual(3);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/__tests__/useAppLogic.test.ts b/src/__tests__/useAppLogic.test.ts
new file mode 100644
index 0000000..8e45ed6
--- /dev/null
+++ b/src/__tests__/useAppLogic.test.ts
@@ -0,0 +1,278 @@
+import { beforeEach, describe, expect, it } from "bun:test";
+import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect";
+import {
+ describeError,
+ makeAppLayerWith,
+ syncSuccessMessage,
+ useAppLogic,
+} from "@/hooks/useAppLogic";
+import { DriveScan, DriveScanError } from "@/services/effects/DriveScan";
+import { SyncEngine, SyncError } from "@/services/effects/SyncEngine";
+import { actions, state } from "@/store";
+import { selectDrive } from "@/utils/driveSelection";
+import type { Drive } from "@/types/drive";
+import type { PodcastEpisode } from "@/types/podcast";
+
+const drive: Drive = {
+ id: "d1",
+ name: "USB",
+ bsdName: "USB",
+ mountPoint: "/Volumes/USB",
+ totalSpace: 32_000_000_000,
+ freeSpace: 16_000_000_000,
+};
+
+const episode = (id: string): PodcastEpisode => ({
+ id,
+ title: `Episode ${id}`,
+ author: "Dan Carlin",
+ duration: 60,
+ published: new Date("2024-01-01"),
+ onDrive: false,
+ filePath: `/mac/${id}.mp3`,
+ fileSize: 1000,
+ selected: true,
+ showName: "Show",
+});
+
+describe("syncSuccessMessage", () => {
+ it("reports a clean sync plainly", () => {
+ expect(syncSuccessMessage(0)).toBe("Sync complete");
+ });
+
+ it("counts a single discarded file in the singular", () => {
+ // The singular/plural branch is the whole reason this is a function.
+ expect(syncSuccessMessage(1)).toBe("Sync complete (1 file discarded: tagging failed)");
+ });
+
+ it("pluralises multiple discarded files", () => {
+ expect(syncSuccessMessage(2)).toBe("Sync complete (2 files discarded: tagging failed)");
+ expect(syncSuccessMessage(17)).toBe("Sync complete (17 files discarded: tagging failed)");
+ });
+
+ it("never claims a clean sync while files were discarded", () => {
+ for (const n of [1, 2, 5, 100]) {
+ expect(syncSuccessMessage(n)).not.toBe("Sync complete");
+ expect(syncSuccessMessage(n)).toContain(String(n));
+ }
+ });
+});
+
+describe("useAppLogic.startSync", () => {
+ beforeEach(() => {
+ actions.resetState();
+ actions.setAppView("normal");
+ });
+
+ it("reports a missing drive and sets no success message", () => {
+ const logic = useAppLogic();
+ actions.setCurrentDrive(null);
+
+ logic.startSync([episode("1")]);
+
+ expect(state.errorMsg).toBe("No drive selected");
+ // A refused sync is not a success.
+ expect(state.successMsg).toBe("");
+ });
+
+ it("clears a stale success message when a new sync starts", () => {
+ const logic = useAppLogic();
+ actions.setCurrentDrive(null);
+
+ // Stand in for a previous run that finished cleanly.
+ actions.setSuccessMsg("Sync complete");
+ expect(state.successMsg).toBe("Sync complete");
+
+ logic.startSync([episode("1")]);
+
+ // The refused sync set an error, and the two are mutually exclusive, so
+ // the stale success must not survive alongside it.
+ expect(state.errorMsg).toBe("No drive selected");
+ expect(state.successMsg).toBe("");
+ });
+
+ it("clears a stale success message when a drive is selected", () => {
+ actions.setSuccessMsg("Sync complete");
+ expect(state.successMsg).toBe("Sync complete");
+
+ // selectDrive is the path a drive selection actually takes; it clears
+ // the status so a previous run's result does not follow the user to the
+ // new drive.
+ selectDrive(drive, () => {});
+
+ expect(state.currentDrive).toBe(drive);
+ expect(state.successMsg).toBe("");
+ });
+
+ it("does not clear the status when only rescanning the same drive", () => {
+ const logic = useAppLogic();
+ actions.setSuccessMsg("Sync complete");
+
+ // loadDrivePodcasts is a bare rescan, not a selection. It reports its own
+ // errors but is not a "new destination", so it leaves the status alone.
+ logic.loadDrivePodcasts(drive);
+
+ expect(state.successMsg).toBe("Sync complete");
+ });
+});
+
+describe("status channel exclusivity", () => {
+ beforeEach(() => {
+ actions.resetState();
+ });
+
+ it("never holds a success and an error at the same time", () => {
+ // The invariant the two setters enforce, exercised the way the app does it.
+ actions.setSuccessMsg("Sync complete");
+ actions.setErrorMsg("drive rescan failed");
+ expect(state.successMsg).toBe("");
+ expect(state.errorMsg).toBe("drive rescan failed");
+
+ actions.setSuccessMsg("Sync complete");
+ expect(state.errorMsg).toBe("");
+ expect(state.successMsg).toBe("Sync complete");
+ });
+});
+
+describe("describeError", () => {
+ it("prefers a plain Error's message", () => {
+ expect(describeError(new Error("disk on fire"))).toBe("disk on fire");
+ });
+
+ it("unwraps an Effect tagged error, whose message is empty", () => {
+ // A Data.TaggedError IS an Error with an empty `message`, so the naive
+ // `instanceof Error ? err.message : String(err)` yields "" and the user
+ // is shown nothing at all.
+ const err = new DriveScanError({ cause: new Error("rescan failed") });
+ expect(err instanceof Error).toBe(true);
+ expect(err.message).toBe("");
+ expect(describeError(err)).toBe("rescan failed");
+ });
+
+ it("unwraps an Effect Cause to the underlying error", () => {
+ // The sync-failure path. Cause.toString() renders the whole wrapper,
+ // "Cause([Fail(SyncError (cause: Error: no space left))])", where the
+ // user needs to read "no space left".
+ const cause = Cause.fail(new SyncError({ episode: "Ep 1", cause: new Error("no space left") }));
+ expect(cause.toString()).toContain("Cause(");
+ expect(describeError(cause)).toBe("no space left");
+
+ expect(
+ describeError(Cause.fail(new DriveScanError({ cause: new Error("rescan failed") }))),
+ ).toBe("rescan failed");
+ });
+
+ it("names an interruption rather than rendering a cause wrapper", async () => {
+ // A real interrupt, the way cancelSync produces one: fork, interrupt,
+ // then await to collect the Exit.
+ const forked = Effect.runFork(Effect.never);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ await Effect.runPromise(Fiber.interrupt(forked));
+ const exit = await Effect.runPromise(Fiber.await(forked));
+ expect(Exit.isFailure(exit)).toBe(true);
+ const cause = (exit as unknown as { cause: Cause.Cause }).cause;
+ expect(Cause.hasInterruptsOnly(cause)).toBe(true);
+ expect(describeError(cause)).toBe("Cancelled");
+ });
+
+ it("falls back to the tag when there is no message or cause", () => {
+ expect(describeError(new DriveScanError({ cause: undefined }))).toBe("DriveScanError");
+ });
+
+ it("does not throw on a hostile constructor name", () => {
+ // It runs inside Effect.catch handlers and the Errored boundary, so a
+ // throw here would replace the error with a defect, and in index.tsx
+ // crash the app.
+ expect(() => describeError({ constructor: { name: "(" } })).not.toThrow();
+ expect(describeError({ constructor: { name: "(" } })).toBe("Unknown error");
+ });
+
+ it("never returns something meaningless", () => {
+ // An empty status renders as nothing, and so does "[object Object]".
+ expect(describeError(new Error(" "))).toBe("Unknown error");
+ expect(describeError(new Error(""))).toBe("Unknown error");
+ expect(describeError(null)).toBe("Unknown error");
+ expect(describeError(undefined)).toBe("Unknown error");
+ expect(describeError({})).toBe("Unknown error");
+ });
+
+ it("still reports primitives, where String() is the whole story", () => {
+ expect(describeError("disk full")).toBe("disk full");
+ expect(describeError(42)).toBe("42");
+ expect(describeError(false)).toBe("false");
+ });
+});
+
+/**
+ * A DriveScan that fails the post-sync rescan on demand.
+ *
+ * This cannot be provoked against a real tempdir: the live scanDrive returns []
+ * for a missing Podcasts folder and swallows its own list errors, so it
+ * essentially never fails on a well-formed directory.
+ */
+const driveScanDouble = (failScan: boolean) =>
+ Layer.succeed(DriveScan, {
+ scanDrive: (_drivePath) =>
+ failScan
+ ? Effect.fail(new DriveScanError({ cause: new Error("rescan failed") }))
+ : Effect.succeed([]),
+ buildDriveIndex: () => Effect.succeed(new Map()),
+ hasPodcastsFolder: () => Effect.succeed(false),
+ });
+
+/** A SyncEngine with nothing to copy, so startSync succeeds immediately. */
+const emptyPlanEngine = Layer.succeed(SyncEngine, {
+ createPlan: () => Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 0, totalBytes: 0 }),
+ execute: () =>
+ Stream.succeed({
+ currentFile: "",
+ currentIndex: 0,
+ totalFiles: 0,
+ bytesTransferred: 0,
+ totalBytes: 0,
+ discarded: 0,
+ startTime: 0,
+ status: "complete",
+ }),
+ copyFileWithProgress: () => Effect.void,
+ cleanup: () => Effect.void,
+});
+
+/** The live layer with only DriveScan and SyncEngine replaced. */
+const testLayer = (failScan: boolean) =>
+ makeAppLayerWith({ scan: driveScanDouble(failScan), sync: emptyPlanEngine });
+
+/** Lets the fire-and-forget effects started by startSync run to completion. */
+const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
+
+describe("useAppLogic post-sync rescan gate", () => {
+ beforeEach(() => {
+ actions.resetState();
+ actions.setAppView("normal");
+ });
+
+ it("does not claim success when the rescan after a sync fails", async () => {
+ const logic = useAppLogic(testLayer(true));
+ actions.setCurrentDrive(drive);
+
+ logic.startSync([episode("1")]);
+ await settle();
+
+ // The rescan's error must survive, and must be visible: the underlying
+ // cause is what the user needs, since the drive listing is now empty.
+ expect(state.errorMsg).toBe("rescan failed");
+ // Reporting success here would clear that error and claim a clean run.
+ expect(state.successMsg).toBe("");
+ });
+
+ it("reports success when the rescan succeeds", async () => {
+ const logic = useAppLogic(testLayer(false));
+ actions.setCurrentDrive(drive);
+
+ logic.startSync([episode("1")]);
+ await settle();
+
+ expect(state.successMsg).toBe("All episodes already synced");
+ expect(state.errorMsg).toBe("");
+ });
+});
diff --git a/src/components/DriveItem.tsx b/src/components/DriveItem.tsx
deleted file mode 100644
index fbb49d3..0000000
--- a/src/components/DriveItem.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Colors } from "@/theme/colors";
-import type { Drive } from "@/types/drive";
-import { formatDriveInfo } from "@/utils/drive";
-
-export interface DriveItemProps {
- drive: Drive;
- selected: boolean;
-}
-
-export function DriveItem(props: DriveItemProps) {
- const backgroundColor = () => (props.selected ? Colors.list.focused : undefined);
- const textColor = () => (props.selected ? Colors.text.inverse : Colors.text.primary);
-
- return (
-
-
- {props.selected ? "> " : " "}
- {formatDriveInfo(props.drive)}
-
-
- );
-}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 32f1287..0af0c88 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -19,7 +19,7 @@ export function Header(props: HeaderProps) {
flexDirection='row'
alignItems='center'
style={{
- padding: isSmallHeight() ? 1 : 1,
+ padding: 1,
paddingTop: isSmallHeight() ? 0 : 1,
paddingBottom: isSmallHeight() ? 0 : 1,
}}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index c9fcbd1..9fc32c2 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,5 +1,5 @@
-import { useTerminalDimensions } from "@opentui/solid";
-import { createMemo, type JSX, Show } from "solid-js";
+import { useTerminalDimensions, type JSX } from "@opentui/solid";
+import { createMemo, Show } from "solid-js";
import { Colors } from "@/theme/colors";
export interface ModalProps {
diff --git a/src/components/Selector.tsx b/src/components/Selector.tsx
index a632df3..24c0897 100644
--- a/src/components/Selector.tsx
+++ b/src/components/Selector.tsx
@@ -54,7 +54,7 @@ export function Selector(props: SelectorProps) {
>
-
+
diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx
index 5dde96a..cb4bb8e 100644
--- a/src/components/Spinner.tsx
+++ b/src/components/Spinner.tsx
@@ -1,9 +1,8 @@
-import { createSignal, onCleanup, onMount } from "solid-js";
+import { createSignal, onSettled } from "solid-js";
import { Colors } from "@/theme/colors";
import { getSpinnerFrame, getSpinnerStyle, type SpinnerVariant } from "@/utils/spinners";
export interface SpinnerProps {
- active: boolean;
label?: string;
variant?: SpinnerVariant;
}
@@ -13,45 +12,16 @@ export function Spinner(props: SpinnerProps) {
const getInterval = () => getSpinnerStyle(props.variant).interval;
- onMount(() => {
- if (!props.active) return;
-
- const interval = setInterval(() => {
- setFrameIndex((i) => i + 1);
- }, getInterval());
-
- onCleanup(() => clearInterval(interval));
- });
-
- // Effect to handle active changes
- let intervalId: ReturnType | null = null;
-
- const startAnimation = () => {
- if (intervalId) return;
- intervalId = setInterval(() => {
+ onSettled(() => {
+ const intervalId = setInterval(() => {
setFrameIndex((i) => i + 1);
}, getInterval());
- };
-
- const stopAnimation = () => {
- if (intervalId) {
- clearInterval(intervalId);
- intervalId = null;
- }
- };
- onMount(() => {
- if (props.active) {
- startAnimation();
- }
+ return () => clearInterval(intervalId);
});
- onCleanup(() => stopAnimation());
-
const spinnerChar = () => getSpinnerFrame(frameIndex(), props.variant);
- if (!props.active) return null;
-
return (
{spinnerChar()}
diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx
index 0177477..ff3ab90 100644
--- a/src/components/ThemePicker.tsx
+++ b/src/components/ThemePicker.tsx
@@ -1,7 +1,6 @@
-import { createEffect } from "solid-js";
import type { useAppLogic } from "@/hooks/useAppLogic";
import { actions, state } from "@/store";
-import { setTheme, Themes } from "@/theme/colors";
+import { previewThemeAt, setTheme, Themes } from "@/theme/colors";
import { Selector } from "./Selector";
interface ThemePickerProps {
@@ -12,15 +11,10 @@ interface ThemePickerProps {
export function ThemePicker(props: ThemePickerProps) {
const themeNames = Themes;
- // Sync theme preview with selected index in store
- createEffect(() => {
- if (state.appView === "themeSelection") {
- const themeName = themeNames[state.themeMenuIndex];
- if (themeName) {
- setTheme(themeName);
- }
- }
- });
+ const handleIndexChange = (index: number) => {
+ actions.setThemeMenuIndex(index);
+ previewThemeAt(index);
+ };
const handleSelect = (themeName: string) => {
setTheme(themeName);
@@ -40,7 +34,7 @@ export function ThemePicker(props: ThemePickerProps) {
visible={true}
items={themeNames}
selectedIndex={state.themeMenuIndex}
- onIndexChange={(index) => actions.setThemeMenuIndex(index)}
+ onIndexChange={handleIndexChange}
formatItem={(name) => ({ name })}
onSelect={handleSelect}
onClose={handleClose}
diff --git a/src/components/TransferPopup.tsx b/src/components/TransferPopup.tsx
index 9fd72df..5eb14d5 100644
--- a/src/components/TransferPopup.tsx
+++ b/src/components/TransferPopup.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createSignal, Show } from "solid-js";
+import { Show } from "solid-js";
import { Spinner } from "@/components/Spinner";
import { Colors } from "@/theme/colors";
import { formatBytes, truncateString } from "@/utils/formatting";
@@ -10,6 +10,7 @@ import { Modal } from "./Modal";
export interface TransferPopupProps {
visible: boolean;
currentFile: string;
+ lastFile: string;
filesDone: number;
totalFiles: number;
bytesTransferred: number;
@@ -23,15 +24,7 @@ export interface TransferPopupProps {
* Shows progress bars, percentages, transfer speed, and counts.
*/
export function TransferPopup(props: TransferPopupProps) {
- const [lastFile, setLastFile] = createSignal("");
-
- createEffect(() => {
- if (props.currentFile && props.currentFile !== "Preparing...") {
- setLastFile(props.currentFile);
- }
- });
-
- const displayFile = () => props.currentFile || lastFile();
+ const displayFile = () => props.currentFile || props.lastFile;
const progress = () =>
calculateProgress(props.filesDone, props.totalFiles, props.bytesTransferred, props.totalBytes);
@@ -59,7 +52,7 @@ export function TransferPopup(props: TransferPopupProps) {
{progressBar()}
{percentage()}%
-
+
diff --git a/src/components/index.ts b/src/components/index.ts
deleted file mode 100644
index effe161..0000000
--- a/src/components/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { ConfirmPopup, type ConfirmPopupProps } from "./ConfirmPopup";
-export { type DebugMessage, DebugPopup, type DebugPopupProps } from "./DebugPopup";
-export { DriveItem, type DriveItemProps } from "./DriveItem";
-export { DriveSelector, type DriveSelectorProps } from "./DriveSelector";
-export { EpisodeItem, type EpisodeItemProps } from "./EpisodeItem";
-export { EpisodeList, type EpisodeListProps } from "./EpisodeList";
-export { Footer, type FooterProps, type Shortcut } from "./Footer";
-export { Header, type HeaderProps } from "./Header";
-export { Modal, type ModalProps } from "./Modal";
-export { Selector, type SelectorProps } from "./Selector";
-export { Spinner, type SpinnerProps } from "./Spinner";
-export { TransferPopup, type TransferPopupProps } from "./TransferPopup";
diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts
index 4051ef9..1aab829 100644
--- a/src/hooks/useAppKeyboard.ts
+++ b/src/hooks/useAppKeyboard.ts
@@ -1,7 +1,8 @@
import { useKeyboard, useRenderer } from "@opentui/solid";
import { actions, state } from "@/store";
-import { setTheme, Themes } from "@/theme/colors";
+import { previewThemeAt, setTheme, Themes } from "@/theme/colors";
import { copyToClipboard } from "@/utils/clipboard";
+import { selectDrive } from "@/utils/driveSelection";
import { quitApp } from "@/utils/terminal";
import type { useAppLogic } from "./useAppLogic";
@@ -41,9 +42,13 @@ export const useAppKeyboard = (logic: ReturnType) => {
setTheme(state.lastSavedTheme);
actions.setAppView("normal");
} else if (key === "up" || key === "k") {
- actions.setThemeMenuIndex((i) => Math.max(0, i - 1));
+ const index = Math.max(0, state.themeMenuIndex - 1);
+ actions.setThemeMenuIndex(index);
+ previewThemeAt(index);
} else if (key === "down" || key === "j") {
- actions.setThemeMenuIndex((i) => Math.min(Themes.length - 1, i + 1));
+ const index = Math.min(Themes.length - 1, state.themeMenuIndex + 1);
+ actions.setThemeMenuIndex(index);
+ previewThemeAt(index);
} else if (key === "return" || key === "enter") {
const themeName = Themes[state.themeMenuIndex];
if (themeName) {
@@ -63,7 +68,10 @@ export const useAppKeyboard = (logic: ReturnType) => {
const handleNormalActions = (key: string, ctrl: boolean): void => {
if (ctrl && key === "t") {
const currentIndex = (Themes as string[]).indexOf(state.lastSavedTheme);
- actions.setThemeMenuIndex(currentIndex >= 0 ? currentIndex : 0);
+ const index = currentIndex >= 0 ? currentIndex : 0;
+ actions.setThemeMenuIndex(index);
+ if (currentIndex >= 0) previewThemeAt(index);
+ else setTheme(state.lastSavedTheme);
actions.setAppView("themeSelection");
return;
}
@@ -189,9 +197,7 @@ export const useAppKeyboard = (logic: ReturnType) => {
} else if (key === "return" || key === "enter") {
const drive = state.drives[state.driveMenuIndex];
if (drive) {
- actions.setCurrentDrive(drive);
- actions.setAppView("normal");
- logic.loadDrivePodcasts(drive);
+ selectDrive(drive, logic.loadDrivePodcasts);
}
} else if (ctrl && (key === "f" || key === "F")) {
const drive = state.drives[state.driveMenuIndex];
diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts
index ebcefce..2cbf0c9 100644
--- a/src/hooks/useAppLogic.ts
+++ b/src/hooks/useAppLogic.ts
@@ -1,6 +1,6 @@
import { dirname } from "node:path";
-import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect";
-import { onMount, onCleanup } from "solid-js";
+import { Cause, Effect, Exit, Fiber, Layer, Option, Stream } from "effect";
+import { onSettled } from "solid-js";
import {
DriveDetection,
DriveDetectionLive,
@@ -76,29 +76,140 @@ type AppRequirements =
| MetadataEditor
| Logger;
+/**
+ * Assembles the service graph from its leaves.
+ *
+ * Each service is a parameter so tests can substitute one. The alternatives are
+ * a drive, a database or an unplugged cable, none of which belong in a unit
+ * test. Passed the live layers, the result is the app's production wiring.
+ */
+const buildLayer = (services: {
+ scan: Layer.Layer;
+ sync: Layer.Layer;
+ scanDeps: Layer.Layer;
+ syncDeps: Layer.Layer;
+}): Layer.Layer =>
+ Layer.mergeAll(
+ Detection.pipe(Layer.provide(services.scanDeps)),
+ Podcast.pipe(Layer.provide(services.scanDeps)),
+ services.scanDeps,
+ services.scan,
+ services.syncDeps,
+ services.sync,
+ Settings.pipe(Layer.provide(services.scanDeps)),
+ ) as Layer.Layer;
+
+/**
+ * The live service layer. Exposed so tests can supply their own.
+ */
+export const makeAppLayer = (): Layer.Layer =>
+ AppLayer as unknown as Layer.Layer;
+
+/**
+ * The production wiring with a substitute for the services a test needs to
+ * control. Only DriveScan and SyncEngine are substitutable: the others are
+ * leaves, and a double for one would need its own dependencies wired in.
+ */
+export const makeAppLayerWith = (overrides: {
+ scan?: Layer.Layer;
+ sync?: Layer.Layer;
+}): Layer.Layer => {
+ // The substitution has to win over the live layer it shadows, and in a merge
+ // the later layer does. Scan and Sync arrive already provided Base, so the
+ // override is merged at the leaves rather than over the whole graph.
+ const scanLayer = overrides.scan
+ ? (Layer.mergeAll(Scan, overrides.scan) as Layer.Layer)
+ : Scan;
+ const syncLayer = overrides.sync
+ ? (Layer.mergeAll(Sync, overrides.sync) as Layer.Layer)
+ : Sync;
+ return buildLayer({
+ scan: scanLayer,
+ sync: syncLayer,
+ scanDeps: Base,
+ syncDeps: Layer.mergeAll(Base, scanLayer),
+ }) as unknown as Layer.Layer;
+};
+
+const nonEmpty = (value: unknown): value is string =>
+ typeof value === "string" && value.trim() !== "";
+
+/**
+ * Renders an error for the status line.
+ *
+ * Deliberately not `err instanceof Error ? err.message : String(err)`: Effect's
+ * tagged errors (Data.TaggedError) are Errors with an empty `message`, so that
+ * check silently produces an empty status and the user sees nothing at all.
+ *
+ * Prefers a real message, then the cause, then the tag. Anything that would
+ * stringify to something meaningless (an empty string, a bare class name,
+ * "[object Object]", "null") becomes "Unknown error", because a status line
+ * showing that is worse than one admitting it does not know.
+ */
+export const describeError = (err: unknown): string => {
+ // An Effect Cause wraps the real failure. Unwrap it, or the user reads
+ // "Cause([Fail(SyncError (cause: Error: disk full))])" instead of "disk full".
+ if (Cause.isCause(err)) {
+ return Option.match(Cause.findErrorOption(err), {
+ onNone: () => (Cause.hasInterruptsOnly(err) ? "Cancelled" : describeError(Cause.pretty(err))),
+ onSome: (e) => describeError(e),
+ });
+ }
+
+ if (err instanceof Error && nonEmpty(err.message)) return err.message;
+
+ if (typeof err === "object" && err !== null) {
+ const { cause } = err as { cause?: unknown };
+ if (nonEmpty(cause)) return cause;
+ if (cause instanceof Error && nonEmpty(cause.message)) return cause.message;
+ const { _tag } = err as { _tag?: unknown };
+ if (nonEmpty(_tag)) return _tag;
+ }
+
+ // Nothing usable on the value itself. String() is only worth it for
+ // primitives, where it is the whole story.
+ if (typeof err === "string") return err.trim() === "" ? "Unknown error" : err;
+ if (typeof err === "number" || typeof err === "boolean") return String(err);
+
+ return "Unknown error";
+};
+
+/**
+ * Builds the success message for a completed sync.
+ *
+ * Files are discarded when tagging fails, so the sync still succeeds but the
+ * drive may be missing files. That has to be visible in the message, or the
+ * user is told everything landed when it did not.
+ */
+export const syncSuccessMessage = (discarded: number): string =>
+ discarded > 0
+ ? `Sync complete (${discarded} ${discarded === 1 ? "file" : "files"} discarded: tagging failed)`
+ : "Sync complete";
+
/**
* Hook containing the core application logic, orchestrating various Effect services.
+ *
+ * @param layer Service layer to run against. Defaults to the live services;
+ * tests pass fakes to drive the real control flow.
*/
-export const useAppLogic = () => {
- let driveListenerFiber: Fiber.RuntimeFiber | undefined;
- let activeSyncFiber: Fiber.RuntimeFiber | undefined;
+export const useAppLogic = (layer: Layer.Layer = makeAppLayer()) => {
+ let driveListenerFiber: Fiber.Fiber | undefined;
+ let activeSyncFiber: Fiber.Fiber | undefined;
/**
* Runs an effect to completion using the AppLayer.
*/
const run = (effect: Effect.Effect) =>
- Effect.runPromise(Effect.provide(effect, AppLayer as unknown as Layer.Layer)).catch(
- (err) => {
- console.error("Unhandled Effect Promise rejection:", err);
- actions.addDebugMessage(`Unhandled error: ${String(err)}`, "error");
- },
- );
+ Effect.runPromise(Effect.provide(effect, layer as unknown as Layer.Layer)).catch((err) => {
+ console.error("Unhandled Effect Promise rejection:", err);
+ actions.addDebugMessage(`Unhandled error: ${String(err)}`, "error");
+ });
/**
* Runs an effect as a fiber using the AppLayer.
*/
const runFork = (effect: Effect.Effect) =>
- Effect.runFork(Effect.provide(effect, AppLayer as unknown as Layer.Layer));
+ Effect.runFork(Effect.provide(effect, layer as unknown as Layer.Layer));
/**
* Helper for view transitions that ensures a view is set during effect execution
@@ -116,7 +227,9 @@ export const useAppLogic = () => {
/**
* Loads podcasts from a drive using the DriveScan service.
*/
- const loadDrivePodcastsEffect = (drive: Drive) =>
+ const loadDrivePodcastsEffect = (
+ drive: Drive,
+ ): Effect.Effect =>
Effect.gen(function* () {
const logger = yield* Logger;
actions.setLoadingDrive(true);
@@ -130,13 +243,17 @@ export const useAppLogic = () => {
// Mark Mac podcasts that are on the drive
const updated = markEpisodesOnDrive(state.macPodcasts, episodes);
actions.setMacPodcasts(updated);
+ return true;
}).pipe(
- Effect.catchAll((err) => {
- const errorMessage = err instanceof Error ? err.message : String(err);
+ // Yields false when the rescan failed, so a caller that is about to report
+ // success does not overwrite the error set here. The two status fields are
+ // mutually exclusive, so a later setSuccessMsg would clear it.
+ Effect.catch((err) => {
+ const errorMessage = describeError(err);
actions.setErrorMsg(errorMessage);
actions.addDebugMessage(errorMessage, "error");
actions.setDrivePodcasts([]);
- return Effect.void;
+ return Effect.succeed(false);
}),
Effect.onExit(() => Effect.sync(() => actions.setLoadingDrive(false))),
);
@@ -180,8 +297,8 @@ export const useAppLogic = () => {
}
}
}).pipe(
- Effect.catchAll((err) => {
- const errorMessage = err instanceof Error ? err.message : String(err);
+ Effect.catch((err) => {
+ const errorMessage = describeError(err);
actions.setErrorMsg(errorMessage);
actions.addDebugMessage(errorMessage, "error");
actions.setDrives([]);
@@ -240,8 +357,8 @@ export const useAppLogic = () => {
}),
);
}).pipe(
- Effect.catchAll((err) => {
- const errorMessage = err instanceof Error ? err.message : String(err);
+ Effect.catch((err) => {
+ const errorMessage = describeError(err);
actions.addDebugMessage(`Drive listener error: ${errorMessage}`, "error");
return Effect.void;
}),
@@ -263,7 +380,7 @@ export const useAppLogic = () => {
}
return settings;
}).pipe(
- Effect.catchAll(() => {
+ Effect.catch(() => {
actions.addDebugMessage("Failed to load settings", "error");
return Effect.succeed({ theme: "Catppuccin", favoriteDrives: [] });
}),
@@ -283,7 +400,7 @@ export const useAppLogic = () => {
});
yield* logger.info(`Toggled favorite: ${driveId}`);
}).pipe(
- Effect.catchAll(() => {
+ Effect.catch(() => {
actions.addDebugMessage("Failed to save favorites", "error");
return Effect.void;
}),
@@ -303,7 +420,7 @@ export const useAppLogic = () => {
actions.setLastSavedTheme(themeName);
yield* logger.info(`Saved theme: ${themeName}`);
}).pipe(
- Effect.catchAll(() => {
+ Effect.catch(() => {
actions.addDebugMessage("Failed to save theme", "error");
return Effect.void;
}),
@@ -329,8 +446,8 @@ export const useAppLogic = () => {
actions.setMacPodcasts(episodes);
}
}).pipe(
- Effect.catchAll((err) => {
- const errorMessage = err instanceof Error ? err.message : String(err);
+ Effect.catch((err) => {
+ const errorMessage = describeError(err);
actions.setErrorMsg(errorMessage);
actions.addDebugMessage(errorMessage, "error");
return Effect.void;
@@ -354,8 +471,8 @@ export const useAppLogic = () => {
const episodes = yield* podcastService.loadMacPodcasts;
actions.setMacPodcasts(episodes);
}).pipe(
- Effect.catchAll((err) => {
- actions.setErrorMsg(err instanceof Error ? err.message : String(err));
+ Effect.catch((err) => {
+ actions.setErrorMsg(describeError(err));
return Effect.void;
}),
Effect.onExit(() => Effect.sync(() => actions.setLoadingMac(false))),
@@ -372,6 +489,8 @@ export const useAppLogic = () => {
* Starts the sync operation for selected episodes.
*/
const startSync = (episodesToSync: PodcastEpisode[]) => {
+ // A new sync supersedes any previous outcome message.
+ actions.setSuccessMsg("");
const drive = state.currentDrive;
if (!drive) {
actions.setErrorMsg("No drive selected");
@@ -420,8 +539,15 @@ export const useAppLogic = () => {
const stream = syncEngine.execute(plan, drive.mountPoint);
+ // Files copied but discarded because tagging failed. The sync still
+ // succeeds, so the count rides along in the success message rather
+ // than failing the run.
+ let discarded = 0;
+
yield* Stream.runForEach(stream, (progress) =>
Effect.sync(() => {
+ discarded = progress.discarded;
+
actions.updateTransferProgress({
currentFile: progress.currentFile,
filesDone: progress.status === "complete" ? progress.totalFiles : progress.currentIndex,
@@ -443,13 +569,13 @@ export const useAppLogic = () => {
actions.updateTransferProgress({ currentFile: "Finalizing drive..." });
yield* syncEngine.cleanup(drive.mountPoint);
- return { success: true, message: "Sync complete" };
+ return { success: true, message: syncSuccessMessage(discarded) };
});
run(
Effect.gen(function* () {
const logger = yield* Logger;
- const fiber = yield* Effect.fork(syncProgram);
+ const fiber = yield* Effect.forkChild(syncProgram);
activeSyncFiber = fiber;
const exit = yield* Fiber.await(fiber);
@@ -457,20 +583,27 @@ export const useAppLogic = () => {
if (Exit.isSuccess(exit)) {
if (exit.value.success) {
- yield* loadDrivePodcastsEffect(drive);
+ const rescanOk = yield* loadDrivePodcastsEffect(drive);
actions.setMacPodcasts((prev) => prev.map((ep) => ({ ...ep, selected: false })));
+ // The rescan sets its own error if it failed; setting success here
+ // would clear it and claim a clean outcome that did not happen.
+ if (rescanOk) {
+ actions.setSuccessMsg(exit.value.message);
+ }
} else {
actions.setErrorMsg(exit.value.message);
}
} else {
const cause = exit.cause;
- if (Cause.isInterruptedOnly(cause)) {
+ if (Cause.hasInterruptsOnly(cause)) {
yield* logger.info("Sync cancelled by user");
actions.setErrorMsg("");
} else {
- const err = cause.toString();
yield* logger.error("Sync failed", cause);
- actions.setErrorMsg(String(err));
+ // Not cause.toString(): that renders the whole Effect cause
+ // wrapper, e.g. "Cause([Fail(SyncError (cause: Error: disk full))])",
+ // where the user needs to read "disk full".
+ actions.setErrorMsg(describeError(cause));
}
}
}).pipe(withView("syncing")),
@@ -495,7 +628,7 @@ export const useAppLogic = () => {
// Delete the episode files
yield* Effect.forEach(
selected,
- (ep) => fs.remove(ep.filePath).pipe(Effect.catchAll(() => Effect.void)),
+ (ep) => fs.remove(ep.filePath).pipe(Effect.catch(() => Effect.void)),
{ discard: true },
);
@@ -506,10 +639,10 @@ export const useAppLogic = () => {
Effect.gen(function* () {
const exists = yield* fs.exists(dir);
if (exists) {
- yield* fs.cleanupSystemHiddenFiles(dir).pipe(Effect.catchAll(() => Effect.void));
+ yield* fs.cleanupSystemHiddenFiles(dir).pipe(Effect.catch(() => Effect.void));
const empty = yield* fs.isDirEmpty(dir);
if (empty) {
- yield* fs.remove(dir).pipe(Effect.catchAll(() => Effect.void));
+ yield* fs.remove(dir).pipe(Effect.catch(() => Effect.void));
}
}
}),
@@ -534,17 +667,16 @@ export const useAppLogic = () => {
}
};
- onMount(() => {
+ onSettled(() => {
initialize();
- });
-
- onCleanup(() => {
- if (driveListenerFiber) {
- Effect.runFork(Fiber.interrupt(driveListenerFiber));
- }
- if (activeSyncFiber) {
- Effect.runFork(Fiber.interrupt(activeSyncFiber));
- }
+ return () => {
+ if (driveListenerFiber) {
+ Effect.runFork(Fiber.interrupt(driveListenerFiber));
+ }
+ if (activeSyncFiber) {
+ Effect.runFork(Fiber.interrupt(activeSyncFiber));
+ }
+ };
});
return {
diff --git a/src/index.tsx b/src/index.tsx
index f09b999..32824aa 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,5 +1,5 @@
import { render, useTerminalDimensions } from "@opentui/solid";
-import { createMemo, ErrorBoundary, Show } from "solid-js";
+import { createMemo, Errored, Show } from "solid-js";
import { ConfirmPopup } from "@/components/ConfirmPopup";
import { DebugPopup } from "@/components/DebugPopup";
import { DriveSelector } from "@/components/DriveSelector";
@@ -8,9 +8,10 @@ import { Header } from "@/components/Header";
import { ThemePicker } from "@/components/ThemePicker";
import { TransferPopup } from "@/components/TransferPopup";
import { useAppKeyboard } from "@/hooks/useAppKeyboard";
-import { useAppLogic } from "@/hooks/useAppLogic";
+import { describeError, useAppLogic } from "@/hooks/useAppLogic";
import { actions, state } from "@/store";
import { Colors } from "@/theme/colors";
+import { selectDrive } from "@/utils/driveSelection";
import { getFooterShortcuts } from "@/utils/keyboard";
import { DriveView } from "@/views/DriveView";
import { PodcastView } from "@/views/PodcastView";
@@ -114,6 +115,12 @@ const App = () => {
+
+
+ {state.successMsg}
+
+
+
@@ -128,9 +135,7 @@ const App = () => {
visible={true}
isScanning={state.isScanning}
onSelect={(drive) => {
- actions.setCurrentDrive(drive);
- logic.loadDrivePodcasts(drive);
- actions.setAppView("normal");
+ selectDrive(drive, logic.loadDrivePodcasts);
}}
onClose={() => actions.setAppView("normal")}
onShortcutClick={handleShortcutClick}
@@ -141,6 +146,7 @@ const App = () => {
{
const Root = () => {
const terminalDimensions = useTerminalDimensions();
return (
- (
-
- Fatal Error Occurred:
-
- {err.message}
-
-
- Press Ctrl+C to exit.
+ {
+ const error = err();
+ return (
+
+ Fatal Error Occurred:
+
+ {describeError(error)}
+
+
+ Press Ctrl+C to exit.
+
-
- )}
+ );
+ }}
>
-
+
);
};
diff --git a/src/services/effects/DriveDetection.ts b/src/services/effects/DriveDetection.ts
index 66bf987..888235b 100644
--- a/src/services/effects/DriveDetection.ts
+++ b/src/services/effects/DriveDetection.ts
@@ -5,7 +5,7 @@
* Uses Bun.spawn for native process execution and parsing of 'diskutil activity'.
*/
-import { Context, Data, Effect, Either, Layer, Option, Ref, Stream } from "effect";
+import { Context, Data, Effect, Layer, Option, Ref, Result, Stream } from "effect";
import type { Drive } from "@/types/drive";
import {
getPlistBoolean,
@@ -27,7 +27,7 @@ export type DriveEvent =
/**
* DriveDetection Service Tag
*/
-export class DriveDetection extends Context.Tag("DriveDetection")<
+export class DriveDetection extends Context.Service<
DriveDetection,
{
/** Scans for available external/removable volumes */
@@ -39,7 +39,7 @@ export class DriveDetection extends Context.Tag("DriveDetection")<
/** A stream of drive appearance and disappearance events */
readonly driveEvents: Stream.Stream;
}
->() {}
+>()("DriveDetection") {}
const EXCLUDED_NAMES = new Set([
"Macintosh HD",
@@ -105,11 +105,11 @@ const runDiskutil = (args: string[]) =>
throw new Error(`diskutil exited with code ${exitCode}`);
}
- const parsedEither = parsePlist(output);
- if (Either.isLeft(parsedEither)) {
- throw parsedEither.left;
+ const parsedResult = parsePlist(output);
+ if (Result.isFailure(parsedResult)) {
+ throw parsedResult.failure;
}
- const parsed = parsedEither.right;
+ const parsed = parsedResult.success;
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed as PlistDict;
}
@@ -118,9 +118,9 @@ const runDiskutil = (args: string[]) =>
catch: (cause) => new DriveDetectionError({ cause }),
});
-const getDriveDetails = (identifier: string, logger: Context.Tag.Service) =>
- Effect.gen(function* (_) {
- const info = yield* _(runDiskutil(["info", identifier]));
+const getDriveDetails = (identifier: string, logger: Logger["Service"]) =>
+ Effect.gen(function* () {
+ const info = yield* runDiskutil(["info", identifier]);
const exclusionReason = getExclusionReason(info);
if (exclusionReason) {
@@ -166,7 +166,7 @@ const getDriveDetails = (identifier: string, logger: Context.Tag.Service
totalSpace,
freeSpace: freeSpace ?? -1,
} as Drive);
- }).pipe(Effect.catchAll(() => Effect.succeed(Option.none())));
+ }).pipe(Effect.catch(() => Effect.succeed(Option.none())));
/**
* Parses a line from `diskutil activity` output.
@@ -183,7 +183,11 @@ export function parseActivityLine(
const action = match[1];
const bsdName = match[2];
- if (action === "DiskAppeared" || action === "VolumeMount" || action === "DiskDescriptionChanged") {
+ if (
+ action === "DiskAppeared" ||
+ action === "VolumeMount" ||
+ action === "DiskDescriptionChanged"
+ ) {
return Option.some({ type: "Appeared", bsdName });
}
if (action === "DiskDisappeared" || action === "VolumeUnmount") {
@@ -219,12 +223,8 @@ export const DriveDetectionLive = Layer.effect(
catch: (cause) => new DriveDetectionError({ cause }),
});
- const drives = yield* Effect.forEach(
- volumeNames,
- (name) => getDriveDetails(`/Volumes/${name}`, logger),
- {
- concurrency: "inherit",
- },
+ const drives = yield* Effect.forEach(volumeNames, (name) =>
+ getDriveDetails(`/Volumes/${name}`, logger),
);
return drives.filter(Option.isSome).map((opt) => opt.value);
@@ -243,90 +243,98 @@ export const DriveDetectionLive = Layer.effect(
const lastCrashRef = yield* Ref.make(0);
const createProcessStream = () =>
- Stream.acquireRelease(
- Effect.gen(function* () {
- // Spawn first so if it fails, scanningRef is never set
- const process = Bun.spawn(["diskutil", "activity"], {
- stdout: "pipe",
- stderr: "ignore",
- });
- yield* Ref.set(scanningRef, true);
- return process;
- }),
- (process) =>
- Effect.gen(function* () {
- process.kill();
- yield* Ref.set(scanningRef, false);
- }),
- ).pipe(
- Stream.flatMap((process) =>
- Stream.fromAsyncIterable(
- (async function* () {
- const reader = process.stdout.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) {
- throw new Error("diskutil activity process exited unexpectedly");
- }
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() ?? "";
- for (const line of lines) {
- if (line.trim()) yield line;
+ Stream.scoped(
+ Stream.fromEffect(
+ Effect.acquireRelease(
+ Effect.gen(function* () {
+ // Spawn first so if it fails, scanningRef is never set
+ const process = Bun.spawn(["diskutil", "activity"], {
+ stdout: "pipe",
+ stderr: "ignore",
+ });
+ yield* Ref.set(scanningRef, true);
+ return process;
+ }),
+ (process) =>
+ Effect.gen(function* () {
+ process.kill();
+ yield* Ref.set(scanningRef, false);
+ }),
+ ),
+ ).pipe(
+ Stream.flatMap((process) =>
+ Stream.fromAsyncIterable(
+ (async function* () {
+ const reader = process.stdout.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ throw new Error("diskutil activity process exited unexpectedly");
+ }
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+ for (const line of lines) {
+ if (line.trim()) yield line;
+ }
}
+ } finally {
+ reader.releaseLock();
}
- } finally {
- reader.releaseLock();
- }
- })(),
- (e) => new DriveDetectionError({ cause: e }),
+ })(),
+ (e) => new DriveDetectionError({ cause: e }),
+ ),
),
- ),
- Stream.map(parseActivityLine),
- Stream.filterMap((o) => o),
- Stream.tap((event) => logger.debug(`diskutil event: ${event.type} ${event.bsdName}`)),
- Stream.mapEffect((event) =>
- Effect.gen(function* () {
- const map = yield* Ref.get(driveIdMap);
- const now = Date.now();
-
- // Debounce events for the same drive to prevent rapid successive reads
- const lastTime = (yield* Ref.get(lastEventTimeMap)).get(event.bsdName) ?? 0;
- if (now - lastTime < 1000) {
- return Option.none();
- }
- yield* Ref.update(lastEventTimeMap, (m) => new Map(m).set(event.bsdName, now));
-
- if (event.type === "Appeared") {
- const details = yield* getDriveDetails(event.bsdName, logger);
- if (Option.isSome(details)) {
- const drive = details.value;
- yield* Ref.update(driveIdMap, (m) => new Map(m).set(event.bsdName, drive.id));
- yield* logger.info(`Drive appeared/changed: ${drive.name} (${drive.id})`);
- return Option.some({ _tag: "Appeared", drive } as DriveEvent);
+ Stream.map(parseActivityLine),
+ Stream.filterMap((o) =>
+ Option.isSome(o) ? Result.succeed(o.value) : Result.fail(undefined),
+ ),
+ Stream.tap((event) => logger.debug(`diskutil event: ${event.type} ${event.bsdName}`)),
+ Stream.mapEffect((event) =>
+ Effect.gen(function* () {
+ const map = yield* Ref.get(driveIdMap);
+ const now = Date.now();
+
+ // Debounce events for the same drive to prevent rapid successive reads
+ const lastTime = (yield* Ref.get(lastEventTimeMap)).get(event.bsdName) ?? 0;
+ if (now - lastTime < 1000) {
+ return Option.none();
+ }
+ yield* Ref.update(lastEventTimeMap, (m) => new Map(m).set(event.bsdName, now));
+
+ if (event.type === "Appeared") {
+ const details = yield* getDriveDetails(event.bsdName, logger);
+ if (Option.isSome(details)) {
+ const drive = details.value;
+ yield* Ref.update(driveIdMap, (m) => new Map(m).set(event.bsdName, drive.id));
+ yield* logger.info(`Drive appeared/changed: ${drive.name} (${drive.id})`);
+ return Option.some({ _tag: "Appeared", drive } as DriveEvent);
+ }
+ return Option.none();
+ }
+
+ // Disappeared
+ const driveId = map.get(event.bsdName);
+ if (driveId) {
+ yield* Ref.update(driveIdMap, (m) => {
+ const newMap = new Map(m);
+ newMap.delete(event.bsdName);
+ return newMap;
+ });
+ yield* logger.info(`Drive disappeared: ${driveId}`);
+ return Option.some({ _tag: "Disappeared", driveId } as DriveEvent);
}
return Option.none();
- }
-
- // Disappeared
- const driveId = map.get(event.bsdName);
- if (driveId) {
- yield* Ref.update(driveIdMap, (m) => {
- const newMap = new Map(m);
- newMap.delete(event.bsdName);
- return newMap;
- });
- yield* logger.info(`Drive disappeared: ${driveId}`);
- return Option.some({ _tag: "Disappeared", driveId } as DriveEvent);
- }
- return Option.none();
- }),
+ }),
+ ),
+ Stream.filterMap((o) =>
+ Option.isSome(o) ? Result.succeed(o.value) : Result.fail(undefined),
+ ),
),
- Stream.filterMap((o) => o),
);
// Retry with exponential backoff up to 5 times (resets if stable for 60s)
@@ -335,7 +343,7 @@ export const DriveDetectionLive = Layer.effect(
attempts: number,
): Stream.Stream =>
stream.pipe(
- Stream.catchAll((err) => {
+ Stream.catch((err) => {
const now = Date.now();
return Stream.fromEffect(
Effect.gen(function* () {
@@ -355,7 +363,7 @@ export const DriveDetectionLive = Layer.effect(
const oldMap = yield* Ref.get(driveIdMap);
// Re-scan to get fresh state
const freshDrives = yield* scanDrives().pipe(
- Effect.catchAll(() => Effect.succeed([] as Drive[])),
+ Effect.catch(() => Effect.succeed([] as Drive[])),
);
const newMap = new Map(freshDrives.map((d) => [d.bsdName, d.id]));
yield* Ref.set(driveIdMap, newMap);
diff --git a/src/services/effects/DriveScan.ts b/src/services/effects/DriveScan.ts
index 0d7119e..7d579d1 100644
--- a/src/services/effects/DriveScan.ts
+++ b/src/services/effects/DriveScan.ts
@@ -4,6 +4,7 @@ import { EpisodeMatcher } from "@/services/effects/EpisodeMatcher";
import { FileSystem } from "@/services/effects/FileSystem";
import { Logger } from "@/services/effects/Logger";
import type { Podcast } from "@/types/podcast";
+import { UNKNOWN_AUTHOR } from "@/utils/formatting";
export class DriveScanError extends Data.TaggedError("DriveScanError")<{
readonly cause: unknown;
@@ -12,7 +13,7 @@ export class DriveScanError extends Data.TaggedError("DriveScanError")<{
/**
* DriveScan Service Tag
*/
-export class DriveScan extends Context.Tag("DriveScan")<
+export class DriveScan extends Context.Service<
DriveScan,
{
/** Scans a drive for existing podcast files in the 'Podcasts' folder */
@@ -32,7 +33,7 @@ export class DriveScan extends Context.Tag("DriveScan")<
drivePath: string,
) => Effect.Effect;
}
->() {}
+>()("DriveScan") {}
/**
* Helper to parse podcast file info from path.
@@ -80,7 +81,7 @@ const getFilesRecursive = (
const fs = yield* FileSystem;
const logger = yield* Logger;
const entries = yield* fs.list(dir).pipe(
- Effect.catchAll((err) =>
+ Effect.catch((err) =>
Effect.gen(function* () {
yield* logger.error(`Failed to list directory ${dir}`, err);
return [] as string[];
@@ -96,7 +97,7 @@ const getFilesRecursive = (
const relPath = baseRel ? join(baseRel, entry) : entry;
const isDir = yield* fs
.isDirectory(fullPath)
- .pipe(Effect.catchAll(() => Effect.succeed(false)));
+ .pipe(Effect.catch(() => Effect.succeed(false)));
if (isDir) {
return yield* getFilesRecursive(fullPath, relPath);
@@ -174,6 +175,8 @@ export const DriveScanLive = Layer.succeed(
episode: {
id: `${showName}-${title}`,
title: title.replace(/_/g, " "),
+ // Drive-derived episodes have no ZAUTHOR to read.
+ author: UNKNOWN_AUTHOR,
duration: 0,
published,
onDrive: true,
@@ -190,7 +193,7 @@ export const DriveScanLive = Layer.succeed(
podcastsMap.set(showName, {
id: showName,
title: showName.replace(/_/g, " "),
- author: "Unknown",
+ author: UNKNOWN_AUTHOR,
episodeCount: 0,
episodes: [],
});
@@ -206,7 +209,7 @@ export const DriveScanLive = Layer.succeed(
);
return Array.from(podcastsMap.values());
- }).pipe(Effect.catchAll((err) => Effect.fail(new DriveScanError({ cause: err })))),
+ }).pipe(Effect.catch((err) => Effect.fail(new DriveScanError({ cause: err })))),
buildDriveIndex: (drivePath) =>
Effect.gen(function* () {
@@ -269,7 +272,7 @@ export const DriveScanLive = Layer.succeed(
yield* logger.info(`Drive index built with ${index.size} episodes`);
return index;
- }).pipe(Effect.catchAll((err) => Effect.fail(new DriveScanError({ cause: err })))),
+ }).pipe(Effect.catch((err) => Effect.fail(new DriveScanError({ cause: err })))),
hasPodcastsFolder: (drivePath) =>
Effect.gen(function* () {
@@ -279,7 +282,7 @@ export const DriveScanLive = Layer.succeed(
const exists = yield* fs.exists(podcastsDir);
yield* logger.debug(`Checked folder existence at ${podcastsDir}: ${exists}`);
return exists;
- }).pipe(Effect.catchAll((err) => Effect.fail(new DriveScanError({ cause: err })))),
+ }).pipe(Effect.catch((err) => Effect.fail(new DriveScanError({ cause: err })))),
}),
);
@@ -306,11 +309,12 @@ export const createDriveScanTest = (mockPodcasts: MockDrivePodcast[] = []) =>
const podcasts: Podcast[] = mockPodcasts.map((mock) => ({
id: mock.name,
title: mock.name,
- author: "Unknown",
+ author: UNKNOWN_AUTHOR,
episodeCount: mock.episodes.length,
episodes: mock.episodes.map((ep) => ({
id: ep.id,
title: ep.title,
+ author: UNKNOWN_AUTHOR,
duration: 0,
published: new Date(),
onDrive: true,
diff --git a/src/services/effects/EpisodeMatcher.ts b/src/services/effects/EpisodeMatcher.ts
index b33f309..f508adb 100644
--- a/src/services/effects/EpisodeMatcher.ts
+++ b/src/services/effects/EpisodeMatcher.ts
@@ -4,7 +4,7 @@ import { sanitizeFilename } from "@/utils/formatting";
/**
* EpisodeMatcher Service Tag
*/
-export class EpisodeMatcher extends Context.Tag("EpisodeMatcher")<
+export class EpisodeMatcher extends Context.Service<
EpisodeMatcher,
{
/** Builds the expected relative path for an episode on the drive */
@@ -16,7 +16,7 @@ export class EpisodeMatcher extends Context.Tag("EpisodeMatcher")<
driveIndex: Map,
) => boolean;
}
->() {}
+>()("EpisodeMatcher") {}
const buildExpectedDrivePathImpl = (showName: string, title: string): string => {
return `${sanitizeFilename(showName)}/${sanitizeFilename(title)}.mp3`;
diff --git a/src/services/effects/FileSystem.ts b/src/services/effects/FileSystem.ts
index eb9e644..bd3dcd2 100644
--- a/src/services/effects/FileSystem.ts
+++ b/src/services/effects/FileSystem.ts
@@ -55,7 +55,7 @@ export class RemoveError extends Data.TaggedError("RemoveError")<{
/**
* FileSystem Service Tag
*/
-export class FileSystem extends Context.Tag("FileSystem")<
+export class FileSystem extends Context.Service<
FileSystem,
{
/** Checks if a path exists on the filesystem */
@@ -99,7 +99,7 @@ export class FileSystem extends Context.Tag("FileSystem")<
/** Checks if a directory contains no visible files */
readonly isDirEmpty: (path: string) => Effect.Effect;
}
->() {}
+>()("FileSystem") {}
const isSystemHiddenFileImpl = (name: string): boolean => {
if (SYSTEM_HIDDEN_FILES.includes(name)) return true;
@@ -118,7 +118,7 @@ export const FileSystemLive = Layer.effect(
await stat(path);
}).pipe(
Effect.as(true),
- Effect.catchAll(() => Effect.succeed(false)),
+ Effect.catch(() => Effect.succeed(false)),
),
readFile: (path) =>
@@ -205,7 +205,7 @@ export const FileSystemLive = Layer.effect(
const { stat } = await import("node:fs/promises");
const s = await stat(path);
return s.isDirectory();
- }).pipe(Effect.catchAll(() => Effect.succeed(false))),
+ }).pipe(Effect.catch(() => Effect.succeed(false))),
glob: (pattern, cwd) =>
Effect.tryPromise({
@@ -235,7 +235,7 @@ export const FileSystemLive = Layer.effect(
const { stat } = await import("node:fs/promises");
const s = await stat(path);
return s.size;
- }).pipe(Effect.catchAll(() => Effect.succeed(0))),
+ }).pipe(Effect.catch(() => Effect.succeed(0))),
ensureDir: (dirPath) =>
Effect.gen(function* () {
@@ -281,7 +281,7 @@ export const FileSystemLive = Layer.effect(
Effect.tapError((err) =>
logger.error(`Failed to remove hidden file: ${entry}`, err),
),
- Effect.catchAll(() => Effect.void),
+ Effect.catch(() => Effect.void),
);
}
}
@@ -293,7 +293,7 @@ export const FileSystemLive = Layer.effect(
const entries = yield* Effect.tryPromise({
try: () => readdir(path),
catch: (cause) => new ReadDirError({ path, cause }),
- }).pipe(Effect.catchAll(() => Effect.succeed([] as string[])));
+ }).pipe(Effect.catch(() => Effect.succeed([] as string[])));
const visibleFiles = entries.filter((e) => !isSystemHiddenFileImpl(e));
return visibleFiles.length === 0;
}),
diff --git a/src/services/effects/Logger.ts b/src/services/effects/Logger.ts
index 043aad5..762a189 100644
--- a/src/services/effects/Logger.ts
+++ b/src/services/effects/Logger.ts
@@ -4,7 +4,7 @@ import { actions } from "@/store";
/**
* Logger Service Tag
*/
-export class Logger extends Context.Tag("Logger")<
+export class Logger extends Context.Service<
Logger,
{
/** Logs a debug message to the application store */
@@ -14,7 +14,7 @@ export class Logger extends Context.Tag("Logger")<
/** Logs an error message and optional cause to the application store */
readonly error: (message: string, cause?: unknown) => Effect.Effect;
}
->() {}
+>()("Logger") {}
/**
* Live implementation of Logger that updates the store actions.
diff --git a/src/services/effects/MetadataEditor.ts b/src/services/effects/MetadataEditor.ts
index 6f2aeb3..40777cc 100644
--- a/src/services/effects/MetadataEditor.ts
+++ b/src/services/effects/MetadataEditor.ts
@@ -18,13 +18,13 @@ export interface PodcastMetadata {
/**
* MetadataEditor Service Tag
*/
-export class MetadataEditor extends Context.Tag("MetadataEditor")<
+export class MetadataEditor extends Context.Service<
MetadataEditor,
{
/** Writes ID3 tags to an MP3 file */
readonly write: (path: string, metadata: PodcastMetadata) => Effect.Effect;
}
->() {}
+>()("MetadataEditor") {}
/**
* Live implementation of MetadataEditor using node-id3.
diff --git a/src/services/effects/PodcastService.ts b/src/services/effects/PodcastService.ts
index 0bf6c7d..9c6214e 100644
--- a/src/services/effects/PodcastService.ts
+++ b/src/services/effects/PodcastService.ts
@@ -22,7 +22,7 @@ export class DatabaseNotFoundError extends Data.TaggedError("DatabaseNotFoundErr
/**
* Podcast Service Tag
*/
-export class PodcastService extends Context.Tag("PodcastService")<
+export class PodcastService extends Context.Service<
PodcastService,
{
/** Checks if the Apple Podcasts database is available */
@@ -32,7 +32,7 @@ export class PodcastService extends Context.Tag("PodcastService")<
/** Returns the absolute path to the MTLibrary.sqlite database */
readonly getDatabasePath: () => string;
}
->() {}
+>()("PodcastService") {}
/**
* Live implementation of PodcastService using a background worker for SQLite access.
@@ -71,10 +71,10 @@ export const PodcastServiceLive = Layer.effect(
const workerUrl = new URL(workerPath, import.meta.url);
yield* logger.debug(`Spawning worker for database access: ${workerUrl}`);
return new Worker(workerUrl);
- }).pipe(Effect.catchAll((err) => Effect.fail(new PodcastError({ cause: err })))),
+ }).pipe(Effect.catch((err) => Effect.fail(new PodcastError({ cause: err })))),
// Use: Perform the async work
(worker) =>
- Effect.async((resume) => {
+ Effect.callback((resume) => {
worker.onmessage = (e) => {
if (e.data.type === "SUCCESS") {
const episodes = e.data.data as PodcastEpisode[];
@@ -116,13 +116,3 @@ export const PodcastServiceLive = Layer.effect(
});
}),
);
-
-/**
- * Creates a mock PodcastService for testing.
- */
-export const createPodcastServiceTest = (episodes: PodcastEpisode[] = []) =>
- Layer.succeed(PodcastService, {
- getDatabasePath: () => "/mock/path/to/db.sqlite",
- checkAvailability: Effect.succeed(true),
- loadMacPodcasts: Effect.succeed(episodes),
- });
diff --git a/src/services/effects/SettingsService.ts b/src/services/effects/SettingsService.ts
index bbcb175..2f82792 100644
--- a/src/services/effects/SettingsService.ts
+++ b/src/services/effects/SettingsService.ts
@@ -17,7 +17,7 @@ const SCHEMA_URL =
/**
* SettingsService Service Tag
*/
-export class SettingsService extends Context.Tag("SettingsService")<
+export class SettingsService extends Context.Service<
SettingsService,
{
/** Loads application settings from ~/.config/podapple/podapple.jsonc */
@@ -25,7 +25,7 @@ export class SettingsService extends Context.Tag("SettingsService")<
/** Saves application settings to ~/.config/podapple/podapple.jsonc */
readonly saveSettings: (settings: Partial) => Effect.Effect;
}
->() {}
+>()("SettingsService") {}
/**
* Live implementation of SettingsService.
@@ -53,7 +53,7 @@ export const SettingsServiceLive = Layer.effect(
catch: (cause) => new Error(`Failed to parse settings: ${cause}`),
}),
),
- Effect.catchAll(() => Effect.succeed(null)),
+ Effect.catch(() => Effect.succeed(null)),
);
if (result) {
diff --git a/src/services/effects/SyncEngine.ts b/src/services/effects/SyncEngine.ts
index 39e074f..ab4dd11 100644
--- a/src/services/effects/SyncEngine.ts
+++ b/src/services/effects/SyncEngine.ts
@@ -6,7 +6,7 @@
import { Buffer } from "node:buffer";
import * as fs from "node:fs/promises";
-import { Context, Data, Effect, Layer, Option, Stream } from "effect";
+import { Cause, Context, Data, Effect, Layer, Option, Result, Stream } from "effect";
import { EpisodeMatcher } from "@/services/effects/EpisodeMatcher";
import { FileSystem, type WriteError } from "@/services/effects/FileSystem";
import { Logger } from "@/services/effects/Logger";
@@ -40,7 +40,7 @@ export class CleanupError extends Data.TaggedError("CleanupError")<{
/**
* SyncEngine Service Tag
*/
-export class SyncEngine extends Context.Tag("SyncEngine")<
+export class SyncEngine extends Context.Service<
SyncEngine,
{
/** Creates a plan for what needs to be copied or deleted */
@@ -63,7 +63,7 @@ export class SyncEngine extends Context.Tag("SyncEngine")<
/** Cleans up empty show directories and system hidden files on the drive */
readonly cleanup: (drivePath: string) => Effect.Effect;
}
->() {}
+>()("SyncEngine") {}
/**
* Formats the destination path for an episode on the drive.
@@ -87,64 +87,66 @@ export function formatDestPath(
* Cleans up partial files on failure or interruption.
*/
const copyFileStream = (src: string, dest: string): Stream.Stream =>
- Stream.unwrapScoped(
- Effect.gen(function* () {
- const srcHandle = yield* Effect.acquireRelease(
- Effect.tryPromise({
- try: () => fs.open(src, "r"),
- catch: (cause) => new SyncCopyError({ src, dest, cause }),
- }),
- (handle) => Effect.promise(() => handle.close()),
- );
+ Stream.scoped(
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const srcHandle = yield* Effect.acquireRelease(
+ Effect.tryPromise({
+ try: () => fs.open(src, "r"),
+ catch: (cause) => new SyncCopyError({ src, dest, cause }),
+ }),
+ (handle) => Effect.promise(() => handle.close()),
+ );
- const destHandle = yield* Effect.acquireRelease(
- Effect.tryPromise({
- try: () => fs.open(dest, "w"),
- catch: (cause) => new SyncCopyError({ src, dest, cause }),
- }),
- (handle) =>
- Effect.gen(function* () {
- yield* Effect.promise(() => handle.close());
- // Check if we were interrupted or failed before finishing
- const stat = yield* Effect.tryPromise(() => fs.stat(dest)).pipe(
- Effect.catchAll(() => Effect.succeed(null)),
- );
- const srcStat = yield* Effect.tryPromise(() => fs.stat(src)).pipe(
- Effect.catchAll(() => Effect.succeed(null)),
- );
- if (stat && srcStat && stat.size < srcStat.size) {
- yield* Effect.tryPromise(() => fs.unlink(dest)).pipe(
- Effect.catchAll(() => Effect.void),
- );
- }
+ const destHandle = yield* Effect.acquireRelease(
+ Effect.tryPromise({
+ try: () => fs.open(dest, "w"),
+ catch: (cause) => new SyncCopyError({ src, dest, cause }),
}),
- );
+ (handle) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => handle.close());
+ // Check if we were interrupted or failed before finishing
+ const stat = yield* Effect.tryPromise(() => fs.stat(dest)).pipe(
+ Effect.catch(() => Effect.succeed(null)),
+ );
+ const srcStat = yield* Effect.tryPromise(() => fs.stat(src)).pipe(
+ Effect.catch(() => Effect.succeed(null)),
+ );
+ if (stat && srcStat && stat.size < srcStat.size) {
+ yield* Effect.tryPromise(() => fs.unlink(dest)).pipe(
+ Effect.catch(() => Effect.void),
+ );
+ }
+ }),
+ );
- const buffer = Buffer.alloc(BUFFER_SIZE);
+ const buffer = Buffer.alloc(BUFFER_SIZE);
- return Stream.repeatEffectOption(
- Effect.tryPromise({
- try: () => srcHandle.read(buffer, 0, BUFFER_SIZE, null),
- catch: (cause) => Option.some(new SyncCopyError({ src, dest, cause })),
- }).pipe(
- Effect.flatMap(({ bytesRead }) => {
- if (bytesRead === 0) return Effect.fail(Option.none());
- return Effect.tryPromise({
- try: () => destHandle.write(buffer.subarray(0, bytesRead)),
- catch: (cause) => Option.some(new SyncCopyError({ src, dest, cause })),
- }).pipe(
- Effect.flatMap(() =>
- Effect.tryPromise({
- try: () => destHandle.datasync(),
- catch: (cause) => Option.some(new SyncCopyError({ src, dest, cause })),
- }),
- ),
- Effect.as(bytesRead),
- );
- }),
- ),
- );
- }),
+ return Stream.fromEffectRepeat(
+ Effect.tryPromise({
+ try: () => srcHandle.read(buffer, 0, BUFFER_SIZE, null),
+ catch: (cause) => new SyncCopyError({ src, dest, cause }),
+ }).pipe(
+ Effect.flatMap(({ bytesRead }): Effect.Effect => {
+ if (bytesRead === 0) return Cause.done();
+ return Effect.tryPromise({
+ try: () => destHandle.write(buffer.subarray(0, bytesRead)),
+ catch: (cause) => new SyncCopyError({ src, dest, cause }),
+ }).pipe(
+ Effect.flatMap(() =>
+ Effect.tryPromise({
+ try: () => destHandle.datasync(),
+ catch: (cause) => new SyncCopyError({ src, dest, cause }),
+ }),
+ ),
+ Effect.as(bytesRead),
+ );
+ }),
+ ),
+ );
+ }),
+ ),
);
/**
@@ -225,6 +227,10 @@ export const SyncEngineLive = Layer.effect(
yield* logger.info(`Executing sync plan: ${plan.totalFiles} files to copy`);
const startTime = Date.now();
let bytesWritten = 0;
+ // Files copied but discarded because tagging failed. The sync still
+ // succeeds, but the count is surfaced so the user is not told
+ // "Sync complete" while files are quietly missing.
+ let discarded = 0;
return Stream.fromIterable(plan.toCopy).pipe(
Stream.zipWithIndex,
@@ -233,10 +239,11 @@ export const SyncEngineLive = Layer.effect(
const initialProgress = Stream.succeed({
currentFile: item.episode.title,
- currentIndex: i,
+ currentIndex: i + 1,
totalFiles: plan.totalFiles,
bytesTransferred: bytesWritten,
totalBytes: plan.totalBytes,
+ discarded,
startTime,
status: "syncing",
});
@@ -252,40 +259,41 @@ export const SyncEngineLive = Layer.effect(
).pipe(
Stream.flatMap(() =>
copyFileStream(item.sourcePath, item.destPath).pipe(
- Stream.map((bytes) => {
+ Stream.map((bytes): SyncProgress => {
bytesWritten += bytes;
return {
currentFile: item.episode.title,
- currentIndex: i,
+ currentIndex: i + 1,
totalFiles: plan.totalFiles,
bytesTransferred: bytesWritten,
totalBytes: plan.totalBytes,
+ discarded,
startTime,
status: "syncing",
- } as SyncProgress;
+ };
}),
),
),
Stream.concat(
Stream.fromEffect(
- Effect.sync(() => {
+ Effect.sync((): SyncProgress => ({
// Emit a progress update to show we are tagging
- return {
- currentFile: `Tagging: ${item.episode.title}`,
- currentIndex: i,
- totalFiles: plan.totalFiles,
- bytesTransferred: bytesWritten,
- totalBytes: plan.totalBytes,
- startTime,
- status: "syncing",
- } as SyncProgress;
- }),
+ currentFile: `Tagging: ${item.episode.title}`,
+ currentIndex: i + 1,
+ totalFiles: plan.totalFiles,
+ bytesTransferred: bytesWritten,
+ totalBytes: plan.totalBytes,
+ discarded,
+ startTime,
+ status: "syncing",
+ })),
),
),
Stream.concat(
Stream.fromEffect(
Effect.gen(function* () {
const metadataEditor = yield* MetadataEditor;
+ const fs = yield* FileSystem;
yield* logger.debug(`Tagging episode: ${item.episode.title}`);
yield* metadataEditor
.write(item.destPath, {
@@ -300,28 +308,56 @@ export const SyncEngineLive = Layer.effect(
Effect.tap(() =>
logger.debug(`Successfully tagged: ${item.episode.title}`),
),
+ // A failed tag never fails the sync, but the copied file would linger
+ // half-tagged on the drive, so discard it and let the next run re-copy.
Effect.tapError((err) =>
- logger.error(`Failed to tag: ${item.episode.title}`, err),
+ logger.error(
+ `Failed to tag: ${item.episode.title}, discarding copied file: ${item.destPath}`,
+ err,
+ ),
+ ),
+ Effect.catch(() =>
+ Effect.suspend(() => {
+ // Count it even if the unlink itself fails — either way
+ // the file did not make it onto the drive correctly.
+ discarded += 1;
+ return fs.remove(item.destPath).pipe(
+ // createPlan skips any dest path that exists, so a
+ // leftover here is never re-copied. Worth a line.
+ Effect.tapError((err) =>
+ logger.error(
+ `Failed to discard untagged file: ${item.destPath}`,
+ err,
+ ),
+ ),
+ Effect.catch(() => Effect.void),
+ );
+ }),
),
- Effect.catchAll(() => Effect.void),
);
}),
- ).pipe(Stream.filterMap(() => Option.none())),
+ ).pipe(Stream.filterMap(() => Result.fail(undefined))),
),
);
return Stream.concat(initialProgress, copyFlow);
}),
Stream.concat(
- Stream.succeed({
- currentFile: "",
- currentIndex: plan.totalFiles,
- totalFiles: plan.totalFiles,
- bytesTransferred: plan.totalBytes,
- totalBytes: plan.totalBytes,
- startTime,
- status: "complete",
- }),
+ // Deferred, not Stream.succeed: `discarded` is only final once every
+ // file has been tagged, and Stream.succeed builds its value when
+ // the stream is assembled rather than when it is consumed.
+ Stream.suspend((): Stream.Stream =>
+ Stream.succeed({
+ currentFile: "",
+ currentIndex: plan.totalFiles,
+ totalFiles: plan.totalFiles,
+ bytesTransferred: plan.totalBytes,
+ totalBytes: plan.totalBytes,
+ discarded,
+ startTime,
+ status: "complete",
+ }),
+ ),
),
Stream.tap(() => logger.info("Sync plan execution complete")),
);
@@ -351,7 +387,7 @@ export const SyncEngineLive = Layer.effect(
const podcastsPath = `${drivePath}/Podcasts`;
const showDirs = yield* fs.readDir(podcastsPath).pipe(
Effect.mapError((cause) => new CleanupError({ path: podcastsPath, cause })),
- Effect.catchAll(() => Effect.succeed([] as string[])),
+ Effect.catch(() => Effect.succeed([] as string[])),
);
for (const showDir of showDirs) {
if (fs.isSystemHiddenFile(showDir)) continue;
@@ -359,13 +395,13 @@ export const SyncEngineLive = Layer.effect(
const isDir = yield* fs.isDirectory(showPath);
if (isDir) {
yield* logger.debug(`Cleaning show directory: ${showDir}`);
- yield* fs.cleanupSystemHiddenFiles(showPath).pipe(Effect.catchAll(() => Effect.void));
+ yield* fs.cleanupSystemHiddenFiles(showPath).pipe(Effect.catch(() => Effect.void));
const empty = yield* fs.isDirEmpty(showPath);
if (empty) {
yield* logger.info(`Removing empty show directory: ${showDir}`);
yield* fs.remove(showPath).pipe(
Effect.mapError((cause) => new CleanupError({ path: showPath, cause })),
- Effect.catchAll(() => Effect.void),
+ Effect.catch(() => Effect.void),
);
}
}
@@ -411,33 +447,33 @@ export const createSyncEngineTest = (mockFiles: Map = new Ma
totalBytes += sSize;
}
}
- return { toCopy, toDelete: [], totalFiles: toCopy.length, totalBytes } as SyncPlan;
+ return { toCopy, toDelete: [], totalFiles: toCopy.length, totalBytes } satisfies SyncPlan;
}),
execute: (plan) => {
const startTime = Date.now();
return Stream.fromIterable(plan.toCopy).pipe(
Stream.zipWithIndex,
- Stream.map(
- ([item, i]): SyncProgress => ({
- currentFile: item.episode.title,
- currentIndex: i + 1,
- totalFiles: plan.totalFiles,
- bytesTransferred: plan.toCopy.slice(0, i + 1).reduce((acc, i) => acc + i.size, 0),
- totalBytes: plan.totalBytes,
- startTime,
- status: "syncing",
- }),
- ),
+ Stream.map(([item, i]): SyncProgress => ({
+ currentFile: item.episode.title,
+ currentIndex: i + 1,
+ totalFiles: plan.totalFiles,
+ bytesTransferred: plan.toCopy.slice(0, i + 1).reduce((acc, i) => acc + i.size, 0),
+ totalBytes: plan.totalBytes,
+ discarded: 0,
+ startTime,
+ status: "syncing",
+ })),
Stream.concat(
- Stream.succeed({
+ Stream.succeed({
currentFile: "",
currentIndex: plan.totalFiles,
totalFiles: plan.totalFiles,
bytesTransferred: plan.totalBytes,
totalBytes: plan.totalBytes,
+ discarded: 0,
startTime,
status: "complete",
- } as SyncProgress),
+ }),
),
);
},
diff --git a/src/services/workers/db.worker.ts b/src/services/workers/db.worker.ts
index d992a8b..850dfde 100644
--- a/src/services/workers/db.worker.ts
+++ b/src/services/workers/db.worker.ts
@@ -6,11 +6,14 @@ declare var self: Worker;
const APPLE_EPOCH_OFFSET = 978307200;
+/** Fallback used when a podcast has no author recorded, so ID3 artist is never blank. */
+const UNKNOWN_AUTHOR = "Unknown";
+
const parseFileUrl = (url: string) =>
Effect.try({
try: () => decodeURIComponent(new URL(url).pathname),
catch: () => new Error("Invalid URL"),
- }).pipe(Effect.orElse(() => Effect.succeed(url.replace("file://", ""))));
+ }).pipe(Effect.orElseSucceed(() => url.replace("file://", "")));
self.onmessage = (event: MessageEvent) => {
const { type, dbPath } = event.data;
@@ -26,6 +29,7 @@ self.onmessage = (event: MessageEvent) => {
e.Z_PK as id,
e.ZTITLE as title,
p.ZTITLE as showName,
+ p.ZAUTHOR as author,
e.ZASSETURL as assetUrl,
e.ZPUBDATE as pubDate,
e.ZDURATION as duration
@@ -53,6 +57,7 @@ self.onmessage = (event: MessageEvent) => {
id: String(row.id),
title: row.title ?? "Untitled",
showName: row.showName ?? "Unknown",
+ author: row.author?.trim() || UNKNOWN_AUTHOR,
filePath,
published: row.pubDate
? new Date((row.pubDate + APPLE_EPOCH_OFFSET) * 1000)
diff --git a/src/store/index.ts b/src/store/index.ts
index fd9587f..bd0157e 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -1,4 +1,4 @@
-import { createStore } from "solid-js/store";
+import { createStore } from "solid-js";
import type { DebugMessage } from "@/components/DebugPopup";
import type { Drive } from "@/types/drive";
import type { AppView, FocusedPane } from "@/types/keyboard";
@@ -6,6 +6,7 @@ import type { PodcastEpisode } from "@/types/podcast";
export interface TransferProgress {
currentFile: string;
+ lastFile: string;
filesDone: number;
totalFiles: number;
bytesTransferred: number;
@@ -28,6 +29,7 @@ export interface AppState {
isScanning: boolean;
transferProgress: TransferProgress;
errorMsg: string;
+ successMsg: string;
debugMessages: DebugMessage[];
lastKey: string | null;
lastSavedTheme: string;
@@ -53,6 +55,7 @@ const initialState: AppState = {
isScanning: false,
transferProgress: {
currentFile: "",
+ lastFile: "",
filesDone: 0,
totalFiles: 0,
bytesTransferred: 0,
@@ -60,6 +63,7 @@ const initialState: AppState = {
speed: 0,
},
errorMsg: "",
+ successMsg: "",
debugMessages: [],
lastKey: null,
lastSavedTheme: "Catppuccin",
@@ -68,67 +72,166 @@ const initialState: AppState = {
export const [state, setState] = createStore(initialState);
+const updateState = (update: (draft: AppState) => void): void => {
+ setState(update);
+};
+
+const resolveUpdate = (value: T | ((previous: T) => T), previous: T): T =>
+ typeof value === "function" ? (value as (previous: T) => T)(previous) : value;
+
export const actions = {
- setAppView: (view: AppView) => setState("appView", view),
- setFocusedPane: (pane: FocusedPane) => setState("focusedPane", pane),
- setMacIndex: (index: number | ((prev: number) => number)) => setState("macIndex", index),
- setDriveIndex: (index: number | ((prev: number) => number)) => setState("driveIndex", index),
- setDriveMenuIndex: (index: number | ((prev: number) => number)) =>
- setState("driveMenuIndex", index),
- setThemeMenuIndex: (index: number | ((prev: number) => number)) =>
- setState("themeMenuIndex", index),
- setDebugMenuIndex: (index: number | ((prev: number) => number)) =>
- setState("debugMenuIndex", index),
- setMacPodcasts: (episodes: PodcastEpisode[] | ((prev: PodcastEpisode[]) => PodcastEpisode[])) =>
- setState("macPodcasts", episodes),
- setDrivePodcasts: (episodes: PodcastEpisode[] | ((prev: PodcastEpisode[]) => PodcastEpisode[])) =>
- setState("drivePodcasts", episodes),
- setDrives: (drives: Drive[] | ((prev: Drive[]) => Drive[])) => setState("drives", drives),
- setCurrentDrive: (drive: Drive | null | ((prev: Drive | null) => Drive | null)) =>
- setState("currentDrive", drive),
- setLoadingMac: (loading: boolean) => setState("loadingMac", loading),
- setLoadingDrive: (loading: boolean) => setState("loadingDrive", loading),
- setIsScanning: (scanning: boolean) => setState("isScanning", scanning),
+ setAppView: (view: AppView) =>
+ updateState((draft) => {
+ draft.appView = view;
+ }),
+ setFocusedPane: (pane: FocusedPane) =>
+ updateState((draft) => {
+ draft.focusedPane = pane;
+ }),
+ setMacIndex: (index: number | ((previous: number) => number)) =>
+ updateState((draft) => {
+ draft.macIndex = resolveUpdate(index, draft.macIndex);
+ }),
+ setDriveIndex: (index: number | ((previous: number) => number)) =>
+ updateState((draft) => {
+ draft.driveIndex = resolveUpdate(index, draft.driveIndex);
+ }),
+ setDriveMenuIndex: (index: number | ((previous: number) => number)) =>
+ updateState((draft) => {
+ draft.driveMenuIndex = resolveUpdate(index, draft.driveMenuIndex);
+ }),
+ setThemeMenuIndex: (index: number | ((previous: number) => number)) =>
+ updateState((draft) => {
+ draft.themeMenuIndex = resolveUpdate(index, draft.themeMenuIndex);
+ }),
+ setDebugMenuIndex: (index: number | ((previous: number) => number)) =>
+ updateState((draft) => {
+ draft.debugMenuIndex = resolveUpdate(index, draft.debugMenuIndex);
+ }),
+ setMacPodcasts: (
+ episodes: PodcastEpisode[] | ((previous: PodcastEpisode[]) => PodcastEpisode[]),
+ ) =>
+ updateState((draft) => {
+ draft.macPodcasts = resolveUpdate(episodes, draft.macPodcasts);
+ }),
+ setDrivePodcasts: (
+ episodes: PodcastEpisode[] | ((previous: PodcastEpisode[]) => PodcastEpisode[]),
+ ) =>
+ updateState((draft) => {
+ draft.drivePodcasts = resolveUpdate(episodes, draft.drivePodcasts);
+ }),
+ setDrives: (drives: Drive[] | ((previous: Drive[]) => Drive[])) =>
+ updateState((draft) => {
+ draft.drives = resolveUpdate(drives, draft.drives);
+ }),
+ setCurrentDrive: (drive: Drive | null | ((previous: Drive | null) => Drive | null)) =>
+ updateState((draft) => {
+ draft.currentDrive = resolveUpdate(drive, draft.currentDrive);
+ }),
+ setLoadingMac: (loading: boolean) =>
+ updateState((draft) => {
+ draft.loadingMac = loading;
+ }),
+ setLoadingDrive: (loading: boolean) =>
+ updateState((draft) => {
+ draft.loadingDrive = loading;
+ }),
+ setIsScanning: (scanning: boolean) =>
+ updateState((draft) => {
+ draft.isScanning = scanning;
+ }),
updateTransferProgress: (progress: Partial) =>
- setState("transferProgress", (prev) => ({ ...prev, ...progress })),
- setErrorMsg: (msg: string) => setState("errorMsg", msg),
+ updateState((draft) => {
+ if (progress.currentFile === "Preparing...") {
+ draft.transferProgress.lastFile = "";
+ } else if (progress.currentFile) {
+ draft.transferProgress.lastFile = progress.currentFile;
+ }
+ Object.assign(draft.transferProgress, progress);
+ }),
+ // The two message fields are mutually exclusive: one status line at a time.
+ // Enforced here rather than at each call site so a stale success cannot sit
+ // next to a fresh error (and vice versa) whichever setter runs.
+ setErrorMsg: (msg: string) =>
+ updateState((draft) => {
+ draft.errorMsg = msg;
+ draft.successMsg = "";
+ }),
+ setSuccessMsg: (msg: string) =>
+ updateState((draft) => {
+ draft.successMsg = msg;
+ draft.errorMsg = "";
+ }),
addDebugMessage: (message: string, type: DebugMessage["type"] = "info") => {
- setState("debugMessages", (prev) => {
- const next = [...prev, { timestamp: Date.now(), message, type }];
- return next.length > 500 ? next.slice(-500) : next;
+ updateState((draft) => {
+ draft.debugMessages.push({ timestamp: Date.now(), message, type });
+ if (draft.debugMessages.length > 500) {
+ draft.debugMessages.splice(0, draft.debugMessages.length - 500);
+ }
});
},
- clearDebugMessages: () => setState("debugMessages", []),
- setLastKey: (key: string | null) => setState("lastKey", key),
- setLastSavedTheme: (theme: string) => setState("lastSavedTheme", theme),
- setFavoriteDrives: (drives: string[]) => setState("favoriteDrives", drives),
+ clearDebugMessages: () =>
+ updateState((draft) => {
+ draft.debugMessages = [];
+ }),
+ setLastKey: (key: string | null) =>
+ updateState((draft) => {
+ draft.lastKey = key;
+ }),
+ setLastSavedTheme: (theme: string) =>
+ updateState((draft) => {
+ draft.lastSavedTheme = theme;
+ }),
+ setFavoriteDrives: (drives: string[]) =>
+ updateState((draft) => {
+ draft.favoriteDrives = drives;
+ }),
toggleFavoriteDrive: (driveId: string) => {
- setState("favoriteDrives", (prev) => {
- if (prev.includes(driveId)) {
- return prev.filter((id) => id !== driveId);
+ updateState((draft) => {
+ const index = draft.favoriteDrives.indexOf(driveId);
+ if (index >= 0) {
+ draft.favoriteDrives.splice(index, 1);
+ } else {
+ draft.favoriteDrives.push(driveId);
}
- return [...prev, driveId];
});
},
toggleMacSelection: (index: number) => {
- setState("macPodcasts", index, "selected", (s) => !s);
+ updateState((draft) => {
+ const episode = draft.macPodcasts[index];
+ if (episode) episode.selected = !episode.selected;
+ });
},
toggleAllMacSelection: () => {
const allSelected = state.macPodcasts.every((p) => p.selected);
- setState("macPodcasts", (prev) => prev.map((p) => ({ ...p, selected: !allSelected })));
+ updateState((draft) => {
+ for (const episode of draft.macPodcasts) episode.selected = !allSelected;
+ });
},
clearMacSelection: () => {
- setState("macPodcasts", (prev) => prev.map((p) => ({ ...p, selected: false })));
+ updateState((draft) => {
+ for (const episode of draft.macPodcasts) episode.selected = false;
+ });
},
toggleDriveSelection: (index: number) => {
- setState("drivePodcasts", index, "selected", (s) => !s);
+ updateState((draft) => {
+ const episode = draft.drivePodcasts[index];
+ if (episode) episode.selected = !episode.selected;
+ });
},
toggleAllDriveSelection: () => {
const allSelected = state.drivePodcasts.every((p) => p.selected);
- setState("drivePodcasts", (prev) => prev.map((p) => ({ ...p, selected: !allSelected })));
+ updateState((draft) => {
+ for (const episode of draft.drivePodcasts) episode.selected = !allSelected;
+ });
},
clearDriveSelection: () => {
- setState("drivePodcasts", (prev) => prev.map((p) => ({ ...p, selected: false })));
+ updateState((draft) => {
+ for (const episode of draft.drivePodcasts) episode.selected = false;
+ });
},
- resetState: () => setState(initialState),
+ resetState: () =>
+ updateState((draft) => {
+ Object.assign(draft, initialState);
+ }),
};
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index 5eacd4c..79efd83 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -1,5 +1,4 @@
-import { createSignal } from "solid-js";
-import { createStore, reconcile } from "solid-js/store";
+import { createSignal, createStore, reconcile } from "solid-js";
import { createTheme, Palettes, type ThemePalette } from "./themes";
export type Theme = ReturnType;
@@ -20,3 +19,8 @@ export function setTheme(themeName: string) {
setPalette(palette);
}
}
+
+export function previewThemeAt(index: number): void {
+ const themeName = Themes[index];
+ if (themeName) setTheme(themeName);
+}
diff --git a/src/theme/index.ts b/src/theme/index.ts
deleted file mode 100644
index bbdc171..0000000
--- a/src/theme/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./colors";
diff --git a/src/types/index.ts b/src/types/index.ts
deleted file mode 100644
index 0600a51..0000000
--- a/src/types/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from "./drive";
-export * from "./keyboard";
-export * from "./podcast";
-export * from "./sync";
diff --git a/src/types/keyboard.ts b/src/types/keyboard.ts
index 3bb5674..3860ebc 100644
--- a/src/types/keyboard.ts
+++ b/src/types/keyboard.ts
@@ -5,12 +5,7 @@ export type AppView =
| "transferring"
| "confirm"
| "themeSelection"
- | "debug"
- | "main"
- | "podcasts"
- | "episodes"
- | "drives"
- | "sync";
+ | "debug";
/**
* Focused pane in dual-pane layout.
diff --git a/src/types/podcast.ts b/src/types/podcast.ts
index 11bf3fd..7a2c15a 100644
--- a/src/types/podcast.ts
+++ b/src/types/podcast.ts
@@ -1,6 +1,8 @@
export interface Episode {
id: string;
title: string;
+ /** Podcast author, used as the ID3 artist tag. Never the empty string in production. */
+ author: string;
duration: number;
published: Date;
onDrive: boolean;
@@ -22,12 +24,11 @@ export interface Podcast {
uuid?: string; // UUID from macOS Podcasts DB (for linking episodes)
}
-export type PaneId = "mac" | "drive";
-
export interface EpisodeRow {
id: number;
title: string | null;
showName: string | null;
+ author: string | null;
assetUrl: string | null;
pubDate: number | null;
duration: number | null;
diff --git a/src/types/sync.ts b/src/types/sync.ts
index 41f3bb3..b63cd0d 100644
--- a/src/types/sync.ts
+++ b/src/types/sync.ts
@@ -6,22 +6,13 @@ export interface SyncProgress {
totalFiles: number;
bytesTransferred: number;
totalBytes: number;
+ /** Files copied but discarded because tagging failed. Monotonic across a run. */
+ discarded: number;
startTime: number;
status: "idle" | "syncing" | "complete" | "error";
error?: string;
}
-export interface SyncOptions {
- sourcePath: string; // Path to Mac podcasts
- destinationPath: string; // Path to USB drive
- deleteOrphans: boolean; // Remove episodes not on Mac
- dryRun: boolean; // Preview changes without syncing
-}
-
-export interface SyncEpisode extends Episode {
- podcastTitle: string;
-}
-
export interface CopyItem {
episode: Episode;
podcast: Podcast;
@@ -42,23 +33,3 @@ export interface SyncPlan {
totalBytes: number;
totalFiles: number;
}
-
-export interface SyncResult {
- copied: number;
- deleted: number;
- failed: Array<{ file: string; error: string }>;
- duration: number;
-}
-
-export interface DriveEpisode {
- id: string;
- title: string;
- path: string;
- size: number;
-}
-
-export interface DrivePodcast {
- name: string;
- path: string;
- episodes: DriveEpisode[];
-}
diff --git a/src/utils/drive.ts b/src/utils/drive.ts
index 1fd9aa1..5c981ee 100644
--- a/src/utils/drive.ts
+++ b/src/utils/drive.ts
@@ -6,6 +6,7 @@ import { formatBytes } from "@/utils/formatting";
* e.g., "WALKMAN (15.2 GB free)"
*/
export function formatDriveInfo(drive: Drive): string {
- const freeText = drive.freeSpace >= 0 ? `${formatBytes(drive.freeSpace)} free` : "unknown free space";
+ const freeText =
+ drive.freeSpace >= 0 ? `${formatBytes(drive.freeSpace)} free` : "unknown free space";
return `${drive.name} (${freeText})`;
}
diff --git a/src/utils/driveSelection.ts b/src/utils/driveSelection.ts
new file mode 100644
index 0000000..d5b1296
--- /dev/null
+++ b/src/utils/driveSelection.ts
@@ -0,0 +1,9 @@
+import { actions } from "@/store";
+import type { Drive } from "@/types/drive";
+
+export const selectDrive = (drive: Drive, loadDrivePodcasts: (drive: Drive) => void): void => {
+ actions.setCurrentDrive(drive);
+ actions.setErrorMsg("");
+ loadDrivePodcasts(drive);
+ actions.setAppView("normal");
+};
diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts
index a138b67..a5c9ef6 100644
--- a/src/utils/formatting.ts
+++ b/src/utils/formatting.ts
@@ -6,6 +6,9 @@ export const APPLE_EPOCH_OFFSET = 978307200;
const BYTES_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
+/** Fallback podcast author. Matches the DriveScan convention so ID3 artist is never blank. */
+export const UNKNOWN_AUTHOR = "Unknown";
+
/**
* Formats bytes into human-readable string
* e.g., 1073741824 → "1.0 GB"
@@ -89,11 +92,12 @@ export function groupEpisodesByPodcast(episodes: PodcastEpisode[]): Podcast[] {
return Array.from(grouped.entries()).map(([showName, eps]) => ({
id: showName,
title: showName,
- author: "",
+ author: eps.find((ep) => ep.author.trim().length > 0)?.author ?? UNKNOWN_AUTHOR,
episodeCount: eps.length,
episodes: eps.map((ep) => ({
id: ep.id,
title: ep.title,
+ author: ep.author,
duration: ep.duration,
published: ep.published,
onDrive: false,
@@ -188,6 +192,7 @@ export const mapPodcastsToEpisodes = (podcasts: Podcast[]): PodcastEpisode[] =>
p.episodes.map((ep) => ({
id: ep.id,
title: ep.title,
+ author: p.author.trim() || UNKNOWN_AUTHOR,
showName: p.title,
filePath: ep.filePath,
published: ep.published,
@@ -215,6 +220,7 @@ export function mapDriveEpisodes(
episodes.push({
id: info.path,
title: info.title,
+ author: UNKNOWN_AUTHOR,
showName,
filePath: info.path,
published: new Date(), // Not available from file scan
diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts
index a18633e..f4c16c1 100644
--- a/src/utils/keyboard.ts
+++ b/src/utils/keyboard.ts
@@ -1,11 +1,5 @@
import type { AppView } from "@/types/keyboard";
-const NAVIGATION_KEYS = new Set(["j", "k", "up", "down"]);
-
-export function isNavigationKey(key: string): boolean {
- return NAVIGATION_KEYS.has(key.toLowerCase());
-}
-
/**
* Creates a condensed list of footer shortcuts for a given view.
* Matches original podcasts-sync layout.
@@ -18,10 +12,7 @@ export function getFooterShortcuts(
const line2: Array<{ key: string; label: string }> = [];
switch (view) {
- case "main":
case "normal":
- case "podcasts":
- case "episodes":
line1.push(
{ key: "↑/↓", label: "navigate" },
{ key: "tab", label: "switch list" },
@@ -37,9 +28,7 @@ export function getFooterShortcuts(
break;
case "confirm":
case "debug":
- case "drives":
case "driveSelection":
- case "sync":
case "syncing":
case "themeSelection":
case "transferring":
diff --git a/src/utils/plist.ts b/src/utils/plist.ts
index 00fa08f..5cabc00 100644
--- a/src/utils/plist.ts
+++ b/src/utils/plist.ts
@@ -4,7 +4,7 @@
* Parses Apple plist XML format from diskutil output.
*/
-import { Either } from "effect";
+import { Result } from "effect";
export type PlistValue =
| string
@@ -22,7 +22,7 @@ export interface PlistDict {
* Parses Apple plist XML format from diskutil output.
* Supports dict, array, string, integer, real, boolean, date, and data tags.
*/
-export function parsePlist(xml: string): Either.Either {
+export function parsePlist(xml: string): Result.Result {
const content = xml
.replace(/<\?xml[^?]*\?>/g, "")
.replace(/]*>/g, "")
@@ -30,14 +30,14 @@ export function parsePlist(xml: string): Either.Either {
const plistMatch = content.match(/]*>([\s\S]*)<\/plist>/);
if (!plistMatch) {
- return Either.left(new Error("Invalid plist: no element found"));
+ return Result.fail(new Error("Invalid plist: no element found"));
}
const innerContent = plistMatch[1]?.trim() ?? "";
try {
- return Either.right(parseElement(innerContent));
+ return Result.succeed(parseElement(innerContent));
} catch (error) {
- return Either.left(error instanceof Error ? error : new Error(String(error)));
+ return Result.fail(error instanceof Error ? error : new Error(String(error)));
}
}