From b1075e1a8a7f6fedf0f5a687018eaf9becae0f55 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:23:29 -0400 Subject: [PATCH 01/11] feat: migrate to Effect 4 and Solid 2 RC --- AGENTS.md | 6 +- bun.lock | 32 +- package.json | 16 +- patches/@opentui%2Fsolid@0.5.11.patch | 577 +++++++++++++++++++++ src/__tests__/drive_selection.test.ts | 22 + src/__tests__/effects.test.ts | 27 +- src/__tests__/keyboard_integration.test.ts | 12 + src/__tests__/opentui_runtime.test.ts | 31 ++ src/__tests__/plist.test.ts | 16 +- src/__tests__/sync.test.ts | 40 +- src/components/Modal.tsx | 4 +- src/components/Selector.tsx | 2 +- src/components/Spinner.tsx | 38 +- src/components/ThemePicker.tsx | 18 +- src/components/TransferPopup.tsx | 15 +- src/hooks/useAppKeyboard.ts | 20 +- src/hooks/useAppLogic.ts | 51 +- src/index.tsx | 45 +- src/services/effects/DriveDetection.ts | 204 ++++---- src/services/effects/DriveScan.ts | 14 +- src/services/effects/EpisodeMatcher.ts | 4 +- src/services/effects/FileSystem.ts | 14 +- src/services/effects/Logger.ts | 4 +- src/services/effects/MetadataEditor.ts | 4 +- src/services/effects/PodcastService.ts | 8 +- src/services/effects/SettingsService.ts | 6 +- src/services/effects/SyncEngine.ts | 146 +++--- src/services/workers/db.worker.ts | 2 +- src/store/index.ts | 174 +++++-- src/theme/colors.ts | 8 +- src/utils/driveSelection.ts | 9 + src/utils/plist.ts | 10 +- 32 files changed, 1165 insertions(+), 414 deletions(-) create mode 100644 patches/@opentui%2Fsolid@0.5.11.patch create mode 100644 src/__tests__/drive_selection.test.ts create mode 100644 src/__tests__/opentui_runtime.test.ts create mode 100644 src/utils/driveSelection.ts diff --git a/AGENTS.md b/AGENTS.md index 37d348d..9c088df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ The project is built on **Bun** and uses **TypeScript** with **SolidJS** for the - **Runtime**: Bun (latest stable) - **Language**: TypeScript 5+ -- **Framework**: SolidJS 1.9+ +- **Framework**: SolidJS 2.0.0-rc.9 (this migration branch; stable release is a merge gate) - **UI Library**: `@opentui/solid` - **Tooling**: Oxlint/Oxfmt (Linting & Formatting), Just (Command Runner) @@ -100,7 +100,7 @@ Enforced by `.oxlintrc.json`, `.oxfmt.json`, and `tsconfig.json`. - **Signals**: `createSignal` for primitives. - **Stores**: `createStore` for objects/arrays (e.g., file lists). -- **Effects**: `createEffect` for side effects (logging, file I/O). +- **Lifecycle**: Use `onSettled` for setup and cleanup. Keep state synchronization in event handlers or store actions; do not use rendered `createEffect`/`createRenderEffect` in application code. ### TUI Constraints @@ -114,7 +114,7 @@ Enforced by `.oxlintrc.json`, `.oxfmt.json`, and `tsconfig.json`. ## 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. +- **Error Boundaries**: Wrap major sections in `Errored` to prevent crash-to-shell. - **Debug Mode**: Use `just debug` to enable verbose logging to file. --- diff --git a/bun.lock b/bun.lock index 424be3d..ceecb93 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,11 @@ "": { "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.11", + "@opentui/solid": "0.5.11", + "effect": "4.0.0-rc.116", "node-id3": "^0.2.9", - "solid-js": "^1.9.15", + "solid-js": "2.0.0-rc.9", }, "devDependencies": { "@effect/language-service": "^0.87.2", @@ -19,14 +18,17 @@ "oxlint": "^1.82.0", }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "^0.5.11", - "@opentui/core-darwin-x64": "^0.5.11", + "@opentui/core-darwin-arm64": "0.5.11", + "@opentui/core-darwin-x64": "0.5.11", }, "peerDependencies": { "typescript": "^6.0.3", }, }, }, + "patchedDependencies": { + "@opentui/solid@0.5.11": "patches/@opentui%2Fsolid@0.5.11.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=="], @@ -88,8 +90,6 @@ "@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=="], - "@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=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -194,7 +194,7 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.9", "", {}, "sha512-o3pqiTgpH5NR2DstiKrt9s/6+0YOFtv+MfvLONwLsS247I+EWMMyTu9BkRcgd35UR5Pa1DM16lI1/5uaIMY6Gw=="], "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], @@ -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.0-rc.116", "", {}, "sha512-nawqJHSjHV8XIBRZNZ+D7cLZpN3kkSjzy6aiT9ofKENsl7xAMFKEoDa0itN5JFl3GUQ1PIN1T1HNlva5+xyO/A=="], "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=="], @@ -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.9", "", { "dependencies": { "@solidjs/signals": "^2.0.0-rc.9", "csstype": "^3.1.0", "seroval": "~1.6.7", "seroval-plugins": "~1.6.7" } }, "sha512-J/oHWnWqe7S0FeIEdIRKDvyyo+HY/TYKr2PrIB8VlePMWuErDg78QHqdsAV7f6HKa9qhWR/23eqzR/ZRV9ep0g=="], "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=="], diff --git a/package.json b/package.json index 4ba06c6..48874c6 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,11 @@ "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.11", + "@opentui/solid": "0.5.11", + "effect": "4.0.0-rc.116", "node-id3": "^0.2.9", - "solid-js": "^1.9.15" + "solid-js": "2.0.0-rc.9" }, "devDependencies": { "@effect/language-service": "^0.87.2", @@ -31,7 +30,10 @@ "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.11", + "@opentui/core-darwin-x64": "0.5.11" + }, + "patchedDependencies": { + "@opentui/solid@0.5.11": "patches/@opentui%2Fsolid@0.5.11.patch" } } diff --git a/patches/@opentui%2Fsolid@0.5.11.patch b/patches/@opentui%2Fsolid@0.5.11.patch new file mode 100644 index 0000000..4a57459 --- /dev/null +++ b/patches/@opentui%2Fsolid@0.5.11.patch @@ -0,0 +1,577 @@ +diff --git a/node_modules/@opentui/solid/.bun-tag-25d873c820729c6e b/.bun-tag-25d873c820729c6e +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@opentui/solid/.bun-tag-30ebfe2ac5ae6ce4 b/.bun-tag-30ebfe2ac5ae6ce4 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@opentui/solid/.bun-tag-37814309257904d3 b/.bun-tag-37814309257904d3 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@opentui/solid/.bun-tag-85b331d416031a66 b/.bun-tag-85b331d416031a66 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@opentui/solid/.bun-tag-ca17d67b79a1755d b/.bun-tag-ca17d67b79a1755d +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@opentui/solid/.bun-tag-ea6c0dbad8574b0b b/.bun-tag-ea6c0dbad8574b0b +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@opentui/solid/.bun-tag-fd9507e5a5b36088 b/.bun-tag-fd9507e5a5b36088 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/index.bun.js b/index.bun.js +index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a73e72539f 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,21 +872,25 @@ 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)); +- }, undefined, { render: true }); ++ 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 ++ }); ++ insert(renderRoot, content); ++ el.add(container); ++ props.ref && props.ref(container); ++ return () => el.remove(container); ++ }, ++ { render: true } ++ ); + return marker; + } + function createDynamic(component, props) { +@@ -1303,7 +1336,7 @@ function createScrollbackWriter(node, options = {}) { + dispose?.(); + }; + try { +- dispose = _render(() => createComponent2(RendererContext.Provider, { ++ dispose = _render(() => createComponent2(RendererContext, { + get value() { + return snapshotRenderer.renderer; + }, +@@ -1344,7 +1377,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); +@@ -1360,7 +1393,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); +@@ -1446,14 +1479,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); + }, +@@ -1540,7 +1573,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 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d9f870a58 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,23 +849,25 @@ 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)); +- }, undefined, { +- render: true +- }); ++ 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 ++ }); ++ insert(renderRoot, content); ++ el.add(container); ++ props.ref && props.ref(container); ++ return () => el.remove(container); ++ }, ++ { render: true } ++ ); + return marker; + } + function createDynamic(component, props) { +@@ -1277,7 +1308,7 @@ function createScrollbackWriter(node, options = {}) { + dispose?.(); + }; + try { +- dispose = _render(() => createComponent2(RendererContext.Provider, { ++ dispose = _render(() => createComponent2(RendererContext, { + get value() { + return snapshotRenderer.renderer; + }, +@@ -1318,7 +1349,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); +@@ -1334,7 +1365,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); +@@ -1420,14 +1451,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); + }, +@@ -1514,7 +1545,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..5beb9d6 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); } }); }); @@ -876,9 +876,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__/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__/sync.test.ts b/src/__tests__/sync.test.ts index e0db1e0..0687787 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"; @@ -398,6 +407,29 @@ 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 +470,7 @@ describe("SyncEngine", () => { }); const result = await Effect.runPromise( - Effect.either( + Effect.result( Effect.provide( program, Layer.mergeAll(createSyncEngineTest(), EpisodeMatcherLive, createFileSystemTest()), @@ -446,7 +478,7 @@ describe("SyncEngine", () => { ), ); - expect(result._tag).toBe("Left"); + expect(result._tag).toBe("Failure"); }); }); 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/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..b1f53d6 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 { onSettled } from "solid-js"; import { DriveDetection, DriveDetectionLive, @@ -80,8 +80,8 @@ type AppRequirements = * Hook containing the core application logic, orchestrating various Effect services. */ export const useAppLogic = () => { - let driveListenerFiber: Fiber.RuntimeFiber | undefined; - let activeSyncFiber: Fiber.RuntimeFiber | undefined; + let driveListenerFiber: Fiber.Fiber | undefined; + let activeSyncFiber: Fiber.Fiber | undefined; /** * Runs an effect to completion using the AppLayer. @@ -131,7 +131,7 @@ export const useAppLogic = () => { const updated = markEpisodesOnDrive(state.macPodcasts, episodes); actions.setMacPodcasts(updated); }).pipe( - Effect.catchAll((err) => { + Effect.catch((err) => { const errorMessage = err instanceof Error ? err.message : String(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); @@ -180,7 +180,7 @@ export const useAppLogic = () => { } } }).pipe( - Effect.catchAll((err) => { + Effect.catch((err) => { const errorMessage = err instanceof Error ? err.message : String(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); @@ -240,7 +240,7 @@ export const useAppLogic = () => { }), ); }).pipe( - Effect.catchAll((err) => { + Effect.catch((err) => { const errorMessage = err instanceof Error ? err.message : String(err); actions.addDebugMessage(`Drive listener error: ${errorMessage}`, "error"); return Effect.void; @@ -263,7 +263,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 +283,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 +303,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,7 +329,7 @@ export const useAppLogic = () => { actions.setMacPodcasts(episodes); } }).pipe( - Effect.catchAll((err) => { + Effect.catch((err) => { const errorMessage = err instanceof Error ? err.message : String(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); @@ -354,7 +354,7 @@ export const useAppLogic = () => { const episodes = yield* podcastService.loadMacPodcasts; actions.setMacPodcasts(episodes); }).pipe( - Effect.catchAll((err) => { + Effect.catch((err) => { actions.setErrorMsg(err instanceof Error ? err.message : String(err)); return Effect.void; }), @@ -449,7 +449,7 @@ export const useAppLogic = () => { 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); @@ -464,7 +464,7 @@ export const useAppLogic = () => { } } else { const cause = exit.cause; - if (Cause.isInterruptedOnly(cause)) { + if (Cause.hasInterruptsOnly(cause)) { yield* logger.info("Sync cancelled by user"); actions.setErrorMsg(""); } else { @@ -495,7 +495,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 +506,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 +534,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..4a54d43 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"; @@ -11,6 +11,7 @@ import { useAppKeyboard } from "@/hooks/useAppKeyboard"; import { 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"; @@ -128,9 +129,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 +140,7 @@ const App = () => { { const Root = () => { const terminalDimensions = useTerminalDimensions(); return ( - ( - - Fatal Error Occurred: - - {err.message} + { + const error = err(); + return ( + + Fatal Error Occurred: + + {error instanceof Error ? error.message : String(error)} + + + Press Ctrl+C to exit. + - - 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..728a16a 100644 --- a/src/services/effects/DriveScan.ts +++ b/src/services/effects/DriveScan.ts @@ -12,7 +12,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 +32,7 @@ export class DriveScan extends Context.Tag("DriveScan")< drivePath: string, ) => Effect.Effect; } ->() {} +>()("DriveScan") {} /** * Helper to parse podcast file info from path. @@ -80,7 +80,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 +96,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); @@ -206,7 +206,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 +269,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 +279,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 })))), }), ); 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..9b59dbd 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[]; 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..91e23fa 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), + ); + }), + ), + ); + }), + ), ); /** @@ -303,10 +305,10 @@ export const SyncEngineLive = Layer.effect( Effect.tapError((err) => logger.error(`Failed to tag: ${item.episode.title}`, err), ), - Effect.catchAll(() => Effect.void), + Effect.catch(() => Effect.void), ); }), - ).pipe(Stream.filterMap(() => Option.none())), + ).pipe(Stream.filterMap(() => Result.fail(undefined))), ), ); @@ -351,7 +353,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 +361,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), ); } } @@ -417,17 +419,15 @@ export const createSyncEngineTest = (mockFiles: Map = new Ma 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, + startTime, + status: "syncing", + })), Stream.concat( Stream.succeed({ currentFile: "", diff --git a/src/services/workers/db.worker.ts b/src/services/workers/db.worker.ts index d992a8b..0a2f307 100644 --- a/src/services/workers/db.worker.ts +++ b/src/services/workers/db.worker.ts @@ -10,7 +10,7 @@ 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; diff --git a/src/store/index.ts b/src/store/index.ts index fd9587f..171d7d5 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; @@ -53,6 +54,7 @@ const initialState: AppState = { isScanning: false, transferProgress: { currentFile: "", + lastFile: "", filesDone: 0, totalFiles: 0, bytesTransferred: 0, @@ -68,67 +70,157 @@ 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); + }), + setErrorMsg: (msg: string) => + updateState((draft) => { + draft.errorMsg = msg; + }), 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/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/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))); } } From 1a71568e2f879859bdb316fbda067a9043a1fbf1 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:20:34 -0400 Subject: [PATCH 02/11] chore: formatting --- CHANGELOG.md | 6 ++---- src/__tests__/sync.test.ts | 4 +++- src/utils/drive.ts | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) 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/src/__tests__/sync.test.ts b/src/__tests__/sync.test.ts index 0687787..0158912 100644 --- a/src/__tests__/sync.test.ts +++ b/src/__tests__/sync.test.ts @@ -421,7 +421,9 @@ describe("SyncEngine", () => { yield* engine.copyFileWithProgress(sourcePath, destinationPath, () => {}); }); - const layer = Layer.mergeAll(SyncEngineLive, FileSystemLive).pipe(Layer.provide(LoggerLive)); + 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); 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})`; } From 19c78e1008bdc1074b3121c7306246195ac10b40 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:07:41 -0400 Subject: [PATCH 03/11] refactor: drop dead code, legacy views, and check formatting in CI Remove unused DriveItem component and importer-less components barrel. Trim AppView union to the 7 live views and collapse the footer switch. Remove production-unused isNavigationKey and its tests. Fix dead Header padding ternary. Trim AGENTS.md to evergreen guidance. Add just fmt-check and enforce it in CI. --- .github/workflows/ci.yml | 4 +- AGENTS.md | 157 ++------------------------------- justfile | 4 + src/__tests__/keyboard.test.ts | 31 +------ src/components/DriveItem.tsx | 22 ----- src/components/Header.tsx | 2 +- src/components/index.ts | 12 --- src/types/keyboard.ts | 7 +- src/utils/keyboard.ts | 11 --- 9 files changed, 16 insertions(+), 234 deletions(-) delete mode 100644 src/components/DriveItem.tsx delete mode 100644 src/components/index.ts 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 9c088df..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 2.0.0-rc.9 (this migration branch; stable release is a merge gate) -- **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). -- **Lifecycle**: Use `onSettled` for setup and cleanup. Keep state synchronization in event handlers or store actions; do not use rendered `createEffect`/`createRenderEffect` in application code. - -### 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 `Errored` 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/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/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/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/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/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/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": From 60e92182e184cd8fa6d96be70733cb8427c3ed00 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:54:12 -0400 Subject: [PATCH 04/11] chore: bump deps --- bun.lock | 90 ++++++++++++++++++++++++++-------------------------- package.json | 6 ++-- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/bun.lock b/bun.lock index ceecb93..6322ea5 100644 --- a/bun.lock +++ b/bun.lock @@ -13,9 +13,9 @@ }, "devDependencies": { "@effect/language-service": "^0.87.2", - "@types/bun": "^1.4.1", - "oxfmt": "^0.67.0", - "oxlint": "^1.82.0", + "@types/bun": "^1.4.2", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", @@ -118,85 +118,85 @@ "@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=="], - "@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.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], - "@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.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], - "@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.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], - "@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.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], - "@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.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], - "@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.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], - "@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.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], - "@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.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], - "@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.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], - "@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.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], - "@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.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], - "@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.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], - "@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.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], - "@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.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], - "@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.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], - "@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.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], - "@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.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], - "@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.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], - "@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.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], - "@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.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], - "@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.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], - "@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.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], - "@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.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], - "@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.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], - "@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.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], - "@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.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], - "@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.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], - "@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.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], - "@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.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], - "@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.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], - "@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.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], - "@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.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], - "@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.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], - "@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.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], - "@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.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], - "@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.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], - "@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.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], - "@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.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.9", "", {}, "sha512-o3pqiTgpH5NR2DstiKrt9s/6+0YOFtv+MfvLONwLsS247I+EWMMyTu9BkRcgd35UR5Pa1DM16lI1/5uaIMY6Gw=="], - "@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=="], @@ -286,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.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], - "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.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/package.json b/package.json index 48874c6..664fe9b 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ }, "devDependencies": { "@effect/language-service": "^0.87.2", - "@types/bun": "^1.4.1", - "oxfmt": "^0.67.0", - "oxlint": "^1.82.0" + "@types/bun": "^1.4.2", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0" }, "peerDependencies": { "typescript": "^6.0.3" From 6f2daf0cd2b8f7e85438532bb5dc890b54de5439 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:55:25 -0400 Subject: [PATCH 05/11] refactor: drop unused sync and podcast service types Seven exports were declared but referenced nowhere in the repo: - PaneId (superseded by FocusedPane in types/keyboard.ts) - SyncOptions, SyncEpisode, SyncResult - DriveEpisode, DrivePodcast (a self-contained dead pair; DriveScan declares its own inline shape for buildDriveIndex) - createPodcastServiceTest (siblings createSyncEngineTest, createDriveScanTest, createMetadataEditorTest and createFileSystemTest are all still in use) --- src/services/effects/PodcastService.ts | 10 --------- src/types/sync.ts | 31 -------------------------- 2 files changed, 41 deletions(-) diff --git a/src/services/effects/PodcastService.ts b/src/services/effects/PodcastService.ts index 9b59dbd..9c6214e 100644 --- a/src/services/effects/PodcastService.ts +++ b/src/services/effects/PodcastService.ts @@ -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/types/sync.ts b/src/types/sync.ts index 41f3bb3..7c9bf58 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -11,17 +11,6 @@ export interface SyncProgress { 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 +31,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[]; -} From 0665a259685a36a30648ce91c254df5cd04e1e39 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:56:08 -0400 Subject: [PATCH 06/11] fix: surface sync success, correct file counter, tag podcast author Three independent bugs in the sync path. Sync success messages were silently discarded. startSync returns a message on both success paths ("All episodes already synced" and "Sync complete"), but the caller only read it on the failure branch, so the message was computed and thrown away: sync forty files, the popup closes, and nothing confirms it happened. Rather than push a non-error through the error channel, add a dedicated successMsg field and setSuccessMsg action, rendered on its own line in Colors.text.success. Cleared explicitly at the same points errorMsg is (new sync, refresh, drive selection, cancel), matching the existing no-auto-expiry idiom. The transfer file counter was off by one. The engine emitted a 0-based currentIndex from Stream.zipWithIndex while the popup renders it as a done/total counter, so it read 0/3 while the first file copied. Three signs this is a bug and not a choice: the engine's own log line already used `i + 1`, the test double already emitted `i + 1`, and the counter is display-only. Fixed the producer at all three emit sites so production matches the already-tested contract, rather than adjusting the consumer. Includes the "Tagging:" emit, which carried the same off-by-one. The ID3 artist tag was always blank. ZMTPODCAST.ZAUTHOR was populated all along, but the query never asked for it: db.worker.ts selected only p.ZTITLE, and groupEpisodesByPodcast hard-coded `author: ""`, which reached the tag writer as `artist: ""`. Select p.ZAUTHOR, add author to EpisodeRow and to the required Episode interface, carry it through the row mapping, and read it off the grouped episodes with a fallback so a blank is never written. Also discard a copy whose tag write failed. The old code swallowed the error and left the file on the drive with wrong or missing tags. It is now logged and unlinked, so the next run re-copies and re-tags. This preserves the existing rule that a failed tag never fails the sync. --- src/__tests__/effects.test.ts | 2 + src/__tests__/formatting.test.ts | 42 +++++++ src/__tests__/store.test.ts | 16 +++ src/__tests__/sync.test.ts | 5 + src/__tests__/syncEngineLive.test.ts | 179 +++++++++++++++++++++++++++ src/hooks/useAppLogic.ts | 5 + src/index.tsx | 6 + src/services/effects/DriveScan.ts | 8 +- src/services/effects/SyncEngine.ts | 18 ++- src/services/workers/db.worker.ts | 5 + src/store/index.ts | 6 + src/types/podcast.ts | 5 +- src/utils/driveSelection.ts | 1 + src/utils/formatting.ts | 8 +- 14 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/syncEngineLive.test.ts diff --git a/src/__tests__/effects.test.ts b/src/__tests__/effects.test.ts index 5beb9d6..1d490fa 100644 --- a/src/__tests__/effects.test.ts +++ b/src/__tests__/effects.test.ts @@ -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, 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__/store.test.ts b/src/__tests__/store.test.ts index 03bac6a..ca79476 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -54,6 +54,22 @@ describe("store actions", () => { expect(state.transferProgress.totalFiles).toBe(10); }); + it("should keep successMsg separate from errorMsg", () => { + actions.setSuccessMsg("Sync complete"); + expect(state.successMsg).toBe("Sync complete"); + // A success message must not land in the error channel. + expect(state.errorMsg).toBe(""); + + actions.setErrorMsg("something broke"); + expect(state.errorMsg).toBe("something broke"); + // Setting an error must not clobber the success message. + expect(state.successMsg).toBe("Sync complete"); + + actions.setSuccessMsg(""); + expect(state.successMsg).toBe(""); + actions.setErrorMsg(""); + }); + 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 0158912..af397b9 100644 --- a/src/__tests__/sync.test.ts +++ b/src/__tests__/sync.test.ts @@ -38,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, @@ -47,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 @@ -56,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, @@ -73,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, @@ -368,6 +372,7 @@ describe("SyncEngine", () => { { id: "e1", title: "Ep", + author: "A", duration: 100, published: new Date("2024-01-01"), onDrive: false, diff --git a/src/__tests__/syncEngineLive.test.ts b/src/__tests__/syncEngineLive.test.ts new file mode 100644 index 0000000..08710cc --- /dev/null +++ b/src/__tests__/syncEngineLive.test.ts @@ -0,0 +1,179 @@ +/** + * 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[] = []; + yield* Stream.runForEach(engine.execute(plan, join(directory, "out")), (prog) => + Effect.sync(() => { + seen.push(prog.currentIndex); + }), + ); + return seen; + }); + + const seen = 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); + } 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[] = []; + yield* Stream.runForEach(engine.execute(plan, destDir), (prog) => + Effect.sync(() => { + statuses.push(prog.status); + }), + ); + return { statuses, 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); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index b1f53d6..0b2f934 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -347,6 +347,7 @@ export const useAppLogic = () => { actions.setLoadingMac(true); actions.setLoadingDrive(true); actions.setErrorMsg(""); + actions.setSuccessMsg(""); run( Effect.gen(function* () { @@ -372,6 +373,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"); @@ -459,6 +462,7 @@ export const useAppLogic = () => { if (exit.value.success) { yield* loadDrivePodcastsEffect(drive); actions.setMacPodcasts((prev) => prev.map((ep) => ({ ...ep, selected: false }))); + actions.setSuccessMsg(exit.value.message); } else { actions.setErrorMsg(exit.value.message); } @@ -467,6 +471,7 @@ export const useAppLogic = () => { if (Cause.hasInterruptsOnly(cause)) { yield* logger.info("Sync cancelled by user"); actions.setErrorMsg(""); + actions.setSuccessMsg(""); } else { const err = cause.toString(); yield* logger.error("Sync failed", cause); diff --git a/src/index.tsx b/src/index.tsx index 4a54d43..da13743 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -115,6 +115,12 @@ const App = () => { + + + {state.successMsg} + + + diff --git a/src/services/effects/DriveScan.ts b/src/services/effects/DriveScan.ts index 728a16a..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; @@ -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: [], }); @@ -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/SyncEngine.ts b/src/services/effects/SyncEngine.ts index 91e23fa..523fc68 100644 --- a/src/services/effects/SyncEngine.ts +++ b/src/services/effects/SyncEngine.ts @@ -235,7 +235,7 @@ 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, @@ -258,7 +258,7 @@ export const SyncEngineLive = Layer.effect( bytesWritten += bytes; return { currentFile: item.episode.title, - currentIndex: i, + currentIndex: i + 1, totalFiles: plan.totalFiles, bytesTransferred: bytesWritten, totalBytes: plan.totalBytes, @@ -274,7 +274,7 @@ export const SyncEngineLive = Layer.effect( // Emit a progress update to show we are tagging return { currentFile: `Tagging: ${item.episode.title}`, - currentIndex: i, + currentIndex: i + 1, totalFiles: plan.totalFiles, bytesTransferred: bytesWritten, totalBytes: plan.totalBytes, @@ -288,6 +288,7 @@ export const SyncEngineLive = Layer.effect( 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, { @@ -302,10 +303,17 @@ 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(() => + fs.remove(item.destPath).pipe(Effect.catch(() => Effect.void)), ), - Effect.catch(() => Effect.void), ); }), ).pipe(Stream.filterMap(() => Result.fail(undefined))), diff --git a/src/services/workers/db.worker.ts b/src/services/workers/db.worker.ts index 0a2f307..850dfde 100644 --- a/src/services/workers/db.worker.ts +++ b/src/services/workers/db.worker.ts @@ -6,6 +6,9 @@ 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), @@ -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 171d7d5..6c95d7e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -29,6 +29,7 @@ export interface AppState { isScanning: boolean; transferProgress: TransferProgress; errorMsg: string; + successMsg: string; debugMessages: DebugMessage[]; lastKey: string | null; lastSavedTheme: string; @@ -62,6 +63,7 @@ const initialState: AppState = { speed: 0, }, errorMsg: "", + successMsg: "", debugMessages: [], lastKey: null, lastSavedTheme: "Catppuccin", @@ -151,6 +153,10 @@ export const actions = { updateState((draft) => { draft.errorMsg = msg; }), + setSuccessMsg: (msg: string) => + updateState((draft) => { + draft.successMsg = msg; + }), addDebugMessage: (message: string, type: DebugMessage["type"] = "info") => { updateState((draft) => { draft.debugMessages.push({ timestamp: Date.now(), message, type }); 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/utils/driveSelection.ts b/src/utils/driveSelection.ts index d5b1296..7b6a982 100644 --- a/src/utils/driveSelection.ts +++ b/src/utils/driveSelection.ts @@ -4,6 +4,7 @@ import type { Drive } from "@/types/drive"; export const selectDrive = (drive: Drive, loadDrivePodcasts: (drive: Drive) => void): void => { actions.setCurrentDrive(drive); actions.setErrorMsg(""); + actions.setSuccessMsg(""); 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 From 86465ca48b3c3c8076d470e879238706573e10a2 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:20:58 -0400 Subject: [PATCH 07/11] fix: report discarded files and make the status line exclusive Follows the previous sync fixes, addressing two gaps they left. Make successMsg and errorMsg mutually exclusive in the store actions. The two could previously both be set, so a red error and a green "Sync complete" could render at once: after a successful sync, a hotplug-triggered rescan could fail and set an error without clearing the stale success. There are eight setErrorMsg call sites and any of them could fire in that window, so the invariant now lives in the setters rather than being remembered per call site. Three explicit setSuccessMsg("") calls that paired with setErrorMsg("") are gone as redundant. Surface files discarded because tagging failed. Those are logged and unlinked so the next run re-copies, but the sync still reported a green "Sync complete" with nothing on the drive, which is its own kind of lie. SyncProgress gains a required `discarded` counter, and startSync folds the final count into the message: "Sync complete (2 files discarded: tagging failed)". The terminal progress event uses Stream.suspend rather than Stream.succeed, because the latter builds its value when the stream is assembled, so it captured the count as 0 before any file was tagged. Also log an unlink that fails. createPlan skips any destination that exists, so a leftover half-tagged file is never re-copied; the failure previously left no trace at all. Finally, do not overwrite a failed rescan's error with a success message: loadDrivePodcastsEffect now reports whether it succeeded, and the success is only set when it did. Convert the remaining `as SyncProgress` and `as SyncPlan` casts to checked types, since an `as` assertion permits a missing property and the new field would have compiled while emitting undefined. --- src/__tests__/store.test.ts | 32 +++++++++-- src/__tests__/syncEngineLive.test.ts | 59 ++++++++++++++++++-- src/hooks/useAppLogic.ts | 34 +++++++++--- src/services/effects/SyncEngine.ts | 80 +++++++++++++++++++--------- src/store/index.ts | 5 ++ src/types/sync.ts | 2 + src/utils/driveSelection.ts | 1 - 7 files changed, 171 insertions(+), 42 deletions(-) diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index ca79476..9aab9cf 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -54,20 +54,42 @@ describe("store actions", () => { expect(state.transferProgress.totalFiles).toBe(10); }); - it("should keep successMsg separate from errorMsg", () => { + 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 land in the error channel. + // 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"); - // Setting an error must not clobber the success message. + 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(""); - expect(state.successMsg).toBe(""); - actions.setErrorMsg(""); }); it("should add debug messages", () => { diff --git a/src/__tests__/syncEngineLive.test.ts b/src/__tests__/syncEngineLive.test.ts index 08710cc..8d58d73 100644 --- a/src/__tests__/syncEngineLive.test.ts +++ b/src/__tests__/syncEngineLive.test.ts @@ -73,15 +73,17 @@ describe("SyncEngineLive.execute", () => { 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; + return { seen, discarded }; }); - const seen = await Effect.runPromise( + const { seen, discarded } = await Effect.runPromise( Effect.provide( program, Layer.mergeAll( @@ -98,6 +100,9 @@ describe("SyncEngineLive.execute", () => { 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 }); } @@ -147,12 +152,14 @@ describe("SyncEngineLive.execute", () => { 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, destPath: plan.toCopy[0]?.destPath }; + return { statuses, counts, destPath: plan.toCopy[0]?.destPath }; }); const result = await Effect.runPromise( @@ -172,6 +179,52 @@ describe("SyncEngineLive.execute", () => { // 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/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index 0b2f934..c4cd087 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -116,7 +116,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 +132,17 @@ export const useAppLogic = () => { // Mark Mac podcasts that are on the drive const updated = markEpisodesOnDrive(state.macPodcasts, episodes); actions.setMacPodcasts(updated); + return true; }).pipe( + // 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 = err instanceof Error ? err.message : String(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); actions.setDrivePodcasts([]); - return Effect.void; + return Effect.succeed(false); }), Effect.onExit(() => Effect.sync(() => actions.setLoadingDrive(false))), ); @@ -347,7 +353,6 @@ export const useAppLogic = () => { actions.setLoadingMac(true); actions.setLoadingDrive(true); actions.setErrorMsg(""); - actions.setSuccessMsg(""); run( Effect.gen(function* () { @@ -423,8 +428,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, @@ -446,7 +458,12 @@ export const useAppLogic = () => { actions.updateTransferProgress({ currentFile: "Finalizing drive..." }); yield* syncEngine.cleanup(drive.mountPoint); - return { success: true, message: "Sync complete" }; + const message = + discarded > 0 + ? `Sync complete (${discarded} ${discarded === 1 ? "file" : "files"} discarded: tagging failed)` + : "Sync complete"; + + return { success: true, message }; }); run( @@ -460,9 +477,13 @@ 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 }))); - actions.setSuccessMsg(exit.value.message); + // 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); } @@ -471,7 +492,6 @@ export const useAppLogic = () => { if (Cause.hasInterruptsOnly(cause)) { yield* logger.info("Sync cancelled by user"); actions.setErrorMsg(""); - actions.setSuccessMsg(""); } else { const err = cause.toString(); yield* logger.error("Sync failed", cause); diff --git a/src/services/effects/SyncEngine.ts b/src/services/effects/SyncEngine.ts index 523fc68..ab4dd11 100644 --- a/src/services/effects/SyncEngine.ts +++ b/src/services/effects/SyncEngine.ts @@ -227,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, @@ -239,6 +243,7 @@ export const SyncEngineLive = Layer.effect( totalFiles: plan.totalFiles, bytesTransferred: bytesWritten, totalBytes: plan.totalBytes, + discarded, startTime, status: "syncing", }); @@ -254,7 +259,7 @@ 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, @@ -262,26 +267,26 @@ export const SyncEngineLive = Layer.effect( 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 + 1, - 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( @@ -312,7 +317,22 @@ export const SyncEngineLive = Layer.effect( ), ), Effect.catch(() => - fs.remove(item.destPath).pipe(Effect.catch(() => Effect.void)), + 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), + ); + }), ), ); }), @@ -323,15 +343,21 @@ export const SyncEngineLive = Layer.effect( 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")), ); @@ -421,7 +447,7 @@ 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(); @@ -433,19 +459,21 @@ export const createSyncEngineTest = (mockFiles: Map = new Ma 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/store/index.ts b/src/store/index.ts index 6c95d7e..bd0157e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -149,13 +149,18 @@ export const actions = { } 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") => { updateState((draft) => { diff --git a/src/types/sync.ts b/src/types/sync.ts index 7c9bf58..b63cd0d 100644 --- a/src/types/sync.ts +++ b/src/types/sync.ts @@ -6,6 +6,8 @@ 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; diff --git a/src/utils/driveSelection.ts b/src/utils/driveSelection.ts index 7b6a982..d5b1296 100644 --- a/src/utils/driveSelection.ts +++ b/src/utils/driveSelection.ts @@ -4,7 +4,6 @@ import type { Drive } from "@/types/drive"; export const selectDrive = (drive: Drive, loadDrivePodcasts: (drive: Drive) => void): void => { actions.setCurrentDrive(drive); actions.setErrorMsg(""); - actions.setSuccessMsg(""); loadDrivePodcasts(drive); actions.setAppView("normal"); }; From 8dac6f20e006161aeaeb9dac850ab97bbb3b154e Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:31:43 -0400 Subject: [PATCH 08/11] test: cover the sync success message and status channel useAppLogic had no test file, so the string the whole discarded-files change exists to produce was unverified, as was the mutual exclusion of successMsg and errorMsg along the paths the hook actually takes. Extract syncSuccessMessage so the singular/plural branch can be pinned directly, and cover the hook against the real store: a sync refused for a missing drive sets an error and no success, a stale success does not survive a new sync or a drive selection, and a bare rescan leaves the status alone. Note that loadDrivePodcasts is a rescan, not a selection: only selectDrive clears the status, so the two are asserted separately. --- src/__tests__/useAppLogic.test.ts | 127 ++++++++++++++++++++++++++++++ src/hooks/useAppLogic.ts | 19 +++-- 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/useAppLogic.test.ts diff --git a/src/__tests__/useAppLogic.test.ts b/src/__tests__/useAppLogic.test.ts new file mode 100644 index 0000000..b2c4e61 --- /dev/null +++ b/src/__tests__/useAppLogic.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { syncSuccessMessage, useAppLogic } from "@/hooks/useAppLogic"; +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"); + }); +}); diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index c4cd087..3c3999e 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -76,6 +76,18 @@ type AppRequirements = | MetadataEditor | Logger; +/** + * 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. */ @@ -458,12 +470,7 @@ export const useAppLogic = () => { actions.updateTransferProgress({ currentFile: "Finalizing drive..." }); yield* syncEngine.cleanup(drive.mountPoint); - const message = - discarded > 0 - ? `Sync complete (${discarded} ${discarded === 1 ? "file" : "files"} discarded: tagging failed)` - : "Sync complete"; - - return { success: true, message }; + return { success: true, message: syncSuccessMessage(discarded) }; }); run( From d10e087ef9636c0e6bb43b9d833b6364652069c9 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:00:37 -0400 Subject: [PATCH 09/11] refactor: drop unused barrels and stray rust build output Remove src/theme/index.ts and src/types/index.ts. Neither is imported: every consumer reaches the specific module (@/theme/colors, @/types/drive) rather than the barrel, so both re-exports only ever shadow the real modules with a second import path. Also clear rust/, 6.2G of cargo build output left in the working tree from the gpui port. It held only dist/ and target/ with no Cargo.toml, so it was purely regenerable artifacts, and it was not gitignored, which meant a git add -A would have tried to stage 6.2G. --- src/theme/index.ts | 1 - src/types/index.ts | 4 ---- 2 files changed, 5 deletions(-) delete mode 100644 src/theme/index.ts delete mode 100644 src/types/index.ts 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"; From aabf45ef4077a2a390c0273acade4cb59dfbab8e Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:09:22 -0400 Subject: [PATCH 10/11] fix: never show an empty error, and make the hook testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error rendering used `err instanceof Error ? err.message : String(err)` in six places. That is wrong for Effect: a Data.TaggedError subclass such as DriveScanError IS an instance of Error, but its `message` is empty, so the ternary produced "" — setErrorMsg("") renders nothing at all, and a failed drive scan became a completely invisible error. A new test for the post-sync rescan gate failed against exactly that, with state.errorMsg empty after a scan that had demonstrably failed. describeError prefers a real message, then a string or Error cause, then the tag, and never returns something meaningless: an empty string, a bare class name, "[object Object]" and "null" all become "Unknown error". It unwraps an Effect Cause, so the sync-failure path reads "disk full" instead of "Cause([Fail(SyncError (cause: Error: disk full))])", and names an interruption "Cancelled" rather than dumping the wrapper. useAppLogic now takes an optional service layer, defaulting to the live one, so tests can drive the real control flow. Both run and runFork close over that parameter; runFork previously reached for the module-level AppLayer, which would have silently ignored an injected layer. This is the only way to reach the failure paths, since they need an unplugged cable or a broken database — the live scanDrive returns [] for a missing Podcasts folder and swallows its own list errors, so it never fails on a tempdir. Covered: the rescan gate in both directions, the discarded-files message, the exclusive status channel, and describeError including a hostile constructor name, since it runs inside catch handlers and the Errored boundary where a throw would crash the app. --- src/__tests__/useAppLogic.test.ts | 153 +++++++++++++++++++++++++++++- src/hooks/useAppLogic.ts | 133 ++++++++++++++++++++++---- src/index.tsx | 4 +- 3 files changed, 271 insertions(+), 19 deletions(-) diff --git a/src/__tests__/useAppLogic.test.ts b/src/__tests__/useAppLogic.test.ts index b2c4e61..8e45ed6 100644 --- a/src/__tests__/useAppLogic.test.ts +++ b/src/__tests__/useAppLogic.test.ts @@ -1,5 +1,13 @@ import { beforeEach, describe, expect, it } from "bun:test"; -import { syncSuccessMessage, useAppLogic } from "@/hooks/useAppLogic"; +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"; @@ -125,3 +133,146 @@ describe("status channel exclusivity", () => { 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/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index 3c3999e..2cbf0c9 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -1,5 +1,5 @@ import { dirname } from "node:path"; -import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Option, Stream } from "effect"; import { onSettled } from "solid-js"; import { DriveDetection, @@ -76,6 +76,104 @@ 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. * @@ -90,8 +188,11 @@ export const syncSuccessMessage = (discarded: number): string => /** * 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 = () => { +export const useAppLogic = (layer: Layer.Layer = makeAppLayer()) => { let driveListenerFiber: Fiber.Fiber | undefined; let activeSyncFiber: Fiber.Fiber | undefined; @@ -99,18 +200,16 @@ export const useAppLogic = () => { * 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 @@ -150,7 +249,7 @@ export const useAppLogic = () => { // 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 = err instanceof Error ? err.message : String(err); + const errorMessage = describeError(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); actions.setDrivePodcasts([]); @@ -199,7 +298,7 @@ export const useAppLogic = () => { } }).pipe( Effect.catch((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = describeError(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); actions.setDrives([]); @@ -259,7 +358,7 @@ export const useAppLogic = () => { ); }).pipe( Effect.catch((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = describeError(err); actions.addDebugMessage(`Drive listener error: ${errorMessage}`, "error"); return Effect.void; }), @@ -348,7 +447,7 @@ export const useAppLogic = () => { } }).pipe( Effect.catch((err) => { - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = describeError(err); actions.setErrorMsg(errorMessage); actions.addDebugMessage(errorMessage, "error"); return Effect.void; @@ -373,7 +472,7 @@ export const useAppLogic = () => { actions.setMacPodcasts(episodes); }).pipe( Effect.catch((err) => { - actions.setErrorMsg(err instanceof Error ? err.message : String(err)); + actions.setErrorMsg(describeError(err)); return Effect.void; }), Effect.onExit(() => Effect.sync(() => actions.setLoadingMac(false))), @@ -500,9 +599,11 @@ export const useAppLogic = () => { 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")), diff --git a/src/index.tsx b/src/index.tsx index da13743..32824aa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,7 +8,7 @@ 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"; @@ -195,7 +195,7 @@ const Root = () => { > Fatal Error Occurred: - {error instanceof Error ? error.message : String(error)} + {describeError(error)} Press Ctrl+C to exit. From 0dad5cc6fe668990178df5e276935a2448e9786e Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:15:54 -0400 Subject: [PATCH 11/11] test: assert the ID3 artist on disk, not just the value passed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing syncEngineLive test asserts the artist reaching MetadataEditor, which pins the ZAUTHOR chain only up to that boundary. Nothing checked the file itself, so the one link that produced the original bug — the artist frame node-id3 writes — was untested. Writes a real file, tags it, then reads it back with node-id3 and asserts on the tag that actually landed. Reverting groupEpisodesByPodcast to the old `author: ""` makes this fail with an empty artist on disk, which is what every file podapple had synced before the fix. Also covers a missing and a whitespace-only ZAUTHOR, since the fallback has to guarantee a blank is never written. --- src/__tests__/id3_tagging.test.ts | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/__tests__/id3_tagging.test.ts 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(""); + } + }); +});