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))); } }