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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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(""); + } + }); +}); From d0e6baed5c0b4975cb34169ff1022320da206191 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:32:34 -0400 Subject: [PATCH 12/15] fix(solid): patch OpenTUI 0.5.14 for Solid 2 --- bun.lock | 130 +++++++++--------- package.json | 20 +-- ...11.patch => @opentui%2Fsolid@0.5.14.patch} | 95 +++++++------ 3 files changed, 125 insertions(+), 120 deletions(-) rename patches/{@opentui%2Fsolid@0.5.11.patch => @opentui%2Fsolid@0.5.14.patch} (87%) diff --git a/bun.lock b/bun.lock index 6322ea5..7c99a0e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,21 +5,21 @@ "": { "name": "pod", "dependencies": { - "@opentui/core": "0.5.11", - "@opentui/solid": "0.5.11", - "effect": "4.0.0-rc.116", + "@opentui/core": "0.5.14", + "@opentui/solid": "0.5.14", + "effect": "4.0.1", "node-id3": "^0.2.9", - "solid-js": "2.0.0-rc.9", + "solid-js": "2.0.0-rc.13", }, "devDependencies": { - "@effect/language-service": "^0.87.2", + "@effect/language-service": "^0.87.4", "@types/bun": "^1.4.2", - "oxfmt": "^0.70.0", - "oxlint": "^1.85.0", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0", }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.11", - "@opentui/core-darwin-x64": "0.5.11", + "@opentui/core-darwin-arm64": "0.5.14", + "@opentui/core-darwin-x64": "0.5.14", }, "peerDependencies": { "typescript": "^6.0.3", @@ -27,7 +27,7 @@ }, }, "patchedDependencies": { - "@opentui/solid@0.5.11": "patches/@opentui%2Fsolid@0.5.11.patch", + "@opentui/solid@0.5.14": "patches/@opentui%2Fsolid@0.5.14.patch", }, "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -88,7 +88,7 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@effect/language-service": ["@effect/language-service@0.87.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-CfiSoaVQO8pZgaMZGw5VvMvRGybsJ2LaSDoLYaqiVGvo/YYPYVR2GzUKY0h1NtIweq/+SQ5XLrvtzPIttWQ0GQ=="], + "@effect/language-service": ["@effect/language-service@0.87.4", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-AfFn4K45l84LtbQSwoPWc06nrg7OzfV3uJOnIUqimSOWTCIsY1IKBXZfjSsVTq7jDuL9Jtoz8//bZp9+rkEihg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -98,103 +98,103 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="], + "@opentui/core": ["@opentui/core@0.5.14", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.14", "@opentui/core-darwin-x64": "0.5.14", "@opentui/core-linux-arm64": "0.5.14", "@opentui/core-linux-arm64-musl": "0.5.14", "@opentui/core-linux-x64": "0.5.14", "@opentui/core-linux-x64-musl": "0.5.14", "@opentui/core-win32-arm64": "0.5.14", "@opentui/core-win32-x64": "0.5.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w=="], - "@opentui/solid": ["@opentui/solid@0.5.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-u8RJ4UMwzi+r9M1sopZdnT+7XFSHmLzVmhPhc6N2LfBrA9Db4lZ9sQA+ywgdSDkzdANpAOwGLNUQlY/F1OOCLA=="], + "@opentui/solid": ["@opentui/solid@0.5.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-bBRl34mZ0wFGhjHX6y1VNiZmJ3DSm2DVPm0PNSVDpvq9qxRHuywEPTCn/Lgte0C9450tgf11cDqcE3pR9cZwVA=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.72.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.72.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.72.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.72.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.72.0", "", { "os": "linux", "cpu": "arm" }, "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.72.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.72.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.72.0", "", { "os": "linux", "cpu": "none" }, "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.72.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.72.0", "", { "os": "linux", "cpu": "x64" }, "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.72.0", "", { "os": "none", "cpu": "arm64" }, "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.72.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.72.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.72.0", "", { "os": "win32", "cpu": "x64" }, "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.87.0", "", { "os": "android", "cpu": "arm" }, "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.87.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.87.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.87.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.87.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.87.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.87.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.87.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.87.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.87.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.87.0", "", { "os": "linux", "cpu": "none" }, "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.87.0", "", { "os": "linux", "cpu": "none" }, "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.87.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.87.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.87.0", "", { "os": "linux", "cpu": "x64" }, "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.87.0", "", { "os": "none", "cpu": "arm64" }, "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.87.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.87.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.87.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw=="], - "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.9", "", {}, "sha512-o3pqiTgpH5NR2DstiKrt9s/6+0YOFtv+MfvLONwLsS247I+EWMMyTu9BkRcgd35UR5Pa1DM16lI1/5uaIMY6Gw=="], + "@solidjs/signals": ["@solidjs/signals@2.0.0-rc.13", "", {}, "sha512-4+pRdrAHtfyE9BUJWBup3TpzJojJjgW2mV1vm/Jik4tWa5epxXB/YrLkwqP1v8+S9XjyKKZu5BSLqmcpswMYeQ=="], "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], @@ -230,7 +230,7 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "effect": ["effect@4.0.0-rc.116", "", {}, "sha512-nawqJHSjHV8XIBRZNZ+D7cLZpN3kkSjzy6aiT9ofKENsl7xAMFKEoDa0itN5JFl3GUQ1PIN1T1HNlva5+xyO/A=="], + "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], "electron-to-chromium": ["electron-to-chromium@1.5.413", "", {}, "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw=="], @@ -286,9 +286,9 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], - "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=="], + "oxfmt": ["oxfmt@0.72.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.72.0", "@oxfmt/binding-android-arm64": "0.72.0", "@oxfmt/binding-darwin-arm64": "0.72.0", "@oxfmt/binding-darwin-x64": "0.72.0", "@oxfmt/binding-freebsd-x64": "0.72.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", "@oxfmt/binding-linux-arm64-gnu": "0.72.0", "@oxfmt/binding-linux-arm64-musl": "0.72.0", "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-musl": "0.72.0", "@oxfmt/binding-linux-s390x-gnu": "0.72.0", "@oxfmt/binding-linux-x64-gnu": "0.72.0", "@oxfmt/binding-linux-x64-musl": "0.72.0", "@oxfmt/binding-openharmony-arm64": "0.72.0", "@oxfmt/binding-win32-arm64-msvc": "0.72.0", "@oxfmt/binding-win32-ia32-msvc": "0.72.0", "@oxfmt/binding-win32-x64-msvc": "0.72.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g=="], - "oxlint": ["oxlint@1.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=="], + "oxlint": ["oxlint@1.87.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.87.0", "@oxlint/binding-android-arm64": "1.87.0", "@oxlint/binding-darwin-arm64": "1.87.0", "@oxlint/binding-darwin-x64": "1.87.0", "@oxlint/binding-freebsd-x64": "1.87.0", "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", "@oxlint/binding-linux-arm-musleabihf": "1.87.0", "@oxlint/binding-linux-arm64-gnu": "1.87.0", "@oxlint/binding-linux-arm64-musl": "1.87.0", "@oxlint/binding-linux-ppc64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-musl": "1.87.0", "@oxlint/binding-linux-s390x-gnu": "1.87.0", "@oxlint/binding-linux-x64-gnu": "1.87.0", "@oxlint/binding-linux-x64-musl": "1.87.0", "@oxlint/binding-openharmony-arm64": "1.87.0", "@oxlint/binding-win32-arm64-msvc": "1.87.0", "@oxlint/binding-win32-ia32-msvc": "1.87.0", "@oxlint/binding-win32-x64-msvc": "1.87.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -322,7 +322,7 @@ "seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], - "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=="], + "solid-js": ["solid-js@2.0.0-rc.13", "", { "dependencies": { "@solidjs/signals": "^2.0.0-rc.13", "csstype": "^3.1.0", "seroval": "~1.6.7", "seroval-plugins": "~1.6.7" } }, "sha512-62bYOI4JZ15KOqL5eReKyWSwAXrGb0fbX8SDnHsbJk2UX+SzSyy1gobas2cxW/0BXcobGfQ1V6ffPyQkMIBdoQ=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -330,7 +330,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/package.json b/package.json index 664fe9b..cb4328a 100644 --- a/package.json +++ b/package.json @@ -14,26 +14,26 @@ "build": "bun scripts/build.ts" }, "dependencies": { - "@opentui/core": "0.5.11", - "@opentui/solid": "0.5.11", - "effect": "4.0.0-rc.116", + "@opentui/core": "0.5.14", + "@opentui/solid": "0.5.14", + "effect": "4.0.1", "node-id3": "^0.2.9", - "solid-js": "2.0.0-rc.9" + "solid-js": "2.0.0-rc.13" }, "devDependencies": { - "@effect/language-service": "^0.87.2", + "@effect/language-service": "^0.87.4", "@types/bun": "^1.4.2", - "oxfmt": "^0.70.0", - "oxlint": "^1.85.0" + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0" }, "peerDependencies": { "typescript": "^6.0.3" }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.11", - "@opentui/core-darwin-x64": "0.5.11" + "@opentui/core-darwin-arm64": "0.5.14", + "@opentui/core-darwin-x64": "0.5.14" }, "patchedDependencies": { - "@opentui/solid@0.5.11": "patches/@opentui%2Fsolid@0.5.11.patch" + "@opentui/solid@0.5.14": "patches/@opentui%2Fsolid@0.5.14.patch" } } diff --git a/patches/@opentui%2Fsolid@0.5.11.patch b/patches/@opentui%2Fsolid@0.5.14.patch similarity index 87% rename from patches/@opentui%2Fsolid@0.5.11.patch rename to patches/@opentui%2Fsolid@0.5.14.patch index 4a57459..9af68c0 100644 --- a/patches/@opentui%2Fsolid@0.5.11.patch +++ b/patches/@opentui%2Fsolid@0.5.14.patch @@ -1,26 +1,5 @@ -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 +index a68be404614cb4811e16e439325b9f46e39653ef..c54b5407889b9ac581aa94b2041a0f1f6c8a4784 100644 --- a/index.bun.js +++ b/index.bun.js @@ -117,7 +117,7 @@ import { @@ -202,7 +181,7 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 return renderer; } -@@ -843,21 +872,25 @@ function Portal(props) { +@@ -843,28 +872,32 @@ function Portal(props) { const renderer = useRenderer(); const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner(); let content; @@ -219,8 +198,12 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 - insert(renderRoot, content); - el.add(container); - props.ref && props.ref(container); -- onCleanup3(() => el.remove(container)); -- }, undefined, { render: true }); +- onCleanup3(() => { +- el.remove(container); +- process.nextTick(() => { +- if (!container.parent) { +- container.destroyRecursively(); +- } + createRenderEffect2( + () => props.children, + () => { @@ -232,18 +215,27 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 + return marker.parent; + }, + configurable: true -+ }); + }); +- }); +- }, undefined, { render: true }); + insert(renderRoot, content); + el.add(container); + props.ref && props.ref(container); -+ return () => el.remove(container); ++ return () => { ++ el.remove(container); ++ process.nextTick(() => { ++ if (!container.parent) { ++ container.destroyRecursively(); ++ } ++ }); ++ }; + }, + { render: true } + ); return marker; } function createDynamic(component, props) { -@@ -1303,7 +1336,7 @@ function createScrollbackWriter(node, options = {}) { +@@ -1310,7 +1343,7 @@ function createScrollbackWriter(node, options = {}) { dispose?.(); }; try { @@ -252,7 +244,7 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 get value() { return snapshotRenderer.renderer; }, -@@ -1344,7 +1377,7 @@ var TimeToFirstDraw = (props) => { +@@ -1351,7 +1384,7 @@ var TimeToFirstDraw = (props) => { }; // src/plugins/slot.tsx import { createSlotRegistry } from "@opentui/core"; @@ -261,7 +253,7 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 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 = {}) { +@@ -1367,7 +1400,7 @@ function createSlot(registry, options = {}) { }; } function Slot(props) { @@ -270,7 +262,7 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 const registry = () => local.registry; const pluginFailurePlaceholder = () => local.pluginFailurePlaceholder; const [version, setVersion] = createSignal3(0); -@@ -1446,14 +1479,14 @@ function Slot(props) { +@@ -1453,14 +1486,14 @@ function Slot(props) { if (!hasInitialOutput) { return resolveFallback(fallbackOnError); } @@ -287,7 +279,7 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 }); return renderPluginFailurePlaceholder(failure, fallbackOnError); }, -@@ -1540,7 +1573,7 @@ var mountSolidRoot = (renderer, node) => { +@@ -1547,7 +1580,7 @@ var mountSolidRoot = (renderer, node) => { originalDestroy(); }; try { @@ -297,7 +289,7 @@ index 5e281cd6e6f3cbb7ffdff0464413d6ab204363a5..c386c9cb4e31c170dff1d1b19e9601a7 return renderer; }, diff --git a/index.js b/index.js -index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d9f870a58 100644 +index 61e64c1a01e23da4243db0a45abef2ebddd246f0..4d28aa3f93115b4a68143033d804ca2d13b8db9a 100644 --- a/index.js +++ b/index.js @@ -100,7 +100,7 @@ function getComponentCatalogue() { @@ -479,7 +471,7 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d return renderer; } -@@ -820,23 +849,25 @@ function Portal(props) { +@@ -820,30 +849,32 @@ function Portal(props) { const renderer = useRenderer(); const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner(); let content; @@ -496,10 +488,12 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d - insert(renderRoot, content); - el.add(container); - props.ref && props.ref(container); -- onCleanup3(() => el.remove(container)); -- }, undefined, { -- render: true -- }); +- onCleanup3(() => { +- el.remove(container); +- process.nextTick(() => { +- if (!container.parent) { +- container.destroyRecursively(); +- } + createRenderEffect2( + () => props.children, + () => { @@ -511,18 +505,29 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d + return marker.parent; + }, + configurable: true -+ }); + }); +- }); +- }, undefined, { +- render: true +- }); + insert(renderRoot, content); + el.add(container); + props.ref && props.ref(container); -+ return () => el.remove(container); ++ return () => { ++ el.remove(container); ++ process.nextTick(() => { ++ if (!container.parent) { ++ container.destroyRecursively(); ++ } ++ }); ++ }; + }, + { render: true } + ); return marker; } function createDynamic(component, props) { -@@ -1277,7 +1308,7 @@ function createScrollbackWriter(node, options = {}) { +@@ -1284,7 +1315,7 @@ function createScrollbackWriter(node, options = {}) { dispose?.(); }; try { @@ -531,7 +536,7 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d get value() { return snapshotRenderer.renderer; }, -@@ -1318,7 +1349,7 @@ var TimeToFirstDraw = (props) => { +@@ -1325,7 +1356,7 @@ var TimeToFirstDraw = (props) => { }; // src/plugins/slot.tsx import { createSlotRegistry } from "@opentui/core"; @@ -540,7 +545,7 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d 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 = {}) { +@@ -1341,7 +1372,7 @@ function createSlot(registry, options = {}) { }; } function Slot(props) { @@ -549,7 +554,7 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d const registry = () => local.registry; const pluginFailurePlaceholder = () => local.pluginFailurePlaceholder; const [version, setVersion] = createSignal3(0); -@@ -1420,14 +1451,14 @@ function Slot(props) { +@@ -1427,14 +1458,14 @@ function Slot(props) { if (!hasInitialOutput) { return resolveFallback(fallbackOnError); } @@ -566,7 +571,7 @@ index 8fc2a09ecb51194fdf758551c6bd8d518c11c452..191c3b4c48128d2a7904a2c6f899e53d }); return renderPluginFailurePlaceholder(failure, fallbackOnError); }, -@@ -1514,7 +1545,7 @@ var mountSolidRoot = (renderer, node) => { +@@ -1521,7 +1552,7 @@ var mountSolidRoot = (renderer, node) => { originalDestroy(); }; try { From 24b14f1d7280c4df13568e160ea67c825d325581 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:42:22 -0400 Subject: [PATCH 13/15] fix(store): make resetState actually reset The store and resetState shared one initialState object, so draft mutations (debug messages, transfer progress, favorites) wrote into it and a reset restored the mutated values. Build a fresh state each time. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/store.test.ts | 24 ++++++++++++++++++++++++ src/store/index.ts | 8 ++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index 9aab9cf..a471c7e 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -98,4 +98,28 @@ describe("store actions", () => { expect(state.debugMessages.length).toBe(1); expect(state.debugMessages[0]?.message).toBe("Test message"); }); + + it("should fully reset state without mutating the initial state", () => { + actions.resetState(); + actions.addDebugMessage("x"); + actions.updateTransferProgress({ filesDone: 3 }); + actions.toggleFavoriteDrive("d"); + expect(state.debugMessages.length).toBe(1); + expect(state.transferProgress.filesDone).toBe(3); + expect(state.favoriteDrives).toEqual(["d"]); + + actions.resetState(); + expect(state.debugMessages.length).toBe(0); + expect(state.transferProgress.filesDone).toBe(0); + expect(state.favoriteDrives).toEqual([]); + + // A second round proves each reset hands out fresh objects. + actions.addDebugMessage("y"); + actions.updateTransferProgress({ filesDone: 5 }); + actions.toggleFavoriteDrive("e"); + actions.resetState(); + expect(state.debugMessages.length).toBe(0); + expect(state.transferProgress.filesDone).toBe(0); + expect(state.favoriteDrives).toEqual([]); + }); }); diff --git a/src/store/index.ts b/src/store/index.ts index bd0157e..15299fd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -38,7 +38,7 @@ export interface AppState { favoriteDrives: string[]; } -const initialState: AppState = { +const createInitialState = (): AppState => ({ appView: "normal", focusedPane: "mac", macIndex: 0, @@ -68,9 +68,9 @@ const initialState: AppState = { lastKey: null, lastSavedTheme: "Catppuccin", favoriteDrives: [], -}; +}); -export const [state, setState] = createStore(initialState); +export const [state, setState] = createStore(createInitialState()); const updateState = (update: (draft: AppState) => void): void => { setState(update); @@ -232,6 +232,6 @@ export const actions = { }, resetState: () => updateState((draft) => { - Object.assign(draft, initialState); + Object.assign(draft, createInitialState()); }), }; From 3b15b32da55a5de327cde435acfa3150bdf58ea6 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:55:18 -0400 Subject: [PATCH 14/15] fix: address app-wide bug review findings Sync and drive: - Copy to .part and rename once copied and tagged, so a partial file is never mistaken for a synced one - Tag only MP3s (ID3 corrupted .m4a), merge tags instead of replacing - Skip missing/empty sources, dedupe colliding destination paths - Cleanup now sees show directories (readDir returned files only) - Refuse a sync that exceeds the drive's free space - Don't drop a VolumeMount debounced by a pre-mount DiskAppeared App state: - Clamp list indices when lists shrink; no -1 menu index - Ignore stale/superseded drive loads; clear the list on drive switch - Clear on-drive markers on unplug or failed load - Rescan and clean up after a cancelled sync; block concurrent syncs - Ctrl+letter no longer triggers plain-letter actions - Fix NaN speed and off-by-one file count UI: - Popup "esc" click no longer clears the pane selection - Debug log rows are clickable; lists scroll to keep the selection visible Misc: settings saved atomically and never overwritten when unparseable, plist entity/real/self-closing parsing, safer sanitizeFilename, DB closed on error, worker spawn failure handled. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/drive_selection.test.ts | 12 + src/__tests__/formatting.test.ts | 35 + src/__tests__/keyboard_integration.test.ts | 49 ++ src/__tests__/plist.test.ts | 76 +++ src/__tests__/render_suites.test.ts | 24 + src/__tests__/services_regressions.test.ts | 704 +++++++++++++++++++++ src/__tests__/store.test.ts | 33 + src/__tests__/useAppLogic.test.ts | 481 +++++++++++++- src/components/DebugPopup.tsx | 3 +- src/components/EpisodeItem.tsx | 4 + src/components/EpisodeList.tsx | 5 + src/components/Selector.tsx | 13 +- src/components/scrollFollow.ts | 39 ++ src/hooks/useAppKeyboard.ts | 12 +- src/hooks/useAppLogic.ts | 150 ++++- src/render_tests/episode_list.test.tsx | 79 +++ src/render_tests/selector.test.tsx | 66 ++ src/services/effects/DriveDetection.ts | 99 ++- src/services/effects/FileSystem.ts | 49 +- src/services/effects/MetadataEditor.ts | 6 +- src/services/effects/PodcastService.ts | 12 +- src/services/effects/SettingsService.ts | 72 ++- src/services/effects/SyncEngine.ts | 264 +++++--- src/services/workers/db.worker.ts | 10 +- src/store/index.ts | 12 +- src/utils/driveSelection.ts | 2 + src/utils/formatting.ts | 24 +- src/utils/plist.ts | 53 +- 28 files changed, 2170 insertions(+), 218 deletions(-) create mode 100644 src/__tests__/render_suites.test.ts create mode 100644 src/__tests__/services_regressions.test.ts create mode 100644 src/components/scrollFollow.ts create mode 100644 src/render_tests/episode_list.test.tsx create mode 100644 src/render_tests/selector.test.tsx diff --git a/src/__tests__/drive_selection.test.ts b/src/__tests__/drive_selection.test.ts index 829bccf..83439d7 100644 --- a/src/__tests__/drive_selection.test.ts +++ b/src/__tests__/drive_selection.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import { actions, state } from "@/store"; import type { Drive } from "@/types/drive"; +import type { PodcastEpisode } from "@/types/podcast"; import { selectDrive } from "@/utils/driveSelection"; describe("drive selection", () => { @@ -19,4 +20,15 @@ describe("drive selection", () => { expect(state.appView).toBe("normal"); expect(loadDrivePodcasts).toHaveBeenCalledWith(drive); }); + + it("clears the previous drive's listing immediately", () => { + const drive = { id: "drive-2", name: "Drive 2", mountPoint: "/Volumes/Drive2" } as Drive; + actions.setDrivePodcasts([{ id: "old", selected: false } as PodcastEpisode]); + actions.setDriveIndex(0); + + // The loader is a no-op, so anything left in drivePodcasts is stale. + selectDrive(drive, () => {}); + + expect(state.drivePodcasts).toEqual([]); + }); }); diff --git a/src/__tests__/formatting.test.ts b/src/__tests__/formatting.test.ts index c06b344..73ef194 100644 --- a/src/__tests__/formatting.test.ts +++ b/src/__tests__/formatting.test.ts @@ -81,6 +81,41 @@ describe("Formatting Utils", () => { expect(sanitizeFilename("")).toBe("untitled"); expect(sanitizeFilename(" ")).toBe("untitled"); }); + + test("never returns a directory reference", () => { + for (const name of [".", "..", "...", " .. "]) { + const out = sanitizeFilename(name); + expect(out).not.toMatch(/^\.+$/); + expect(out.length).toBeGreaterThan(0); + } + expect(sanitizeFilename(".")).toBe("_"); + expect(sanitizeFilename("..")).toBe("_"); + // Dots inside a real name are untouched. + expect(sanitizeFilename("Wait...")).toBe("Wait..."); + }); + + test("does not split a surrogate pair when truncating", () => { + // 99 ASCII chars then an emoji (2 UTF-16 units): a unit-based slice(0, 100) + // would keep only the high surrogate. + const out = sanitizeFilename(`${"a".repeat(99)}😀tail`); + expect(out).toBe(`${"a".repeat(99)}😀`); + expect(Array.from(out).length).toBe(100); + expect(out.isWellFormed()).toBe(true); + }); + + test("truncates by code point, not UTF-16 unit", () => { + const out = sanitizeFilename("😀".repeat(150)); + expect(Array.from(out).length).toBe(100); + expect(out.isWellFormed()).toBe(true); + }); + + test("NFC-normalizes so HFS+ (NFD) and NFC names produce the same key", () => { + const nfc = "Caf\u00e9"; + const nfd = "Cafe\u0301"; + expect(nfc).not.toBe(nfd); + expect(sanitizeFilename(nfd)).toBe(sanitizeFilename(nfc)); + expect(sanitizeFilename(nfd)).toBe(nfc); + }); }); describe("cleanFileUrl", () => { diff --git a/src/__tests__/keyboard_integration.test.ts b/src/__tests__/keyboard_integration.test.ts index dfd4fd5..27eb4c8 100644 --- a/src/__tests__/keyboard_integration.test.ts +++ b/src/__tests__/keyboard_integration.test.ts @@ -158,4 +158,53 @@ describe("Keyboard Integration", () => { pressKey("y"); expect(mockLogic.deleteSelectedFromDrive).toHaveBeenCalled(); }); + + it("keeps the drive menu index at 0 when there are no drives", () => { + actions.setDrives([]); + actions.setAppView("driveSelection"); + + pressKey("j"); + expect(state.driveMenuIndex).toBe(0); + pressKey("down"); + expect(state.driveMenuIndex).toBe(0); + }); + + it("keeps the debug menu index at 0 when there are no messages", () => { + actions.clearDebugMessages(); + actions.setAppView("debug"); + + pressKey("j"); + expect(state.debugMenuIndex).toBe(0); + }); + + it("does not fire bare-letter actions when ctrl is held", () => { + actions.setMacPodcasts([{ id: "1", selected: true } as PodcastEpisode]); + actions.setMacIndex(0); + actions.setFocusedPane("mac"); + + pressKey("s", { ctrl: true }); + pressKey("r", { ctrl: true }); + pressKey("f", { ctrl: true }); + pressKey("a", { ctrl: true }); + pressKey("l", { ctrl: true }); + + expect(mockLogic.startSync).not.toHaveBeenCalled(); + expect(mockLogic.refreshData).not.toHaveBeenCalled(); + expect(mockLogic.scanForDrives).not.toHaveBeenCalled(); + expect(state.macPodcasts[0]?.selected).toBe(true); + expect(state.focusedPane).toBe("mac"); + expect(state.appView).toBe("normal"); + }); + + it("still handles ctrl+t and the ctrl+u/ctrl+d page remaps", () => { + pressKey("t", { ctrl: true }); + expect(state.appView).toBe("themeSelection"); + + actions.setAppView("normal"); + // Remapped to pageup/pagedown with ctrl cleared; must not reach plain-letter actions. + pressKey("d", { ctrl: true }); + pressKey("u", { ctrl: true }); + expect(state.appView).toBe("normal"); + expect(mockLogic.deleteSelectedFromDrive).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/plist.test.ts b/src/__tests__/plist.test.ts index 46eef7d..8a1efce 100644 --- a/src/__tests__/plist.test.ts +++ b/src/__tests__/plist.test.ts @@ -168,4 +168,80 @@ describe("Plist Parser", () => { expect(getPlistBoolean(dict, "Missing")).toBeUndefined(); }); }); + + describe("hardening", () => { + const wrap = (body: string) => `${body}`; + const parse = (body: string) => Result.getOrThrow(parsePlist(wrap(body))) as PlistDict; + + it("does not double-decode entities", () => { + const dict = parse("K&lt; &amp; &#65;"); + expect(dict.K).toBe("< & A"); + }); + + it("decodes the named entities", () => { + const dict = parse( + "Ka <b> & "c" 'd'", + ); + expect(dict.K).toBe("a & \"c\" 'd'"); + }); + + it("decodes decimal and hex numeric entities", () => { + const dict = parse("KAB😀"); + expect(dict.K).toBe("AB\u{1F600}"); + }); + + it("leaves out-of-range numeric entities as written", () => { + const dict = parse("K��"); + expect(dict.K).toBe("��"); + }); + + it("decodes entities at the top level too", () => { + expect(Result.getOrThrow(parsePlist(wrap("&lt;")))).toBe("<"); + }); + + it("parses reals with exponents and signs", () => { + const dict = parse( + "A1.5e3B-2E-2C+4D.5E3.", + ); + expect(dict.A).toBe(1500); + expect(dict.B).toBe(-0.02); + expect(dict.C).toBe(4); + expect(dict.D).toBe(0.5); + expect(dict.E).toBe(3); + }); + + it("parses a top-level real with an exponent", () => { + expect(Result.getOrThrow(parsePlist(wrap("1e2")))).toBe(100); + }); + + it("skips one unparseable value without dropping the remaining keys", () => { + const dict = parse( + "BeforexBad?After7Last", + ); + expect(dict.Before).toBe("x"); + expect("Bad" in dict).toBe(false); + expect(dict.After).toBe(7); + expect(dict.Last).toBe(true); + }); + + it("skips an unparseable final value", () => { + const dict = parse("A1Bad"); + expect(dict).toEqual({ A: 1 }); + }); + + it("parses self-closing strings as empty", () => { + const dict = parse( + "ABC1", + ); + expect(dict.A).toBe(""); + expect(dict.B).toBe(""); + expect(dict.C).toBe(1); + expect(Result.getOrThrow(parsePlist(wrap("")))).toBe(""); + }); + + it("parses self-closing strings inside arrays", () => { + const arr = Result.getOrThrow(parsePlist(wrap("a"))); + expect(arr).toEqual(["", "a"]); + }); + }); }); diff --git a/src/__tests__/render_suites.test.ts b/src/__tests__/render_suites.test.ts new file mode 100644 index 0000000..f890c23 --- /dev/null +++ b/src/__tests__/render_suites.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; + +/** + * The component render tests in src/render_tests need Solid's client build, + * which `bun test` does not wire up from bunfig (it resolves the server build, + * where effects never run and nothing renders), and which breaks the + * store-oriented suites if enabled globally. Run them in a child process with + * the OpenTUI Solid preload so they exercise real rendering. + */ +describe("component render suites", () => { + it("pass under the OpenTUI Solid preload", async () => { + const proc = Bun.spawn( + ["bun", "test", "--preload", "@opentui/solid/preload", "./src/render_tests"], + { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) console.error(stdout + stderr); + expect(code).toBe(0); + }, 60_000); +}); diff --git a/src/__tests__/services_regressions.test.ts b/src/__tests__/services_regressions.test.ts new file mode 100644 index 0000000..1f466d1 --- /dev/null +++ b/src/__tests__/services_regressions.test.ts @@ -0,0 +1,704 @@ +/** + * Regression tests for the effect services: FileSystem.readDir, SyncEngine + * (plan + tagging + cleanup), MetadataEditor merge semantics and the + * DriveDetection debounce. + */ + +import { afterAll, describe, expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; +import { homedir } from "node:os"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Cause, Effect, Exit, Layer, Option, Ref, Result, Stream } from "effect"; +import nodeID3 from "node-id3"; +import { processActivityEvent } from "@/services/effects/DriveDetection"; +import { type EpisodeMatcher, EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher"; +import { createFileSystemTest, FileSystem, FileSystemLive } from "@/services/effects/FileSystem"; +import { Logger, LoggerLive } from "@/services/effects/Logger"; +import { + MetadataEditor, + MetadataEditorLive, + type PodcastMetadata, +} from "@/services/effects/MetadataEditor"; +import { + PodcastError, + PodcastService, + PodcastServiceLive, +} from "@/services/effects/PodcastService"; +import { SettingsService, SettingsServiceLive } from "@/services/effects/SettingsService"; +import { dedupeDestPath, SyncEngine, SyncEngineLive } from "@/services/effects/SyncEngine"; +import type { Drive } from "@/types/drive"; +import type { Podcast } from "@/types/podcast"; + +const root = await mkdtemp(join(tmpdir(), "podapple-svc-")); +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +let counter = 0; +const freshDir = async (): Promise => { + const dir = join(root, `case-${counter++}`); + await mkdir(dir, { recursive: true }); + return dir; +}; + +const recordingEditor = (sink: Array<{ path: string; metadata: PodcastMetadata }>) => + Layer.succeed(MetadataEditor, { + write: (path, metadata) => + Effect.sync(() => { + sink.push({ path, metadata }); + }), + }); + +const syncLayer = (editor: Layer.Layer) => + Layer.mergeAll(SyncEngineLive, EpisodeMatcherLive, FileSystemLive, editor).pipe( + Layer.provide(LoggerLive), + ); + +const episode = (id: string, filePath: string, title = `Episode ${id}`) => ({ + id, + title, + author: "Host", + duration: 60, + published: new Date("2024-01-01T12:00:00Z"), + onDrive: false, + filePath, + fileSize: 0, +}); + +const podcastOf = (episodes: ReturnType[]): Podcast[] => [ + { id: "p", title: "Show", author: "Host", episodeCount: episodes.length, episodes }, +]; + +const runSync = ( + program: Effect.Effect, + editor: Layer.Layer, +) => Effect.runPromise(Effect.provide(program, syncLayer(editor)) as Effect.Effect); + +describe("FileSystem.readDir", () => { + it("returns directories as well as files (and skips dot entries)", async () => { + const dir = await freshDir(); + await mkdir(join(dir, "Show A")); + await Bun.write(join(dir, "loose.mp3"), new Uint8Array([1])); + await Bun.write(join(dir, ".DS_Store"), new Uint8Array([1])); + + const entries = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* FileSystem).readDir(dir); + }), + FileSystemLive.pipe(Layer.provide(LoggerLive)), + ), + ); + expect(entries.sort()).toEqual(["Show A", "loose.mp3"]); + }); +}); + +describe("SyncEngine.cleanup", () => { + it("removes empty show dirs, including ones holding only system hidden files", async () => { + const drive = await freshDir(); + const podcasts = join(drive, "Podcasts"); + await mkdir(join(podcasts, "Empty"), { recursive: true }); + await mkdir(join(podcasts, "OnlyHidden"), { recursive: true }); + await Bun.write(join(podcasts, "OnlyHidden", ".DS_Store"), new Uint8Array([1])); + await Bun.write(join(podcasts, "OnlyHidden", "._ep.mp3"), new Uint8Array([1])); + await mkdir(join(podcasts, "Full"), { recursive: true }); + await Bun.write(join(podcasts, "Full", "a.mp3"), new Uint8Array([1, 2])); + await Bun.write(join(podcasts, "Full", ".DS_Store"), new Uint8Array([1])); + + await runSync( + Effect.gen(function* () { + yield* (yield* SyncEngine).cleanup(drive); + }), + recordingEditor([]), + ); + + expect(await Bun.file(join(podcasts, "Full", "a.mp3")).exists()).toBe(true); + // Hidden files are swept even from a show dir that stays. + expect(await Bun.file(join(podcasts, "Full", ".DS_Store")).exists()).toBe(false); + const fs = await import("node:fs/promises"); + const remaining = await fs.readdir(podcasts); + expect(remaining).toEqual(["Full"]); + }); +}); + +describe("SyncEngine tagging", () => { + it("tags .mp3 (any case) but copies .m4a untouched and not counted as discarded", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const m4a = new Uint8Array([9, 8, 7, 6, 5]); + await Bun.write(join(dir, "a.m4a"), m4a); + await Bun.write(join(dir, "b.MP3"), new Uint8Array([1, 2, 3])); + + const sink: Array<{ path: string; metadata: PodcastMetadata }> = []; + const result = await runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan( + podcastOf([episode("1", join(dir, "a.m4a")), episode("2", join(dir, "b.MP3"))]), + out, + new Map(), + ); + const progress = yield* Stream.runCollect(engine.execute(plan, out)); + return { plan, progress: Array.from(progress) }; + }), + recordingEditor(sink), + ); + + expect(sink).toHaveLength(1); + // Tagging happens on the .part, which is renamed into place afterwards. + expect(sink[0]?.path.endsWith(".MP3.part")).toBe(true); + expect(await Bun.file(sink[0]?.path as string).exists()).toBe(false); + const m4aDest = result.plan.toCopy.find((c) => c.sourcePath.endsWith(".m4a"))?.destPath; + expect(await Bun.file(m4aDest as string).bytes()).toEqual(m4a); + expect(result.progress.at(-1)?.discarded).toBe(0); + expect(result.progress.some((p) => p.currentFile.startsWith("Tagging:"))).toBe(true); + expect(result.progress.filter((p) => p.currentFile === "Tagging: Episode 1").length).toBe(0); + }); +}); + +describe("SyncEngine.createPlan", () => { + it("skips items whose source is missing or empty, and logs a warning", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "ok.mp3"), new Uint8Array([1, 2, 3])); + await Bun.write(join(dir, "empty.mp3"), new Uint8Array()); + + const messages: string[] = []; + const logger = Layer.succeed(Logger, { + debug: () => Effect.void, + info: (m) => Effect.sync(() => void messages.push(m)), + error: (m) => Effect.sync(() => void messages.push(m)), + }); + const layer = Layer.mergeAll(SyncEngineLive, EpisodeMatcherLive, FileSystemLive).pipe( + Layer.provide(logger), + ); + + const plan = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan( + podcastOf([ + episode("1", join(dir, "ok.mp3")), + episode("2", join(dir, "empty.mp3")), + episode("3", join(dir, "missing.mp3")), + ]), + out, + new Map(), + ); + }), + layer, + ), + ); + + expect(plan.toCopy.map((c) => c.episode.id)).toEqual(["1"]); + expect(plan.totalBytes).toBe(3); + expect(messages.filter((m) => m.includes("skipping")).length).toBe(2); + }); + + it("gives colliding destinations distinct, deterministic paths and copies both", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "a.mp3"), new Uint8Array([1, 1, 1])); + await Bun.write(join(dir, "b.mp3"), new Uint8Array([2, 2, 2, 2])); + const eps = podcastOf([ + episode("1", join(dir, "a.mp3"), "Same Title"), + episode("2", join(dir, "b.mp3"), "Same Title"), + ]); + + const plan = async () => + runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + recordingEditor([]), + ); + const first = await plan(); + const second = await plan(); + const paths = first.toCopy.map((c) => c.destPath); + + expect(new Set(paths).size).toBe(2); + expect(paths[0]?.endsWith("Same_Title.mp3")).toBe(true); + expect(paths[1]?.endsWith("Same_Title (2).mp3")).toBe(true); + expect(second.toCopy.map((c) => c.destPath)).toEqual(paths); + + await runSync( + Effect.gen(function* () { + yield* Stream.runDrain((yield* SyncEngine).execute(first, out)); + }), + recordingEditor([]), + ); + expect(await Bun.file(paths[0] as string).bytes()).toEqual(new Uint8Array([1, 1, 1])); + expect(await Bun.file(paths[1] as string).bytes()).toEqual(new Uint8Array([2, 2, 2, 2])); + }); + + it("dedupeDestPath is case-insensitive and keeps the extension last", () => { + const used = new Set(); + expect(dedupeDestPath("/d/A.mp3", ".mp3", used)).toBe("/d/A.mp3"); + expect(dedupeDestPath("/d/a.MP3", ".MP3", used)).toBe("/d/a (2).MP3"); + expect(dedupeDestPath("/d/A.mp3", ".mp3", used)).toBe("/d/A (3).mp3"); + }); + + it("skips an existing destination and re-plans a missing one", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "ep.mp3"), new Uint8Array([1, 2, 3, 4, 5, 6])); + const eps = podcastOf([episode("1", join(dir, "ep.mp3"))]); + const planOnce = () => + runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + recordingEditor([]), + ); + + const initial = await planOnce(); + const dest = initial.toCopy[0]?.destPath as string; + expect(initial.toCopy).toHaveLength(1); + + // Even a smaller file at the final path counts as complete (tagging can shrink it). + await Bun.write(dest, new Uint8Array([1, 2])); + expect((await planOnce()).toCopy).toHaveLength(0); + }); +}); + +describe("SyncEngine atomic copy", () => { + const executeOnce = (eps: Podcast[], out: string, editor: Layer.Layer) => + runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(eps, out, new Map()); + const exit = yield* Effect.exit(Stream.runDrain(engine.execute(plan, out))); + return { plan, exit }; + }), + editor, + ); + + it("leaves no dest and no .part when the copy fails", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + // A directory opens for reading but fails on read: a mid-copy I/O error. + await mkdir(join(dir, "ep.mp3")); + const { plan, exit } = await executeOnce( + podcastOf([episode("1", join(dir, "ep.mp3"))]), + out, + recordingEditor([]), + ); + expect(plan.toCopy).toHaveLength(1); + expect(Exit.isFailure(exit)).toBe(true); + const dest = plan.toCopy[0]?.destPath as string; + expect(await Bun.file(dest).exists()).toBe(false); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + }); + + it("leaves no dest and no .part when the copy is interrupted mid-file", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "ep.mp3"), new Uint8Array(5 * 1024 * 1024).fill(7)); + const plan = await runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan( + podcastOf([episode("1", join(dir, "ep.mp3"))]), + out, + new Map(), + ); + // Initial progress plus the first 1 MiB chunk, then the consumer stops. + yield* Stream.runDrain(Stream.take(engine.execute(plan, out), 2)); + return plan; + }), + recordingEditor([]), + ); + const dest = plan.toCopy[0]?.destPath as string; + expect(await Bun.file(dest).exists()).toBe(false); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + }); + + it("removes a stale .part before copying and never leaves one behind", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const content = new Uint8Array([4, 5, 6, 7]); + await Bun.write(join(dir, "ep.m4a"), content); + const eps = podcastOf([episode("1", join(dir, "ep.m4a"))]); + const plan = await runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + recordingEditor([]), + ); + const dest = plan.toCopy[0]?.destPath as string; + await Bun.write(`${dest}.part`, new Uint8Array(100).fill(9)); + await runSync( + Effect.gen(function* () { + yield* Stream.runDrain((yield* SyncEngine).execute(plan, out)); + }), + recordingEditor([]), + ); + expect(await Bun.file(dest).bytes()).toEqual(content); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + }); + + it("does not re-plan an mp3 whose tagged output is smaller than the source", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const source = new Uint8Array(4096).fill(3); + await Bun.write(join(dir, "ep.mp3"), source); + const eps = podcastOf([episode("1", join(dir, "ep.mp3"))]); + // A tagger that shrinks the file, as ID3 padding rewrites can. + const shrinking = Layer.succeed(MetadataEditor, { + write: (path) => + Effect.promise(async () => { + await Bun.write(path, source.subarray(0, 1000)); + }), + }); + const { plan, exit } = await executeOnce(eps, out, shrinking); + expect(Exit.isSuccess(exit)).toBe(true); + const dest = plan.toCopy[0]?.destPath as string; + expect((await Bun.file(dest).bytes()).length).toBe(1000); + expect(await Bun.file(`${dest}.part`).exists()).toBe(false); + + const second = await runSync( + Effect.gen(function* () { + return yield* (yield* SyncEngine).createPlan(eps, out, new Map()); + }), + shrinking, + ); + expect(second.toCopy).toHaveLength(0); + }); + + it("copies a non-mp3 byte-identical without tagging", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + const content = new Uint8Array(3000).map((_, i) => (i * 13) % 256); + await Bun.write(join(dir, "ep.m4a"), content); + const sink: Array<{ path: string; metadata: PodcastMetadata }> = []; + const { plan, exit } = await executeOnce( + podcastOf([episode("1", join(dir, "ep.m4a"))]), + out, + recordingEditor(sink), + ); + expect(Exit.isSuccess(exit)).toBe(true); + const dest = plan.toCopy[0]?.destPath as string; + expect(await Bun.file(dest).bytes()).toEqual(content); + expect(sink).toHaveLength(0); + }); +}); + +describe("MetadataEditor merge", () => { + it("keeps frames already in the file (cover art) when writing podcast tags", async () => { + const dir = await freshDir(); + const file = join(dir, "ep.mp3"); + const bytes = new Uint8Array(1024); + for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) % 251; + await Bun.write(file, bytes); + + const cover = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]); + nodeID3.write( + { + title: "Old", + image: { mime: "image/jpeg", type: { id: 3 }, description: "cover", imageBuffer: cover }, + }, + file, + ); + + await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + yield* (yield* MetadataEditor).write(file, { + title: "New", + artist: "Host", + album: "Show", + genre: "Podcast", + year: "2024", + comment: "Published: 2024-01-01", + }); + }), + MetadataEditorLive, + ), + ); + + const tags = nodeID3.read(file); + expect(tags.title).toBe("New"); + expect(tags.artist).toBe("Host"); + const image = tags.image as { imageBuffer: Buffer } | undefined; + expect(image?.imageBuffer.equals(cover)).toBe(true); + }); +}); + +describe("DriveDetection debounce", () => { + const drive: Drive = { + id: "UUID", + name: "USB", + bsdName: "disk4s1", + mountPoint: "/Volumes/USB", + totalSpace: 1, + freeSpace: 1, + }; + const logger = { + debug: () => Effect.void, + info: () => Effect.void, + error: () => Effect.void, + }; + + const setup = () => + Effect.gen(function* () { + return { + driveIdMap: yield* Ref.make(new Map()), + lastEventTimeMap: yield* Ref.make(new Map()), + }; + }); + + it("does not let an empty Appeared (pre-mount) swallow the VolumeMount that follows", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const state = yield* setup(); + const ev = { type: "Appeared", bsdName: "disk4s1" } as const; + const early = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.none()), + logger, + 10_000, + ); + const mounted = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.some(drive)), + logger, + 10_100, + ); + const dup = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.some(drive)), + logger, + 10_500, + ); + const later = yield* processActivityEvent( + ev, + state, + () => Effect.succeed(Option.some(drive)), + logger, + 11_200, + ); + return { early, mounted, dup, later }; + }), + ); + expect(Option.isNone(result.early)).toBe(true); + expect(Option.isSome(result.mounted)).toBe(true); + // A real emitted event still debounces its immediate duplicates. + expect(Option.isNone(result.dup)).toBe(true); + expect(Option.isSome(result.later)).toBe(true); + }); + + it("does not record a timestamp for a Disappeared of an unknown drive", async () => { + const result = await Effect.runPromise( + Effect.gen(function* () { + const state = yield* setup(); + const gone = { type: "Disappeared", bsdName: "disk4s1" } as const; + const unknown = yield* processActivityEvent( + gone, + state, + () => Effect.succeed(Option.none()), + logger, + 5_000, + ); + yield* Ref.update(state.driveIdMap, (m) => new Map(m).set("disk4s1", "UUID")); + const known = yield* processActivityEvent( + gone, + state, + () => Effect.succeed(Option.none()), + logger, + 5_100, + ); + return { unknown, known }; + }), + ); + expect(Option.isNone(result.unknown)).toBe(true); + expect(Option.getOrNull(result.known)).toEqual({ _tag: "Disappeared", driveId: "UUID" }); + }); +}); + +describe("FileSystem.getExtension", () => { + const ext = (path: string) => + Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return (yield* FileSystem).getExtension(path); + }), + FileSystemLive.pipe(Layer.provide(LoggerLive)), + ), + ); + + it("only considers the basename", async () => { + expect(await ext("/Volumes/My.Drive/episode")).toBe(".mp3"); + expect(await ext("/a.b/c.d/episode.m4a")).toBe(".m4a"); + expect(await ext("/a/ep.final.MP3")).toBe(".MP3"); + expect(await ext("/a/ep.")).toBe(".mp3"); + }); +}); + +describe("SyncEngine tagging, invalid dates", () => { + it("does not crash the stream on an invalid published date", async () => { + const dir = await freshDir(); + const out = join(dir, "out"); + await Bun.write(join(dir, "a.mp3"), new Uint8Array([1, 2, 3])); + const bad = { ...episode("1", join(dir, "a.mp3")), published: new Date(Number.NaN) }; + + const sink: Array<{ path: string; metadata: PodcastMetadata }> = []; + const last = await runSync( + Effect.gen(function* () { + const engine = yield* SyncEngine; + const plan = yield* engine.createPlan(podcastOf([bad]), out, new Map()); + const progress = Array.from(yield* Stream.runCollect(engine.execute(plan, out))); + return progress.at(-1); + }), + recordingEditor(sink), + ); + expect(last?.status).toBe("complete"); + expect(sink).toHaveLength(1); + expect(sink[0]?.metadata.year).toBeUndefined(); + expect(sink[0]?.metadata.comment).toBeUndefined(); + expect(sink[0]?.metadata.artist).toBe("Host"); + }); +}); + +describe("SettingsService", () => { + const settingsFile = join(homedir(), ".config", "podapple", "podapple.jsonc"); + const run = ( + files: Map, + program: Effect.Effect, + ) => + Effect.runPromise( + Effect.provide( + program, + SettingsServiceLive.pipe( + Layer.provide(createFileSystemTest(files)), + Layer.provide(LoggerLive), + ), + ), + ); + + it("refuses to overwrite a settings file that exists but does not parse", async () => { + const corrupt = new TextEncoder().encode('{ "theme": "Nord", '); + const files = new Map([[settingsFile, corrupt]]); + + const exit = await run( + files, + Effect.gen(function* () { + return yield* (yield* SettingsService).saveSettings({ theme: "Dracula" }).pipe(Effect.exit); + }), + ); + expect(exit._tag).toBe("Failure"); + expect(files.get(settingsFile)).toBe(corrupt); + // No temp file left behind. + expect(Array.from(files.keys())).toEqual([settingsFile]); + + // Loading still degrades to defaults rather than throwing. + const loaded = await run( + files, + Effect.gen(function* () { + return yield* (yield* SettingsService).loadSettings; + }), + ); + expect(loaded.theme).toBe("Catppuccin"); + }); + + it("creates a missing settings file via temp file + rename", async () => { + const files = new Map(); + await run( + files, + Effect.gen(function* () { + yield* (yield* SettingsService).saveSettings({ theme: "Nord" }); + }), + ); + expect(Array.from(files.keys())).toEqual([settingsFile]); + expect(JSON.parse(new TextDecoder().decode(files.get(settingsFile))).theme).toBe("Nord"); + }); +}); + +describe("PodcastService worker startup", () => { + it("maps a throwing `new Worker` to PodcastError, not a defect", async () => { + const RealWorker = globalThis.Worker; + globalThis.Worker = class { + constructor() { + throw new Error("worker boom"); + } + } as unknown as typeof Worker; + try { + const exit = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const svc = yield* PodcastService; + return yield* svc.loadMacPodcasts.pipe(Effect.exit); + }), + PodcastServiceLive.pipe( + Layer.provide( + // The DB file "exists" as far as the service can tell. + createFileSystemTest( + new Map([ + [ + join( + homedir(), + "Library/Group Containers/243LU875E5.groups.com.apple.podcasts/Documents/MTLibrary.sqlite", + ), + new Uint8Array([1]), + ], + ]), + ), + ), + Layer.provide(LoggerLive), + ), + ), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const error = Cause.findError(exit.cause); + expect(Result.isSuccess(error)).toBe(true); + if (Result.isSuccess(error)) expect(error.success).toBeInstanceOf(PodcastError); + } + } finally { + globalThis.Worker = RealWorker; + } + }); +}); + +describe("db.worker", () => { + it("still loads episodes from a real SQLite file (connection closed in finally)", async () => { + const dir = await freshDir(); + const dbPath = join(dir, "MTLibrary.sqlite"); + const db = new Database(dbPath); + db.run("CREATE TABLE ZMTPODCAST (ZUUID TEXT, ZTITLE TEXT, ZAUTHOR TEXT)"); + db.run( + "CREATE TABLE ZMTEPISODE (Z_PK INTEGER, ZTITLE TEXT, ZPODCASTUUID TEXT, ZASSETURL TEXT, ZPUBDATE REAL, ZDURATION REAL)", + ); + db.run("INSERT INTO ZMTPODCAST VALUES ('u1', 'Show', 'Host')"); + db.run("INSERT INTO ZMTEPISODE VALUES (1, 'Ep', 'u1', 'file:///tmp/a%20b.mp3', 1000, 60)"); + db.close(); + + const worker = new Worker(new URL("../services/workers/db.worker.ts", import.meta.url)); + try { + const message = await new Promise<{ + type: string; + data?: Array<{ filePath: string; author: string }>; + }>((resolve, reject) => { + worker.onmessage = (e) => resolve(e.data); + worker.onerror = (e) => reject(e); + worker.postMessage({ type: "LOAD", dbPath }); + }); + expect(message.type).toBe("SUCCESS"); + expect(message.data?.[0]?.filePath).toBe("/tmp/a b.mp3"); + expect(message.data?.[0]?.author).toBe("Host"); + + // An unreadable database reports ERROR instead of hanging. + const failure = await new Promise<{ type: string }>((resolve) => { + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ type: "LOAD", dbPath: join(dir, "missing.sqlite") }); + }); + expect(failure.type).toBe("ERROR"); + } finally { + worker.terminate(); + } + }); +}); diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index a471c7e..daf8226 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -122,4 +122,37 @@ describe("store actions", () => { expect(state.transferProgress.filesDone).toBe(0); expect(state.favoriteDrives).toEqual([]); }); + + it("clamps macIndex when the Mac list shrinks", () => { + actions.resetState(); + actions.setMacPodcasts([{ id: "1" }, { id: "2" }, { id: "3" }] as PodcastEpisode[]); + actions.setMacIndex(2); + + actions.setMacPodcasts([{ id: "1" }] as PodcastEpisode[]); + expect(state.macIndex).toBe(0); + + actions.setMacPodcasts([]); + expect(state.macIndex).toBe(0); + }); + + it("clamps driveIndex when the drive list shrinks", () => { + actions.resetState(); + actions.setDrivePodcasts([{ id: "1" }, { id: "2" }, { id: "3" }] as PodcastEpisode[]); + actions.setDriveIndex(2); + + actions.setDrivePodcasts([{ id: "1" }, { id: "2" }] as PodcastEpisode[]); + expect(state.driveIndex).toBe(1); + + actions.setDrivePodcasts([]); + expect(state.driveIndex).toBe(0); + }); + + it("leaves an in-range index alone when a list is replaced", () => { + actions.resetState(); + actions.setMacPodcasts([{ id: "1" }, { id: "2" }, { id: "3" }] as PodcastEpisode[]); + actions.setMacIndex(1); + + actions.setMacPodcasts((prev) => prev.map((ep) => ({ ...ep }))); + expect(state.macIndex).toBe(1); + }); }); diff --git a/src/__tests__/useAppLogic.test.ts b/src/__tests__/useAppLogic.test.ts index 8e45ed6..fd3054b 100644 --- a/src/__tests__/useAppLogic.test.ts +++ b/src/__tests__/useAppLogic.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "bun:test"; +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { describeError, @@ -7,7 +7,8 @@ import { useAppLogic, } from "@/hooks/useAppLogic"; import { DriveScan, DriveScanError } from "@/services/effects/DriveScan"; -import { SyncEngine, SyncError } from "@/services/effects/SyncEngine"; +import { CleanupError, SyncEngine, SyncError } from "@/services/effects/SyncEngine"; +import type { SyncProgress } from "@/types/sync"; import { actions, state } from "@/store"; import { selectDrive } from "@/utils/driveSelection"; import type { Drive } from "@/types/drive"; @@ -276,3 +277,479 @@ describe("useAppLogic post-sync rescan gate", () => { expect(state.errorMsg).toBe(""); }); }); + +/** A scan double whose result for each call is released by the test. */ +const gatedScan = () => { + const gates: Array<(value: unknown[]) => void> = []; + let calls = 0; + const layer = Layer.succeed(DriveScan, { + scanDrive: () => { + calls++; + return Effect.promise( + () => new Promise((resolve) => gates.push(resolve as (v: unknown[]) => void)), + ); + }, + buildDriveIndex: () => Effect.succeed(new Map()), + hasPodcastsFolder: () => Effect.succeed(false), + }); + return { layer, gates, calls: () => calls }; +}; + +const scannedShow = (title: string) => ({ + id: "p1", + title: "Show", + author: "Dan Carlin", + episodeCount: 1, + episodes: [ + { + id: title, + title, + author: "Dan Carlin", + duration: 60, + published: new Date("2024-01-01"), + onDrive: false, + filePath: `/Volumes/USB/${title}.mp3`, + fileSize: 1000, + }, + ], +}); + +describe("useAppLogic drive load staleness", () => { + const otherDrive: Drive = { ...drive, id: "d2", name: "Other", mountPoint: "/Volumes/Other" }; + + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("discards scan results when the current drive changed mid-scan", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + actions.setCurrentDrive(otherDrive); + scan.gates[0]?.([scannedShow("Stale")]); + await pending; + + expect(state.drivePodcasts).toEqual([]); + expect(state.loadingDrive).toBe(false); + }); + + it("discards scan results when the drive was unplugged mid-scan", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + actions.setCurrentDrive(null); + scan.gates[0]?.([scannedShow("Stale")]); + await pending; + + expect(state.drivePodcasts).toEqual([]); + }); + + it("applies scan results for the drive that is still current", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + scan.gates[0]?.([scannedShow("Fresh")]); + await pending; + + expect(state.drivePodcasts.map((e) => e.title)).toEqual(["Fresh"]); + }); + + it("does not surface a stale scan's error", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(otherDrive); + + await logic.loadDrivePodcasts(drive); + + expect(state.errorMsg).toBe(""); + }); + + it("clears onDrive markers on Mac episodes when the drive load fails", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(drive); + actions.setMacPodcasts([{ ...episode("1"), onDrive: true }]); + + await logic.loadDrivePodcasts(drive); + + expect(state.errorMsg).toBe("rescan failed"); + expect(state.macPodcasts[0]?.onDrive).toBe(false); + }); +}); + +const progressOf = (over: Partial): SyncProgress => ({ + currentFile: "Episode 1", + currentIndex: 1, + totalFiles: 2, + bytesTransferred: 500, + totalBytes: 1000, + discarded: 0, + startTime: Date.now(), + status: "syncing", + ...over, +}); + +describe("useAppLogic transfer progress", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("counts the in-progress file as not yet done and never reports a non-finite speed", async () => { + const nowSpy = spyOn(Date, "now").mockImplementation(() => 1_000_000); + try { + const seen: Array<{ filesDone: number; speed: number }> = []; + const snapshot = () => + seen.push({ + filesDone: state.transferProgress.filesDone, + speed: state.transferProgress.speed, + }); + const items = [ + progressOf({ currentIndex: 1 }), + progressOf({ currentIndex: 2, currentFile: "Episode 2", bytesTransferred: 1000 }), + progressOf({ currentIndex: 2, status: "complete", bytesTransferred: 1000 }), + ]; + const engine = Layer.succeed(SyncEngine, { + createPlan: () => + Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 2, totalBytes: 1000 }), + // Each emission records the state left by the one before it. + execute: () => Stream.fromIterable(items).pipe(Stream.tap(() => Effect.sync(snapshot))), + copyFileWithProgress: () => Effect.void, + cleanup: () => Effect.void, + }); + const logic = useAppLogic(makeAppLayerWith({ scan: driveScanDouble(false), sync: engine })); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + + // Before the first file: 0 done. During file 2: only file 1 is done. + expect(seen.map((s) => s.filesDone)).toEqual([0, 0, 1]); + // startTime == now, so zero ms have elapsed: speed must be 0, not Infinity/NaN. + for (const s of seen) expect(Number.isFinite(s.speed)).toBe(true); + expect(Number.isFinite(state.transferProgress.speed)).toBe(true); + // "complete" reports every file done. + expect(state.transferProgress.filesDone).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); +}); + +describe("useAppLogic cancelled sync", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + const cancellableEngine = (cleanedUp: string[], failCleanup = false) => + Layer.succeed(SyncEngine, { + createPlan: () => + Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 2, totalBytes: 1000 }), + execute: () => Stream.concat(Stream.make(progressOf({})), Stream.never), + copyFileWithProgress: () => Effect.void, + cleanup: (path) => + Effect.sync(() => { + cleanedUp.push(path); + }).pipe( + Effect.flatMap(() => + failCleanup + ? Effect.fail(new CleanupError({ path, cause: new Error("boom") })) + : Effect.void, + ), + ), + }); + + it("cleans up and rescans the drive without claiming success", async () => { + const cleanedUp: string[] = []; + const scan = gatedScan(); + const logic = useAppLogic( + makeAppLayerWith({ scan: scan.layer, sync: cancellableEngine(cleanedUp) }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + // The pre-sync index and the rescan are separate scans; only the rescan uses scanDrive. + expect(scan.calls()).toBe(0); + + logic.cancelSync(); + await settle(); + + expect(cleanedUp).toEqual([drive.mountPoint]); + expect(scan.calls()).toBe(1); + scan.gates[0]?.([scannedShow("Partial")]); + await settle(); + + expect(state.drivePodcasts.map((e) => e.title)).toEqual(["Partial"]); + expect(state.successMsg).toBe(""); + expect(state.errorMsg).toBe(""); + expect(state.appView).toBe("normal"); + }); + + it("still rescans when cleanup fails", async () => { + const cleanedUp: string[] = []; + const scan = gatedScan(); + const logic = useAppLogic( + makeAppLayerWith({ scan: scan.layer, sync: cancellableEngine(cleanedUp, true) }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + logic.cancelSync(); + await settle(); + + expect(cleanedUp).toEqual([drive.mountPoint]); + expect(scan.calls()).toBe(1); + expect(state.successMsg).toBe(""); + }); +}); + +/** A SyncEngine whose plan and execution the test controls. */ +const planEngine = (opts: { + totalBytes: number; + executed: { count: number }; + execute?: () => Stream.Stream; +}) => + Layer.succeed(SyncEngine, { + createPlan: () => + Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 1, totalBytes: opts.totalBytes }), + execute: () => { + opts.executed.count++; + return ( + opts.execute?.() ?? + Stream.succeed(progressOf({ status: "complete", currentIndex: 1, totalFiles: 1 })) + ); + }, + copyFileWithProgress: () => Effect.void, + cleanup: () => Effect.void, + }); + +describe("useAppLogic free-space check", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("refuses a plan larger than the drive's free space without copying", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 1024 ** 3 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(0); + expect(state.errorMsg).toBe("Not enough space on USB: need 2.0 GB, have 1.0 GB"); + expect(state.successMsg).toBe(""); + expect(state.appView).toBe("normal"); + }); + + it("allows a plan that exactly fits", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 1000, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 1000 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.errorMsg).toBe(""); + expect(state.successMsg).toBe("Sync complete"); + }); + + it("skips the check when free space is unknown (negative)", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 5 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: -1 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.errorMsg).toBe(""); + }); +}); + +describe("useAppLogic sync re-entrancy", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("ignores a second startSync while one runs, and keeps the first cancellable", async () => { + const executed = { count: 0 }; + const cleaned: string[] = []; + const engine = Layer.succeed(SyncEngine, { + createPlan: () => Effect.succeed({ toCopy: [], toDelete: [], totalFiles: 1, totalBytes: 10 }), + execute: () => { + executed.count++; + return Stream.concat(Stream.make(progressOf({})), Stream.never); + }, + copyFileWithProgress: () => Effect.void, + cleanup: (path) => + Effect.sync(() => { + cleaned.push(path); + }), + }); + const logic = useAppLogic(makeAppLayerWith({ scan: driveScanDouble(false), sync: engine })); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + // Synchronous second call: before the first has even forked its fiber. + logic.startSync([episode("2")]); + await settle(); + logic.startSync([episode("3")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.debugMessages.some((m) => JSON.stringify(m).includes("already in progress"))).toBe( + true, + ); + + // The first sync's fiber must still be the one cancelSync reaches. + logic.cancelSync(); + await settle(); + expect(cleaned).toEqual([drive.mountPoint]); + expect(state.appView).toBe("normal"); + }); + + it("allows a new sync once the previous one finished", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 10, executed }), + }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(2); + }); + + it("allows a new sync after the previous one failed", async () => { + const executed = { count: 0 }; + const logic = useAppLogic( + makeAppLayerWith({ + scan: driveScanDouble(false), + sync: planEngine({ + totalBytes: 10, + executed, + execute: () => + Stream.fail(new SyncError({ episode: "Ep", cause: new Error("boom") })) as never, + }), + }), + ); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + expect(state.errorMsg).toBe("boom"); + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(2); + }); +}); + +describe("useAppLogic loadingDrive generations", () => { + const otherDrive: Drive = { ...drive, id: "d2", name: "Other", mountPoint: "/Volumes/Other" }; + + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("keeps loadingDrive true when a superseded load finishes before the newer one", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const first = logic.loadDrivePodcasts(drive); + await settle(); + const second = logic.loadDrivePodcasts(drive); + await settle(); + expect(scan.gates.length).toBe(2); + + // The stale (first) load finishes while the newer one still runs. + scan.gates[0]?.([]); + await first; + expect(state.loadingDrive).toBe(true); + + scan.gates[1]?.([]); + await second; + expect(state.loadingDrive).toBe(false); + }); + + it("clears loadingDrive when the newest load finishes first, and stays cleared", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const first = logic.loadDrivePodcasts(drive); + await settle(); + const second = logic.loadDrivePodcasts(drive); + await settle(); + + scan.gates[1]?.([]); + await second; + expect(state.loadingDrive).toBe(false); + + scan.gates[0]?.([]); + await first; + expect(state.loadingDrive).toBe(false); + }); + + it("clears loadingDrive when the latest load fails", async () => { + const logic = useAppLogic(testLayer(true)); + actions.setCurrentDrive(drive); + + await logic.loadDrivePodcasts(drive); + + expect(state.errorMsg).toBe("rescan failed"); + expect(state.loadingDrive).toBe(false); + }); + + it("clears loadingDrive when the last load is for a drive that is no longer current", async () => { + const scan = gatedScan(); + const logic = useAppLogic(makeAppLayerWith({ scan: scan.layer, sync: emptyPlanEngine })); + actions.setCurrentDrive(drive); + + const pending = logic.loadDrivePodcasts(drive); + await settle(); + actions.setCurrentDrive(otherDrive); + scan.gates[0]?.([]); + await pending; + + expect(state.loadingDrive).toBe(false); + }); +}); diff --git a/src/components/DebugPopup.tsx b/src/components/DebugPopup.tsx index 3f5a06a..69cf4be 100644 --- a/src/components/DebugPopup.tsx +++ b/src/components/DebugPopup.tsx @@ -1,4 +1,4 @@ -import { state } from "@/store"; +import { actions, state } from "@/store"; import { Colors } from "@/theme/colors"; import { Selector } from "./Selector"; @@ -45,6 +45,7 @@ export function DebugPopup(props: DebugPopupProps) { width='85%' height='85%' selectedIndex={state.debugMenuIndex} + onIndexChange={(index) => actions.setDebugMenuIndex(index)} onClose={props.onClose} onShortcutClick={props.onShortcutClick} onSelect={() => {}} diff --git a/src/components/EpisodeItem.tsx b/src/components/EpisodeItem.tsx index 5bb8578..2e279ab 100644 --- a/src/components/EpisodeItem.tsx +++ b/src/components/EpisodeItem.tsx @@ -4,6 +4,8 @@ import type { PodcastEpisode } from "@/types/podcast"; import { getEpisodeDescription, getEpisodeTitle } from "@/utils/formatting"; export interface EpisodeItemProps { + id?: string; + onLayout?: () => void; episode: PodcastEpisode; isSelected: boolean; isFocused: boolean; @@ -41,6 +43,8 @@ export function EpisodeItem(props: EpisodeItemProps) { return ( (props.focused ? Colors.border.focused : Colors.border.unfocused); + const follow = createScrollFollow(() => props.selectedIndex); const backgroundColor = () => (props.focused ? Colors.focused : Colors.background); return ( @@ -55,6 +57,7 @@ export function EpisodeList(props: EpisodeListProps) { {(episode, index) => ( { @@ -31,6 +32,8 @@ export function Selector(props: SelectorProps) { props.selectedIndex !== undefined ? props.selectedIndex : internalIndex(), ); + const follow = createScrollFollow(currentIndex); + const handleInteraction = (index: number, item: T) => { if (currentIndex() === index) { props.onSelect(item); @@ -63,6 +66,7 @@ export function Selector(props: SelectorProps) { fallback={{props.emptyText || "No items found"}} > (props: SelectorProps) { return ( (props: SelectorProps) { ], ]} onShortcutClick={(key) => { - if (key === "esc") props.onClose(); + // "esc" is fully handled by closing; forwarding it would make the + // parent run its escape action against the already-closed view. + if (key === "esc") { + props.onClose(); + return; + } props.onShortcutClick?.(key); }} /> diff --git a/src/components/scrollFollow.ts b/src/components/scrollFollow.ts new file mode 100644 index 0000000..7a00ad3 --- /dev/null +++ b/src/components/scrollFollow.ts @@ -0,0 +1,39 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { createRenderEffect } from "solid-js"; + +let scopeCounter = 0; + +/** + * Keeps the selected row of a `` visible. + * + * Wire-up: pass `follow.ref` as the scrollbox `ref`, and spread + * `follow.rowProps(() => index)` onto each row. + * + * Two triggers cover the two cases a plain effect cannot: + * - the selected index changing (render effect; layout from the previous + * frame is still valid because rows keep their size when selected), and + * - the selected row receiving its first layout (`onSizeChange`), which is + * when it is actually measurable on open or after the list is replaced. + */ +export function createScrollFollow(selectedIndex: () => number) { + const scope = `scroll-follow-${scopeCounter++}`; + let box: ScrollBoxRenderable | undefined; + + const rowId = (index: number) => `${scope}-row-${index}`; + + const sync = (index: number) => { + box?.scrollChildIntoView(rowId(index)); + }; + + createRenderEffect(selectedIndex, sync); + + return { + ref: (el: ScrollBoxRenderable) => { + box = el; + }, + rowId, + onRowLayout: (index: () => number) => () => { + if (index() === selectedIndex()) sync(index()); + }, + }; +} diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 1aab829..c6bfb10 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -76,6 +76,12 @@ export const useAppKeyboard = (logic: ReturnType) => { return; } + // Letter (and space) actions are for the plain key only. Ctrl+ is a + // different chord: ctrl+t is handled above, and ctrl+u/ctrl+d arrive remapped + // to page keys with ctrl cleared, so anything still carrying ctrl here must + // not fall through to the plain action. + if (ctrl && (key.length === 1 || key === "space")) return; + if (key === "f1") { actions.setDebugMenuIndex(Math.max(0, state.debugMessages.length - 1)); actions.setAppView("debug"); @@ -193,7 +199,7 @@ export const useAppKeyboard = (logic: ReturnType) => { } else if (key === "up" || key === "k") { actions.setDriveMenuIndex((i) => Math.max(0, i - 1)); } else if (key === "down" || key === "j") { - actions.setDriveMenuIndex((i) => Math.min(state.drives.length - 1, i + 1)); + actions.setDriveMenuIndex((i) => Math.max(0, Math.min(state.drives.length - 1, i + 1))); } else if (key === "return" || key === "enter") { const drive = state.drives[state.driveMenuIndex]; if (drive) { @@ -221,7 +227,9 @@ export const useAppKeyboard = (logic: ReturnType) => { } else if (key === "up" || key === "k") { actions.setDebugMenuIndex((i) => Math.max(0, i - 1)); } else if (key === "down" || key === "j") { - actions.setDebugMenuIndex((i) => Math.min(state.debugMessages.length - 1, i + 1)); + actions.setDebugMenuIndex((i) => + Math.max(0, Math.min(state.debugMessages.length - 1, i + 1)), + ); } else if (key === "return" || key === "enter") { const message = state.debugMessages[state.debugMenuIndex]; if (message) { diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index 2cbf0c9..48c5d5f 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -27,6 +27,7 @@ import { setTheme } from "@/theme/colors"; import type { Drive } from "@/types/drive"; import type { PodcastEpisode } from "@/types/podcast"; import { + formatBytes, groupEpisodesByPodcast, mapPodcastsToEpisodes, markEpisodesOnDrive, @@ -186,6 +187,16 @@ export const syncSuccessMessage = (discarded: number): string => ? `Sync complete (${discarded} ${discarded === 1 ? "file" : "files"} discarded: tagging failed)` : "Sync complete"; +/** + * Bytes per second since `startTime`, or 0 when no time has elapsed (which would + * otherwise divide by zero and yield Infinity or NaN). + */ +const transferSpeed = (bytesTransferred: number, startTime: number): number => { + if (startTime <= 0) return 0; + const elapsedMs = Date.now() - startTime; + return elapsedMs > 0 ? bytesTransferred / (elapsedMs / 1000) : 0; +}; + /** * Hook containing the core application logic, orchestrating various Effect services. * @@ -195,6 +206,15 @@ export const syncSuccessMessage = (discarded: number): string => export const useAppLogic = (layer: Layer.Layer = makeAppLayer()) => { let driveListenerFiber: Fiber.Fiber | undefined; let activeSyncFiber: Fiber.Fiber | undefined; + // True from startSync until its whole run (copy, rescan) ends. activeSyncFiber + // is only set once the fiber is forked, so it cannot guard a second call that + // arrives in between. + let syncRunning = false; + // Bumped by every drive load (and by the current drive disappearing). Only the + // load holding the latest value may clear loadingDrive, so a superseded load + // finishing late cannot switch the spinner off while a newer one still runs. + let loadGeneration = 0; + let runningLoads = 0; /** * Runs an effect to completion using the AppLayer. @@ -224,39 +244,68 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() () => Effect.sync(() => actions.setAppView("normal")), ); + const isCurrentDrive = (drive: Drive): boolean => state.currentDrive?.id === drive.id; + + /** + * With no readable drive listing, no Mac episode can claim to be on the drive. + */ + const clearOnDriveMarkers = () => { + actions.setMacPodcasts((prev) => markEpisodesOnDrive(prev, [])); + }; + /** * Loads podcasts from a drive using the DriveScan service. */ const loadDrivePodcastsEffect = ( drive: Drive, ): Effect.Effect => - Effect.gen(function* () { - const logger = yield* Logger; - actions.setLoadingDrive(true); - const driveScan = yield* DriveScan; - const podcasts = yield* driveScan.scanDrive(drive.mountPoint); - const episodes = mapPodcastsToEpisodes(podcasts); + // Suspended so the generation is taken when the load runs, not when the + // effect is constructed. + Effect.suspend(() => { + const generation = ++loadGeneration; + runningLoads++; + return Effect.gen(function* () { + const logger = yield* Logger; + actions.setLoadingDrive(true); + const driveScan = yield* DriveScan; + const podcasts = yield* driveScan.scanDrive(drive.mountPoint); - actions.setDrivePodcasts(episodes); - yield* logger.debug(`Loaded ${episodes.length} drive episodes`); + // The scan is slow and the user can switch or unplug the drive while it + // runs. Results for a drive that is no longer current are discarded, or + // they would show one drive's episodes under another. + if (!isCurrentDrive(drive)) return false; - // 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 = describeError(err); - actions.setErrorMsg(errorMessage); - actions.addDebugMessage(errorMessage, "error"); - actions.setDrivePodcasts([]); - return Effect.succeed(false); - }), - Effect.onExit(() => Effect.sync(() => actions.setLoadingDrive(false))), - ); + const episodes = mapPodcastsToEpisodes(podcasts); + + actions.setDrivePodcasts(episodes); + yield* logger.debug(`Loaded ${episodes.length} drive episodes`); + + // 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) => { + if (!isCurrentDrive(drive)) return Effect.succeed(false); + const errorMessage = describeError(err); + actions.setErrorMsg(errorMessage); + actions.addDebugMessage(errorMessage, "error"); + actions.setDrivePodcasts([]); + clearOnDriveMarkers(); + return Effect.succeed(false); + }), + // Runs on success, failure and interruption alike. + Effect.onExit(() => + Effect.sync(() => { + runningLoads--; + if (generation === loadGeneration) actions.setLoadingDrive(false); + }), + ), + ); + }); /** * Imperative wrapper for loadDrivePodcastsEffect. @@ -307,7 +356,8 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() Effect.onExit(() => Effect.sync(() => { actions.setIsScanning(false); - actions.setLoadingDrive(false); + // A load started meanwhile (the user picked a drive) clears its own flag. + if (runningLoads === 0) actions.setLoadingDrive(false); }), ), ); @@ -351,6 +401,11 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() if (state.currentDrive?.id === event.driveId) { actions.setCurrentDrive(null); actions.setDrivePodcasts([]); + clearOnDriveMarkers(); + // A load for the unplugged drive is now pointless. Supersede it so + // the spinner stops here instead of waiting on a scan that may hang. + loadGeneration++; + actions.setLoadingDrive(false); } yield* logger.info(`Drive disappeared: ${event.driveId}`); } @@ -489,6 +544,12 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() * Starts the sync operation for selected episodes. */ const startSync = (episodesToSync: PodcastEpisode[]) => { + // A second sync would overwrite activeSyncFiber and make the first + // uncancellable. Ignore it, and leave the current status untouched. + if (syncRunning) { + actions.addDebugMessage("Sync already in progress; ignoring request"); + return; + } // A new sync supersedes any previous outcome message. actions.setSuccessMsg(""); const drive = state.currentDrive; @@ -502,6 +563,7 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() return; } + syncRunning = true; actions.updateTransferProgress({ currentFile: "Preparing...", filesDone: 0, @@ -531,6 +593,15 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() return { success: true, message: "All episodes already synced" }; } + // Refuse up front rather than fail midway with a half-copied drive. A + // negative freeSpace means the drive could not report it, so no check. + if (drive.freeSpace >= 0 && plan.totalBytes > drive.freeSpace) { + return { + success: false, + message: `Not enough space on ${drive.name}: need ${formatBytes(plan.totalBytes)}, have ${formatBytes(drive.freeSpace)}`, + }; + } + // Initialize progress with plan info actions.updateTransferProgress({ totalFiles: plan.totalFiles, @@ -550,14 +621,15 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() actions.updateTransferProgress({ currentFile: progress.currentFile, - filesDone: progress.status === "complete" ? progress.totalFiles : progress.currentIndex, + // currentIndex is the 1-based file in progress, so it is not done yet. + filesDone: + progress.status === "complete" + ? progress.totalFiles + : Math.max(0, progress.currentIndex - 1), totalFiles: progress.totalFiles, bytesTransferred: progress.bytesTransferred, totalBytes: progress.totalBytes, - speed: - progress.startTime > 0 - ? progress.bytesTransferred / ((Date.now() - progress.startTime) / 1000) - : 0, + speed: transferSpeed(progress.bytesTransferred, progress.startTime), }); if (progress.status === "error" && progress.error) { @@ -575,6 +647,7 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() run( Effect.gen(function* () { const logger = yield* Logger; + const syncEngine = yield* SyncEngine; const fiber = yield* Effect.forkChild(syncProgram); activeSyncFiber = fiber; @@ -598,6 +671,11 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() if (Cause.hasInterruptsOnly(cause)) { yield* logger.info("Sync cancelled by user"); actions.setErrorMsg(""); + // Files copied before the cancel are on the drive: tidy up what the + // interrupted run left behind and rescan so they show. No success + // message, since the sync did not finish. + yield* syncEngine.cleanup(drive.mountPoint).pipe(Effect.catch(() => Effect.void)); + yield* loadDrivePodcastsEffect(drive); } else { yield* logger.error("Sync failed", cause); // Not cause.toString(): that renders the whole Effect cause @@ -606,7 +684,15 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() actions.setErrorMsg(describeError(cause)); } } - }).pipe(withView("syncing")), + }).pipe( + withView("syncing"), + Effect.ensuring( + Effect.sync(() => { + syncRunning = false; + activeSyncFiber = undefined; + }), + ), + ), ); }; diff --git a/src/render_tests/episode_list.test.tsx b/src/render_tests/episode_list.test.tsx new file mode 100644 index 0000000..7523ddd --- /dev/null +++ b/src/render_tests/episode_list.test.tsx @@ -0,0 +1,79 @@ +import { createSignal } from "solid-js"; +import { testRender } from "@opentui/solid"; +import { describe, expect, it } from "bun:test"; +import { DebugPopup } from "@/components/DebugPopup"; +import { EpisodeList } from "@/components/EpisodeList"; +import { state } from "@/store"; +import type { PodcastEpisode } from "@/types/podcast"; + +const episodes: PodcastEpisode[] = Array.from({ length: 40 }, (_, n) => ({ + id: `e${n}`, + title: `episode-${n}`, + author: "author", + duration: 60, + published: new Date(0), + onDrive: false, + filePath: `/tmp/e${n}.mp3`, + fileSize: 1, + showName: "show", + selected: false, +})); + +const settle = async (setup: { renderOnce: () => Promise }) => { + await setup.renderOnce(); + await setup.renderOnce(); + await setup.renderOnce(); +}; + +describe("EpisodeList (rendered)", () => { + it("keeps the selected episode visible on open and as the selection moves", async () => { + const [index, setIndex] = createSignal(30); + const setup = await testRender( + () => ( + + ), + { width: 60, height: 20 }, + ); + await settle(setup); + expect(setup.captureCharFrame()).toContain("episode-30"); + + setIndex(1); + await settle(setup); + expect(setup.captureCharFrame()).toContain("episode-1"); + expect(setup.captureCharFrame()).not.toContain("episode-30"); + + setIndex(38); + await settle(setup); + expect(setup.captureCharFrame()).toContain("episode-38"); + setup.renderer.destroy(); + }); +}); + +describe("DebugPopup (rendered)", () => { + it("clicking a row moves the debug menu selection", async () => { + const messages = Array.from({ length: 5 }, (_, n) => ({ + timestamp: 0, + message: `debug-message-${n}`, + type: "info" as const, + })); + const setup = await testRender( + () => {}} />, + { width: 80, height: 30 }, + ); + await settle(setup); + expect(state.debugMenuIndex).toBe(0); + + const lines = setup.captureCharFrame().split("\n"); + const row = lines.findIndex((line) => line.includes("debug-message-3")); + expect(row).toBeGreaterThanOrEqual(0); + await setup.mockMouse.click(lines[row]!.indexOf("debug-message-3"), row); + await settle(setup); + expect(state.debugMenuIndex).toBe(3); + setup.renderer.destroy(); + }); +}); diff --git a/src/render_tests/selector.test.tsx b/src/render_tests/selector.test.tsx new file mode 100644 index 0000000..e9212b0 --- /dev/null +++ b/src/render_tests/selector.test.tsx @@ -0,0 +1,66 @@ +import { createSignal } from "solid-js"; +import { testRender } from "@opentui/solid"; +import { describe, expect, it, mock } from "bun:test"; +import { Selector } from "@/components/Selector"; + +const items = Array.from({ length: 40 }, (_, n) => ({ name: `item-${n}` })); + +const mount = async (selectedIndex: () => number, extra: Record = {}) => { + const setup = await testRender( + () => ( + ({ name: item.name })} + onSelect={() => {}} + onClose={() => {}} + {...extra} + /> + ), + { width: 60, height: 30 }, + ); + await setup.renderOnce(); + await setup.renderOnce(); + return setup; +}; + +describe("Selector (rendered)", () => { + it("scrolls the initially selected row into view on open", async () => { + const setup = await mount(() => 30); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("item-30"); + setup.renderer.destroy(); + }); + + it("keeps the selection visible as the index moves down and back up", async () => { + const [index, setIndex] = createSignal(0); + const setup = await mount(index); + expect(setup.captureCharFrame()).toContain("item-0"); + setIndex(25); + await setup.renderOnce(); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("item-25"); + expect(setup.captureCharFrame()).not.toContain("item-0 "); + setIndex(2); + await setup.renderOnce(); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("item-2"); + setup.renderer.destroy(); + }); + + it("clicking the footer esc closes without forwarding esc to the parent", async () => { + const onClose = mock(() => {}); + const onShortcutClick = mock((_key: string) => {}); + const setup = await mount(() => 0, { onClose, onShortcutClick }); + const frame = setup.captureCharFrame().split("\n"); + const row = frame.findIndex((line) => line.includes("esc")); + expect(row).toBeGreaterThanOrEqual(0); + await setup.mockMouse.click(frame[row]!.indexOf("esc") + 1, row); + await setup.renderOnce(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onShortcutClick).not.toHaveBeenCalledWith("esc"); + setup.renderer.destroy(); + }); +}); diff --git a/src/services/effects/DriveDetection.ts b/src/services/effects/DriveDetection.ts index 888235b..c268faf 100644 --- a/src/services/effects/DriveDetection.ts +++ b/src/services/effects/DriveDetection.ts @@ -197,6 +197,63 @@ export function parseActivityLine( return Option.none(); } +const DEBOUNCE_MS = 1000; + +/** + * Turns one parsed `diskutil activity` event into a DriveEvent, or none. + * + * Events for the same drive within DEBOUNCE_MS of the last *emitted* event are + * dropped. The timestamp is only recorded when an event is actually emitted: a + * DiskAppeared that arrives before the volume is mounted yields nothing, and + * must not swallow the VolumeMount that follows it moments later. + */ +export const processActivityEvent = ( + event: { type: "Appeared" | "Disappeared"; bsdName: string }, + state: { + readonly driveIdMap: Ref.Ref>; + readonly lastEventTimeMap: Ref.Ref>; + }, + getDetails: (bsdName: string) => Effect.Effect>, + logger: Logger["Service"], + now: number, +): Effect.Effect> => + Effect.gen(function* () { + const { driveIdMap, lastEventTimeMap } = state; + const map = yield* Ref.get(driveIdMap); + + const lastTime = (yield* Ref.get(lastEventTimeMap)).get(event.bsdName) ?? 0; + if (now - lastTime < DEBOUNCE_MS) { + return Option.none(); + } + const recordEmitted = Ref.update(lastEventTimeMap, (m) => new Map(m).set(event.bsdName, now)); + + if (event.type === "Appeared") { + const details = yield* getDetails(event.bsdName); + if (Option.isSome(details)) { + const drive = details.value; + yield* Ref.update(driveIdMap, (m) => new Map(m).set(event.bsdName, drive.id)); + yield* recordEmitted; + yield* logger.info(`Drive appeared/changed: ${drive.name} (${drive.id})`); + return Option.some({ _tag: "Appeared", drive }); + } + 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* recordEmitted; + yield* logger.info(`Drive disappeared: ${driveId}`); + return Option.some({ _tag: "Disappeared", driveId }); + } + return Option.none(); + }); + /** * Live implementation of DriveDetection using macOS diskutil activity and info. */ @@ -295,41 +352,13 @@ export const DriveDetectionLive = Layer.effect( ), 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(); - }), + processActivityEvent( + event, + { driveIdMap, lastEventTimeMap }, + (bsdName) => getDriveDetails(bsdName, logger), + logger, + Date.now(), + ), ), Stream.filterMap((o) => Option.isSome(o) ? Result.succeed(o.value) : Result.fail(undefined), diff --git a/src/services/effects/FileSystem.ts b/src/services/effects/FileSystem.ts index bd3dcd2..8f18287 100644 --- a/src/services/effects/FileSystem.ts +++ b/src/services/effects/FileSystem.ts @@ -66,11 +66,13 @@ export class FileSystem extends Context.Service< readonly writeFile: (path: string, data: Uint8Array) => Effect.Effect; /** Copies a file from src to dest */ readonly copyFile: (src: string, dest: string) => Effect.Effect; + /** Atomically renames (moves) a file, replacing the destination if present */ + readonly rename: (from: string, to: string) => Effect.Effect; /** Creates a directory recursively */ readonly mkdir: (path: string) => Effect.Effect; /** Removes a file or directory recursively */ readonly remove: (path: string) => Effect.Effect; - /** Reads directory entries using Bun.Glob */ + /** Reads visible directory entries (files and directories) using Bun.Glob */ readonly readDir: (path: string) => Effect.Effect; /** Reads directory entries using node:fs/promises */ readonly list: (path: string) => Effect.Effect; @@ -101,6 +103,16 @@ export class FileSystem extends Context.Service< } >()("FileSystem") {} +/** + * Extension of the final path segment only, so "/Volumes/My.Drive/episode" has no + * extension. Falls back to .mp3 when the basename has none. + */ +const getExtensionImpl = (path: string): string => { + const base = path.slice(path.lastIndexOf("/") + 1); + const lastDot = base.lastIndexOf("."); + return lastDot > 0 && lastDot < base.length - 1 ? base.slice(lastDot) : ".mp3"; +}; + const isSystemHiddenFileImpl = (name: string): boolean => { if (SYSTEM_HIDDEN_FILES.includes(name)) return true; return name.startsWith("._"); @@ -178,12 +190,25 @@ export const FileSystemLive = Layer.effect( }).pipe(Effect.tapError((err) => logger.error(`Failed to remove path: ${path}`, err))); }), + rename: (from, to) => + Effect.tryPromise({ + try: async () => { + const fs = await import("node:fs/promises"); + await fs.rename(from, to); + }, + catch: (cause) => new WriteError({ path: to, cause }), + }), + readDir: (path) => Effect.tryPromise({ try: async () => { + // Directories included (the default is files only), otherwise callers + // such as SyncEngine.cleanup never see show directories. Dot entries + // stay excluded: system hidden files are handled by + // cleanupSystemHiddenFiles, and user dot-folders are left alone. const glob = new Bun.Glob("*"); const files: string[] = []; - for await (const file of glob.scan({ cwd: path })) { + for await (const file of glob.scan({ cwd: path, onlyFiles: false })) { files.push(file); } return files; @@ -258,10 +283,7 @@ export const FileSystemLive = Layer.effect( return AUDIO_EXTENSIONS.has(ext); }, - getExtension: (path) => { - const lastDot = path.lastIndexOf("."); - return lastDot >= 0 ? path.slice(lastDot) : ".mp3"; - }, + getExtension: getExtensionImpl, cleanupSystemHiddenFiles: (dirPath) => Effect.gen(function* () { @@ -340,6 +362,16 @@ export const createFileSystemTest = (files: Map = new Map()) return Effect.void; }, + rename: (from, to) => { + const data = files.get(from); + if (!data) { + return Effect.fail(new WriteError({ path: to, cause: "Source not found" })); + } + files.set(to, data); + files.delete(from); + return Effect.void; + }, + readDir: (path) => { const entries = Array.from(files.keys()) .filter((f) => f.startsWith(path) && f !== path) @@ -403,10 +435,7 @@ export const createFileSystemTest = (files: Map = new Map()) return AUDIO_EXTENSIONS.has(ext); }, - getExtension: (path) => { - const lastDot = path.lastIndexOf("."); - return lastDot >= 0 ? path.slice(lastDot) : ".mp3"; - }, + getExtension: getExtensionImpl, cleanupSystemHiddenFiles: (_dirPath) => Effect.void, diff --git a/src/services/effects/MetadataEditor.ts b/src/services/effects/MetadataEditor.ts index 40777cc..4f0a818 100644 --- a/src/services/effects/MetadataEditor.ts +++ b/src/services/effects/MetadataEditor.ts @@ -44,8 +44,10 @@ export const MetadataEditorLive = Layer.succeed(MetadataEditor, { }, year: metadata.year, }; - // Use the Promise API to avoid blocking the main thread (and the TUI) - return nodeID3.Promise.write(tags, path); + // Use the Promise API to avoid blocking the main thread (and the TUI). + // update() merges into existing tags; write() would replace them all + // and drop cover art, chapters and other frames already in the file. + return nodeID3.Promise.update(tags, path); }, catch: (cause) => new MetadataError({ path, cause }), }), diff --git a/src/services/effects/PodcastService.ts b/src/services/effects/PodcastService.ts index 9c6214e..4390556 100644 --- a/src/services/effects/PodcastService.ts +++ b/src/services/effects/PodcastService.ts @@ -70,8 +70,16 @@ 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.catch((err) => Effect.fail(new PodcastError({ cause: err })))), + // `new Worker` can throw synchronously (bad URL, resource limits); + // capture that as a typed PodcastError rather than a defect. + return yield* Effect.try({ + try: () => new Worker(workerUrl), + catch: (cause) => + new PodcastError({ cause, message: "Failed to start database worker" }), + }).pipe( + Effect.tapError((err) => logger.error("Failed to start database worker", err.cause)), + ); + }), // Use: Perform the async work (worker) => Effect.callback((resume) => { diff --git a/src/services/effects/SettingsService.ts b/src/services/effects/SettingsService.ts index 2f82792..a21f39c 100644 --- a/src/services/effects/SettingsService.ts +++ b/src/services/effects/SettingsService.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { Context, Effect, Layer } from "effect"; +import { Context, Effect, Layer, Option } from "effect"; import { FileSystem } from "@/services/effects/FileSystem"; import { Logger } from "@/services/effects/Logger"; @@ -36,40 +36,60 @@ export const SettingsServiceLive = Layer.effect( const fs = yield* FileSystem; const logger = yield* Logger; + const DEFAULTS: Settings = { theme: "Catppuccin", favoriteDrives: [] }; + + /** + * Reads the settings file. A missing file is `None` (use defaults); a file + * that exists but cannot be read or parsed is an error, so callers that + * would overwrite it can refuse. + */ + const readSettings = Effect.gen(function* () { + if (!(yield* fs.exists(SETTINGS_FILE))) return Option.none(); + const data = yield* fs + .readFile(SETTINGS_FILE) + .pipe(Effect.mapError((cause) => new Error(`Failed to read settings: ${cause.path}`))); + const parsed = yield* Effect.try({ + try: () => { + const text = new TextDecoder().decode(data); + // Strip single-line and multi-line comments from JSONC + const cleanJson = text.replace( + /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, + (m, g) => (g ? "" : m), + ); + const value: unknown = JSON.parse(cleanJson); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("expected a JSON object"); + } + return value as Settings; + }, + catch: (cause) => new Error(`Failed to parse settings: ${cause}`), + }); + return Option.some(parsed); + }); + const loadSettings = Effect.gen(function* () { yield* logger.debug(`Loading settings from: ${SETTINGS_FILE}`); - const result = yield* fs.readFile(SETTINGS_FILE).pipe( - Effect.flatMap((data) => - Effect.try({ - try: () => { - const text = new TextDecoder().decode(data); - // Strip single-line and multi-line comments from JSONC - const cleanJson = text.replace( - /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, - (m, g) => (g ? "" : m), - ); - return JSON.parse(cleanJson) as Settings; - }, - catch: (cause) => new Error(`Failed to parse settings: ${cause}`), - }), - ), - Effect.catch(() => Effect.succeed(null)), + const result = yield* readSettings.pipe( + Effect.tapError((err) => logger.error("Could not load settings, using defaults", err)), + Effect.catch(() => Effect.succeed(Option.none())), ); - if (result) { + if (Option.isSome(result)) { yield* logger.info("Settings loaded successfully"); - return result; + return result.value; } yield* logger.info("Using default settings (could not load or parse settings file)"); - return { theme: "Catppuccin", favoriteDrives: [] } as Settings; + return { ...DEFAULTS } as Settings; }); const saveSettings = (newSettings: Partial) => Effect.gen(function* () { yield* logger.debug(`Saving settings to: ${SETTINGS_FILE}`); - const currentSettings = yield* loadSettings; - const mergedSettings = { ...currentSettings, ...newSettings }; + // Strict read: if the file exists but is unreadable/corrupt, fail rather + // than overwrite the user's (possibly hand-edited) settings with defaults. + const current = yield* readSettings; + const mergedSettings = { ...(Option.getOrUndefined(current) ?? DEFAULTS), ...newSettings }; const dirExists = yield* fs.exists(SETTINGS_DIR); if (!dirExists) { @@ -81,7 +101,13 @@ export const SettingsServiceLive = Layer.effect( ...mergedSettings, }; const data = new TextEncoder().encode(JSON.stringify(settingsWithSchema, null, 2)); - yield* fs.writeFile(SETTINGS_FILE, data); + // Write to a temp file and rename so a crash mid-write never leaves a + // truncated settings file behind. + const tmpFile = `${SETTINGS_FILE}.${process.pid}.tmp`; + yield* fs.writeFile(tmpFile, data); + yield* fs + .rename(tmpFile, SETTINGS_FILE) + .pipe(Effect.tapError(() => fs.remove(tmpFile).pipe(Effect.ignore))); yield* logger.info("Settings saved successfully"); }).pipe( Effect.tapError((err) => logger.error("Failed to save settings", err)), diff --git a/src/services/effects/SyncEngine.ts b/src/services/effects/SyncEngine.ts index ab4dd11..6fcb6de 100644 --- a/src/services/effects/SyncEngine.ts +++ b/src/services/effects/SyncEngine.ts @@ -83,13 +83,50 @@ export function formatDestPath( } /** - * Copies a file using node:fs handles and emits the number of bytes written in chunks. - * Cleans up partial files on failure or interruption. + * Makes destination paths unique within one plan. Two episodes can sanitize to the + * same path (same show/title/date, or truncated names), and the second copy would + * overwrite the first. The later one gets " (2)", " (3)", ... before the extension. + * Comparison is case-insensitive because the drive filesystems (FAT/exFAT/APFS) + * usually are. Deterministic: depends only on input order. + */ +export function dedupeDestPath(destPath: string, extension: string, used: Set): string { + const base = + extension && destPath.endsWith(extension) ? destPath.slice(0, -extension.length) : destPath; + const ext = base === destPath ? "" : extension; + let candidate = destPath; + let n = 2; + while (used.has(candidate.toLowerCase())) { + candidate = `${base} (${n})${ext}`; + n += 1; + } + used.add(candidate.toLowerCase()); + return candidate; +} + +/** Only MP3 carries ID3; tagging anything else (e.g. .m4a) would corrupt it. */ +const isMp3 = (path: string): boolean => path.toLowerCase().endsWith(".mp3"); + +/** Best-effort removal of a leftover file; never fails. */ +const removeQuietly = (path: string): Effect.Effect => + Effect.tryPromise(() => fs.rm(path, { force: true })).pipe(Effect.catch(() => Effect.void)); + +/** In-progress copies live at `${dest}.part` and are renamed into place once complete. */ +const partPathOf = (dest: string): string => `${dest}.part`; + +/** + * Copies `src` to `${dest}.part` using node:fs handles and emits the number of bytes + * written in chunks. The caller owns the finished .part file (tag it, then rename it + * to `dest`), which is why a final file on the drive is complete by construction. + * The .part is removed on failure or interruption of the copy itself, and any stale + * one from an earlier crashed run is removed before copying. */ const copyFileStream = (src: string, dest: string): Stream.Stream => Stream.scoped( Stream.unwrap( Effect.gen(function* () { + const partPath = partPathOf(dest); + let finished = false; + const srcHandle = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => fs.open(src, "r"), @@ -98,25 +135,20 @@ const copyFileStream = (src: string, dest: string): Stream.Stream Effect.promise(() => handle.close()), ); + yield* removeQuietly(partPath); + const destHandle = yield* Effect.acquireRelease( Effect.tryPromise({ - try: () => fs.open(dest, "w"), + try: () => fs.open(partPath, "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), - ); + // Failed or interrupted before the source was fully read: drop the + // partial. Nothing ever appears at `dest` until the caller renames. + if (!finished) { + yield* removeQuietly(partPath); } }), ); @@ -129,7 +161,10 @@ const copyFileStream = (src: string, dest: string): Stream.Stream new SyncCopyError({ src, dest, cause }), }).pipe( Effect.flatMap(({ bytesRead }): Effect.Effect => { - if (bytesRead === 0) return Cause.done(); + if (bytesRead === 0) { + finished = true; + return Cause.done(); + } return Effect.tryPromise({ try: () => destHandle.write(buffer.subarray(0, bytesRead)), catch: (cause) => new SyncCopyError({ src, dest, cause }), @@ -171,6 +206,7 @@ export const SyncEngineLive = Layer.effect( podcast: (typeof mac)[number]; fullDestPath: string; }> = []; + const usedDestPaths = new Set(); for (const podcast of mac) { for (const episode of podcast.episodes) { @@ -178,12 +214,12 @@ export const SyncEngineLive = Layer.effect( if (matcher.matchEpisode(podcast.title, episode, driveIndex)) continue; const ext = fs.getExtension(episode.filePath); - const fullDestPath = formatDestPath( - drivePath, - podcast.title, - episode.title, - episode.published, + // Deduped before the existence check so the " (2)" assignment is + // stable across runs, whatever is already on the drive. + const fullDestPath = dedupeDestPath( + formatDestPath(drivePath, podcast.title, episode.title, episode.published, ext), ext, + usedDestPaths, ); candidates.push({ episode, podcast, fullDestPath }); } @@ -193,10 +229,22 @@ export const SyncEngineLive = Layer.effect( candidates, ({ episode, podcast, fullDestPath }) => Effect.gen(function* () { + // getFileSize yields 0 for a missing/unreadable file. Planning such + // an item would fail the whole execute stream at copy time. + const sSize = yield* fs.getFileSize(episode.filePath); + if (sSize <= 0) { + yield* logger.info( + `Warning: skipping "${episode.title}", source is missing or empty: ${episode.filePath}`, + ); + return Option.none(); + } + + // Copies land at `${dest}.part` and are renamed into place only once + // complete (and tagged), so any non-empty file at the final path is + // whole. Do not compare sizes: ID3 tagging legitimately changes them. const dSize = yield* fs.getFileSize(fullDestPath); if (dSize > 0) return Option.none(); - const sSize = yield* fs.getFileSize(episode.filePath); return Option.some({ episode, podcast, @@ -214,7 +262,12 @@ export const SyncEngineLive = Layer.effect( totalBytes += opt.value.size; } } - const plan: SyncPlan = { toCopy, toDelete: [], totalFiles: toCopy.length, totalBytes }; + const plan: SyncPlan = { + toCopy, + toDelete: [], + totalFiles: toCopy.length, + totalBytes, + }; yield* logger.info( `Sync plan created: ${plan.totalFiles} files to copy, ${plan.totalBytes} bytes total`, ); @@ -248,7 +301,7 @@ export const SyncEngineLive = Layer.effect( status: "syncing", }); - const copyFlow = Stream.fromEffect( + const copyOnly = Stream.fromEffect( Effect.gen(function* () { yield* logger.debug( `Starting copy of [${i + 1}/${plan.totalFiles}]: ${item.episode.title}`, @@ -274,72 +327,99 @@ export const SyncEngineLive = Layer.effect( }), ), ), - Stream.concat( - Stream.fromEffect( - Effect.sync((): SyncProgress => ({ - // Emit a progress update to show we are tagging - currentFile: `Tagging: ${item.episode.title}`, - currentIndex: i + 1, - totalFiles: plan.totalFiles, - bytesTransferred: bytesWritten, - totalBytes: plan.totalBytes, - discarded, - startTime, - status: "syncing", - })), - ), - ), - Stream.concat( - Stream.fromEffect( - Effect.gen(function* () { - const metadataEditor = yield* MetadataEditor; - const fs = yield* FileSystem; - yield* logger.debug(`Tagging episode: ${item.episode.title}`); - yield* metadataEditor - .write(item.destPath, { - title: item.episode.title, - artist: item.podcast.author, - album: item.podcast.title, - genre: "Podcast", - year: item.episode.published.getFullYear().toString(), - comment: `Published: ${item.episode.published.toISOString().split("T")[0]}`, - }) - .pipe( - 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}, discarding copied file: ${item.destPath}`, - err, - ), - ), - Effect.catch(() => - Effect.suspend(() => { - // Count it even if the unlink itself fails — either way - // the file did not make it onto the drive correctly. - discarded += 1; - return fs.remove(item.destPath).pipe( - // createPlan skips any dest path that exists, so a - // leftover here is never re-copied. Worth a line. - Effect.tapError((err) => - logger.error( - `Failed to discard untagged file: ${item.destPath}`, - err, - ), - ), - Effect.catch(() => Effect.void), - ); - }), - ), - ); - }), - ).pipe(Stream.filterMap(() => Result.fail(undefined))), - ), ); + const partPath = partPathOf(item.destPath); + + // Moves the finished .part into place. Any failure or interruption + // here drops the .part so nothing half-done is left on the drive. + const finalize = Effect.gen(function* () { + const fs = yield* FileSystem; + yield* fs.rename(partPath, item.destPath); + }).pipe(Effect.onError(() => removeQuietly(partPath))); + + // Only MP3s carry ID3. Tagging .m4a etc. prepends an ID3 block and + // corrupts the file, so those are copied untouched. + const copyFlow = !isMp3(item.destPath) + ? copyOnly.pipe(Stream.concat(Stream.fromEffect(finalize).pipe(Stream.drain))) + : copyOnly.pipe( + Stream.concat( + Stream.fromEffect( + Effect.sync((): SyncProgress => ({ + // Emit a progress update to show we are tagging + currentFile: `Tagging: ${item.episode.title}`, + currentIndex: i + 1, + totalFiles: plan.totalFiles, + bytesTransferred: bytesWritten, + totalBytes: plan.totalBytes, + discarded, + startTime, + status: "syncing", + })), + ), + ), + Stream.concat( + Stream.fromEffect( + Effect.gen(function* () { + const metadataEditor = yield* MetadataEditor; + const fs = yield* FileSystem; + yield* logger.debug(`Tagging episode: ${item.episode.title}`); + // Tag the .part so the final path only ever holds a finished file. + const tagged = yield* metadataEditor + .write(partPath, { + title: item.episode.title, + artist: item.podcast.author, + album: item.podcast.title, + genre: "Podcast", + // An invalid Date makes toISOString throw (a defect that + // would kill the stream), so omit date-derived tags. + ...(Number.isNaN(item.episode.published.getTime()) + ? {} + : { + year: item.episode.published.getFullYear().toString(), + comment: `Published: ${item.episode.published.toISOString().split("T")[0]}`, + }), + }) + .pipe( + Effect.tap(() => + logger.debug(`Successfully tagged: ${item.episode.title}`), + ), + Effect.as(true), + // A failed tag never fails the sync, but a half-tagged file must + // not reach the drive, so discard the .part and let the next + // run re-copy. + Effect.tapError((err) => + logger.error( + `Failed to tag: ${item.episode.title}, discarding copied file: ${item.destPath}`, + err, + ), + ), + Effect.catch(() => + Effect.suspend(() => { + // Count it even if the removal itself fails: either way + // the file did not make it onto the drive correctly. + discarded += 1; + return fs.remove(partPath).pipe( + Effect.tapError((err) => + logger.error( + `Failed to discard untagged file: ${partPath}`, + err, + ), + ), + Effect.catch(() => Effect.void), + Effect.as(false), + ); + }), + ), + // Interrupted mid-tag: do not leave the .part behind. + Effect.onInterrupt(() => removeQuietly(partPath)), + ); + if (tagged) yield* finalize; + }), + ).pipe(Stream.filterMap(() => Result.fail(undefined))), + ), + ); + return Stream.concat(initialProgress, copyFlow); }), Stream.concat( @@ -378,6 +458,9 @@ export const SyncEngineLive = Layer.effect( ), Stream.runDrain, ); + yield* fs + .rename(partPathOf(dest), dest) + .pipe(Effect.onError(() => removeQuietly(partPathOf(dest)))); }), cleanup: (drivePath) => @@ -447,7 +530,12 @@ export const createSyncEngineTest = (mockFiles: Map = new Ma totalBytes += sSize; } } - return { toCopy, toDelete: [], totalFiles: toCopy.length, totalBytes } satisfies SyncPlan; + return { + toCopy, + toDelete: [], + totalFiles: toCopy.length, + totalBytes, + } satisfies SyncPlan; }), execute: (plan) => { const startTime = Date.now(); diff --git a/src/services/workers/db.worker.ts b/src/services/workers/db.worker.ts index 850dfde..6c5bbad 100644 --- a/src/services/workers/db.worker.ts +++ b/src/services/workers/db.worker.ts @@ -24,7 +24,8 @@ self.onmessage = (event: MessageEvent) => { const rows = yield* Effect.try({ try: () => { const db = new Database(dbPath, { readonly: true }); - const query = ` + try { + const query = ` SELECT e.Z_PK as id, e.ZTITLE as title, @@ -38,9 +39,10 @@ self.onmessage = (event: MessageEvent) => { WHERE e.ZASSETURL IS NOT NULL ORDER BY e.ZPUBDATE DESC `; - const results = db.query(query).all() as EpisodeRow[]; - db.close(); - return results; + return db.query(query).all() as EpisodeRow[]; + } finally { + db.close(); + } }, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }); diff --git a/src/store/index.ts b/src/store/index.ts index 15299fd..362b81a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -76,6 +76,9 @@ const updateState = (update: (draft: AppState) => void): void => { setState(update); }; +const clampIndex = (index: number, length: number): number => + Math.min(Math.max(0, index), Math.max(0, length - 1)); + const resolveUpdate = (value: T | ((previous: T) => T), previous: T): T => typeof value === "function" ? (value as (previous: T) => T)(previous) : value; @@ -112,13 +115,18 @@ export const actions = { episodes: PodcastEpisode[] | ((previous: PodcastEpisode[]) => PodcastEpisode[]), ) => updateState((draft) => { - draft.macPodcasts = resolveUpdate(episodes, draft.macPodcasts); + const next = resolveUpdate(episodes, draft.macPodcasts); + draft.macPodcasts = next; + // The list may have shrunk under the cursor; keep it on a real row. + draft.macIndex = clampIndex(draft.macIndex, next.length); }), setDrivePodcasts: ( episodes: PodcastEpisode[] | ((previous: PodcastEpisode[]) => PodcastEpisode[]), ) => updateState((draft) => { - draft.drivePodcasts = resolveUpdate(episodes, draft.drivePodcasts); + const next = resolveUpdate(episodes, draft.drivePodcasts); + draft.drivePodcasts = next; + draft.driveIndex = clampIndex(draft.driveIndex, next.length); }), setDrives: (drives: Drive[] | ((previous: Drive[]) => Drive[])) => updateState((draft) => { diff --git a/src/utils/driveSelection.ts b/src/utils/driveSelection.ts index d5b1296..b94dce1 100644 --- a/src/utils/driveSelection.ts +++ b/src/utils/driveSelection.ts @@ -3,6 +3,8 @@ import type { Drive } from "@/types/drive"; export const selectDrive = (drive: Drive, loadDrivePodcasts: (drive: Drive) => void): void => { actions.setCurrentDrive(drive); + // The previous drive's listing must not show under the new drive while it loads. + actions.setDrivePodcasts([]); actions.setErrorMsg(""); loadDrivePodcasts(drive); actions.setAppView("normal"); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index a5c9ef6..d9e8018 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -33,9 +33,16 @@ export function formatBytes(rawBytes: number): string { return `${value.toFixed(1)} ${BYTES_UNITS[unitIndex]}`; } +/** Maximum length of a sanitized name, in code points. */ +const MAX_FILENAME_LENGTH = 100; + /** * Helper to sanitize filenames for FAT32 compatibility. * Replaces invalid characters with dashes and normalizes spaces. + * + * The result is NFC-normalized. HFS+ hands back NFD names from readdir while + * Podcasts.app metadata is typically NFC, so keys built from the two sides only + * match if both are normalized to the same form. */ export function sanitizeFilename(name: string): string { if (!name || name.trim().length === 0) { @@ -44,7 +51,7 @@ export function sanitizeFilename(name: string): string { // Replace invalid chars with - // < > : " / \ | ? * - let sanitized = name.replace(/[<>:"/\\|?*]/g, "-"); + let sanitized = name.normalize("NFC").replace(/[<>:"/\\|?*]/g, "-"); sanitized = sanitized.replace(/&/g, "and"); sanitized = sanitized.replace(/'/g, ""); @@ -54,12 +61,19 @@ export function sanitizeFilename(name: string): string { return "untitled"; } - // Truncate to 100 chars (FAT32 limit is higher but this is safe) - if (sanitized.length > 100) { - sanitized = sanitized.slice(0, 100); + // Truncate by code point: slicing UTF-16 units can cut a surrogate pair in + // half and leave a lone surrogate, which is not a valid filename. + const codePoints = Array.from(sanitized); + if (codePoints.length > MAX_FILENAME_LENGTH) { + sanitized = codePoints.slice(0, MAX_FILENAME_LENGTH).join(""); + } + + // "." and ".." (or any run of dots) are directory references, not names. + if (/^\.+$/.test(sanitized)) { + return "_"; } - return sanitized; + return sanitized.normalize("NFC"); } /** diff --git a/src/utils/plist.ts b/src/utils/plist.ts index 5cabc00..481fe9e 100644 --- a/src/utils/plist.ts +++ b/src/utils/plist.ts @@ -18,6 +18,12 @@ export interface PlistDict { [key: string]: PlistValue; } +/** `` content: optional sign, digits with optional fraction, optional exponent. */ +const REAL_PATTERN = /^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*<\/real>/; + +/** `` and ``: an empty string written as an empty element. */ +const SELF_CLOSING_STRING = /^/; + /** * Parses Apple plist XML format from diskutil output. * Supports dict, array, string, integer, real, boolean, date, and data tags. @@ -60,6 +66,10 @@ function parseElement(xml: string): PlistValue { return parseArray(trimmed); } + if (SELF_CLOSING_STRING.test(trimmed)) { + return ""; + } + const stringMatch = trimmed.match(/^([\s\S]*?)<\/string>/); if (stringMatch) { return decodeXmlEntities(stringMatch[1] ?? ""); @@ -70,7 +80,7 @@ function parseElement(xml: string): PlistValue { return Number.parseInt(intMatch[1] ?? "0", 10); } - const realMatch = trimmed.match(/^(-?[\d.]+)<\/real>/); + const realMatch = trimmed.match(REAL_PATTERN); if (realMatch) { return Number.parseFloat(realMatch[1] ?? "0"); } @@ -159,7 +169,14 @@ function parseDict(xml: string): PlistDict { const valueStart = pos; const valueResult = extractNextValue(content, valueStart); - if (!valueResult) break; + if (!valueResult) { + // One value this parser does not understand must not take the rest of + // the dict with it. Drop this key and resume at the next one. + const nextKey = content.indexOf("", valueStart); + if (nextKey === -1) break; + pos = nextKey; + continue; + } result[key] = valueResult.value; pos = valueResult.endPos; @@ -188,6 +205,11 @@ function extractNextValue( return { value: false, endPos: pos + 9 }; } + const emptyString = remaining.match(SELF_CLOSING_STRING); + if (emptyString) { + return { value: "", endPos: pos + emptyString[0].length }; + } + const stringMatch = remaining.match(/^([\s\S]*?)<\/string>/); if (stringMatch) { return { @@ -204,7 +226,7 @@ function extractNextValue( }; } - const realMatch = remaining.match(/^(-?[\d.]+)<\/real>/); + const realMatch = remaining.match(REAL_PATTERN); if (realMatch) { return { value: Number.parseFloat(realMatch[1] ?? "0"), @@ -319,15 +341,28 @@ function findClosingTag(xml: string, tagName: string): number { return xml.length; } +/** + * Decodes the five predefined XML entities and numeric character references. + * + * One pass over the input, so decoded output is never decoded again: the literal + * text "&lt;" becomes "<", not "<". Chaining replaces with & first + * gets this wrong. + */ function decodeXmlEntities(str: string): string { - return str - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'"); + return str.replace( + /&(?:#x([0-9a-fA-F]+)|#(\d+)|(amp|lt|gt|quot|apos));/g, + (match, hex, dec, named) => { + if (named) return NAMED_ENTITIES[named as keyof typeof NAMED_ENTITIES]; + const codePoint = hex ? Number.parseInt(hex, 16) : Number.parseInt(dec, 10); + // Out of range (or a lone surrogate) is not a character; leave it as written. + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return match; + return String.fromCodePoint(codePoint); + }, + ); } +const NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" } as const; + /** * Gets a string value from a PlistDict. */ From 3afa4703f1ea261f2c00bf3056edcb1e6c953a53 Mon Sep 17 00:00:00 2001 From: jonathancrangle <94425204+joncrangle@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:46:23 -0400 Subject: [PATCH 15/15] fix: match episodes by date, refresh free space, serialize settings saves - Match Mac episodes to drive files on show + publish date + title, so a new episode sharing an older one's title ("Trailer") is still synced. Undated legacy files and " (2)" dedupe suffixes still match; drive names are NFC-normalized; filename dates parse as local time. - Re-read the drive's free space (statfs) before the space check and after sync/delete, instead of using the value from detection. - Serialize settings saves so concurrent favorite/theme saves can't lose an update. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/drive_matching.test.ts | 220 +++++++++++++++++++++ src/__tests__/effects.test.ts | 22 ++- src/__tests__/services_regressions.test.ts | 36 ++++ src/__tests__/useAppLogic.test.ts | 145 ++++++++++++++ src/hooks/useAppLogic.ts | 44 ++++- src/services/effects/DriveDetection.ts | 28 ++- src/services/effects/DriveScan.ts | 61 ++++-- src/services/effects/EpisodeMatcher.ts | 46 +++-- src/services/effects/SettingsService.ts | 65 +++--- src/types/podcast.ts | 6 + src/utils/formatting.ts | 48 ++++- 11 files changed, 642 insertions(+), 79 deletions(-) create mode 100644 src/__tests__/drive_matching.test.ts diff --git a/src/__tests__/drive_matching.test.ts b/src/__tests__/drive_matching.test.ts new file mode 100644 index 0000000..812ddbf --- /dev/null +++ b/src/__tests__/drive_matching.test.ts @@ -0,0 +1,220 @@ +/** + * Regression tests: episodes are matched to files on the drive by show + publish + * date + title, not title alone. Exercises the real DriveScan, EpisodeMatcher and + * SyncEngine.createPlan against a temp directory. + */ + +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer } from "effect"; +import { DriveScan, DriveScanLive } from "@/services/effects/DriveScan"; +import { EpisodeMatcher, EpisodeMatcherLive } from "@/services/effects/EpisodeMatcher"; +import { FileSystemLive } from "@/services/effects/FileSystem"; +import { LoggerLive } from "@/services/effects/Logger"; +import { createMetadataEditorTest } from "@/services/effects/MetadataEditor"; +import { SyncEngine, SyncEngineLive } from "@/services/effects/SyncEngine"; +import type { Episode, Podcast } from "@/types/podcast"; +import { formatDate, markEpisodesOnDrive, mapPodcastsToEpisodes } from "@/utils/formatting"; + +const root = await mkdtemp(join(tmpdir(), "podapple-match-")); +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +let counter = 0; +/** Creates a fake drive with the given files under Podcasts/ and a Mac source dir. */ +const makeDrive = async (files: string[]) => { + const dir = join(root, `case-${counter++}`); + const drive = join(dir, "drive"); + const mac = join(dir, "mac"); + await mkdir(mac, { recursive: true }); + for (const f of files) { + await Bun.write(join(drive, "Podcasts", f), new Uint8Array([1, 2, 3])); + } + return { drive, mac }; +}; + +const layer = Layer.mergeAll( + DriveScanLive, + EpisodeMatcherLive, + FileSystemLive, + SyncEngineLive, + createMetadataEditorTest(), +).pipe(Layer.provideMerge(LoggerLive)); + +const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d, 9, 30); + +const macEpisode = async ( + mac: string, + id: string, + title: string, + published: Date, +): Promise => { + const filePath = join(mac, `${id}.mp3`); + await Bun.write(filePath, new Uint8Array([9, 9, 9, 9])); + return { + id, + title, + author: "Author", + duration: 0, + published, + onDrive: false, + filePath, + fileSize: 4, + }; +}; + +const podcastOf = (title: string, episodes: Episode[]): Podcast => ({ + id: title, + title, + author: "Author", + episodeCount: episodes.length, + episodes, +}); + +/** Scans the drive, builds the index and plans a sync of `podcasts`. */ +const plan = (drive: string, podcasts: Podcast[]) => + Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const index = yield* (yield* DriveScan).buildDriveIndex(drive); + return yield* (yield* SyncEngine).createPlan(podcasts, drive, index); + }), + layer, + ), + ); + +describe("drive matching includes the publish date", () => { + it("same title, different date: the newer episode is not on the drive and is planned", async () => { + const { drive, mac } = await makeDrive(["Show/2024-01-01 - Trailer.mp3"]); + const older = await macEpisode(mac, "old", "Trailer", localDate(2024, 1, 1)); + const newer = await macEpisode(mac, "new", "Trailer", localDate(2024, 6, 15)); + + const result = await plan(drive, [podcastOf("Show", [older, newer])]); + expect(result.toCopy.map((c) => c.episode.id)).toEqual(["new"]); + expect(result.toCopy[0]?.destPath).toBe(`${drive}/Podcasts/Show/2024-06-15 - Trailer.mp3`); + }); + + it("same title, different date: only the matching date is marked onDrive", async () => { + const { drive, mac } = await makeDrive(["Show/2024-01-01 - Trailer.mp3"]); + const older = await macEpisode(mac, "old", "Trailer", localDate(2024, 1, 1)); + const newer = await macEpisode(mac, "new", "Trailer", localDate(2024, 6, 15)); + + const scanned = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* DriveScan).scanDrive(drive); + }), + layer, + ), + ); + const driveEpisodes = mapPodcastsToEpisodes(scanned).map((e) => ({ + ...e, + showName: "Show", + })); + const marked = markEpisodesOnDrive( + [older, newer].map((e) => ({ ...e, showName: "Show", selected: false })), + driveEpisodes, + ); + expect(marked.map((e) => [e.id, e.onDrive])).toEqual([ + ["old", true], + ["new", false], + ]); + }); + + it("same title and same date: matched, nothing planned", async () => { + const { drive, mac } = await makeDrive(["Show/2024-01-01 - Trailer.mp3"]); + const ep = await macEpisode(mac, "a", "Trailer", localDate(2024, 1, 1)); + + const result = await plan(drive, [podcastOf("Show", [ep])]); + expect(result.toCopy).toHaveLength(0); + }); + + it("a '(2)' deduped file still matches its episode", async () => { + const { drive, mac } = await makeDrive([ + "Show/2024-01-01 - Trailer.mp3", + "Show/2024-03-03 - Bonus (2).mp3", + ]); + // The "(2)" copy exists on the drive, the undeduped base name does not. + const bonus = await macEpisode(mac, "b", "Bonus", localDate(2024, 3, 3)); + const other = await macEpisode(mac, "c", "Bonus", localDate(2024, 3, 4)); + + const result = await plan(drive, [podcastOf("Show", [bonus, other])]); + expect(result.toCopy.map((c) => c.episode.id)).toEqual(["c"]); + + const scanned = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* DriveScan).scanDrive(drive); + }), + layer, + ), + ); + const marked = markEpisodesOnDrive( + [bonus, other].map((e) => ({ ...e, showName: "Show", selected: false })), + mapPodcastsToEpisodes(scanned).map((e) => ({ ...e, showName: "Show" })), + ); + expect(marked.map((e) => e.onDrive)).toEqual([true, false]); + }); + + it("Unknown dates round-trip: an Unknown episode matches 'Unknown - Title' only", async () => { + const { drive, mac } = await makeDrive(["Show/Unknown - Trailer.mp3"]); + const unknown = await macEpisode(mac, "u", "Trailer", new Date(NaN)); + const dated = await macEpisode(mac, "d", "Trailer", localDate(2024, 1, 1)); + expect(formatDate(unknown.published)).toBe("Unknown"); + + const result = await plan(drive, [podcastOf("Show", [unknown, dated])]); + expect(result.toCopy.map((c) => c.episode.id)).toEqual(["d"]); + }); + + it("legacy undated drive files still match by show and title", async () => { + const { drive, mac } = await makeDrive(["Show/Trailer.mp3"]); + const ep = await macEpisode(mac, "a", "Trailer", localDate(2024, 1, 1)); + + const result = await plan(drive, [podcastOf("Show", [ep])]); + expect(result.toCopy).toHaveLength(0); + }); + + it("the matcher keys the date from a Date and from the filename literal identically", async () => { + const keys = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const matcher = yield* EpisodeMatcher; + return [ + matcher.buildExpectedDrivePath("Show", "Ep", localDate(2024, 12, 31)), + matcher.buildExpectedDrivePath("Show", "Ep", "2024-12-31"), + ]; + }), + EpisodeMatcherLive, + ), + ); + expect(keys[0]).toBe("Show/2024-12-31 - Ep.mp3"); + expect(keys[1]).toBe(keys[0]!); + }); +}); + +describe("DriveScan.scanDrive show-name normalization", () => { + it("groups an NFD show directory with an NFC one", async () => { + const nfc = "Café"; + const nfd = "Café"; + expect(nfc).not.toBe(nfd); + const { drive } = await makeDrive([ + `${nfc}/2024-01-01 - One.mp3`, + `${nfd}/2024-01-02 - Two.mp3`, + ]); + + const podcasts = await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + return yield* (yield* DriveScan).scanDrive(drive); + }), + layer, + ), + ); + expect(podcasts).toHaveLength(1); + expect(podcasts[0]?.id).toBe(nfc); + expect(podcasts[0]?.episodes).toHaveLength(2); + }); +}); diff --git a/src/__tests__/effects.test.ts b/src/__tests__/effects.test.ts index 1d490fa..86c64fb 100644 --- a/src/__tests__/effects.test.ts +++ b/src/__tests__/effects.test.ts @@ -596,7 +596,7 @@ describe("DriveScan Service", () => { // Check index has correct key expect(result.size).toBe(1); - expect(result.has("Tech_Talk/Episode 1")).toBe(true); + expect(result.has("Tech_Talk/Episode_1.mp3")).toBe(true); }); }); @@ -727,8 +727,8 @@ describe("DriveScan Service", () => { ); expect(result.size).toBe(3); - expect(result.has("Tech_Talk/Episode_1.mp3")).toBe(true); - expect(result.has("Tech_Talk/Episode_2.mp3")).toBe(true); + expect(result.has("Tech_Talk/2024-01-15 - Episode_1.mp3")).toBe(true); + expect(result.has("Tech_Talk/2024-01-22 - Episode_2.mp3")).toBe(true); expect(result.has("Tech_Talk/InvalidDate_-_Episode_3.mp3")).toBe(true); }); @@ -761,7 +761,7 @@ describe("DriveScan Service", () => { it("buildExpectedDrivePath formats destination path correctly", async () => { const program = Effect.gen(function* () { const matcher = yield* EpisodeMatcher; - return matcher.buildExpectedDrivePath("Tech Talk", "Episode 1: Intro"); + return matcher.buildExpectedDrivePath("Tech Talk", "Episode 1: Intro", ""); }); const result = await Effect.runPromise(Effect.provide(program, EpisodeMatcherLive)); @@ -779,33 +779,37 @@ describe("DriveScan Service", () => { ]); // 1. Path match - const match1 = matcher.matchEpisode("Tech Talk", { title: "Episode 1" }, driveIndex); + const match1 = matcher.matchEpisode( + "Tech Talk", + { published: new Date(0), title: "Episode 1" }, + driveIndex, + ); // 2. Size match (same show) const match2 = matcher.matchEpisode( "Tech Talk", - { title: "Episode 2", fileSize: 2000 }, + { published: new Date(0), title: "Episode 2", fileSize: 2000 }, driveIndex, ); // 3. Duration match (within 2% tolerance, same show) const match3 = matcher.matchEpisode( "Tech Talk", - { title: "Episode 3", duration: 303 }, // 303 vs 300 is 1% diff + { published: new Date(0), title: "Episode 3", duration: 303 }, // 303 vs 300 is 1% diff driveIndex, ); // 4. Same size/duration but different show should NOT match const match4 = matcher.matchEpisode( "Other Show", - { title: "Episode 4", fileSize: 1000, duration: 100 }, + { published: new Date(0), title: "Episode 4", fileSize: 1000, duration: 100 }, driveIndex, ); // 5. Duration match outside tolerance (e.g. 310s for 300s) should NOT match const match5 = matcher.matchEpisode( "Tech Talk", - { title: "Episode 5", duration: 310 }, // 310 vs 300 is > 3% diff + { published: new Date(0), title: "Episode 5", duration: 310 }, // 310 vs 300 is > 3% diff driveIndex, ); diff --git a/src/__tests__/services_regressions.test.ts b/src/__tests__/services_regressions.test.ts index 1f466d1..13633d6 100644 --- a/src/__tests__/services_regressions.test.ts +++ b/src/__tests__/services_regressions.test.ts @@ -605,6 +605,42 @@ describe("SettingsService", () => { expect(loaded.theme).toBe("Catppuccin"); }); + it("persists both updates when two saves with different keys overlap", async () => { + const files = new Map(); + // Reads yield (the first save's read is also its check for a missing file), so unserialised saves would both merge into the same + // stale snapshot and the later write would drop the earlier key. + const slowReads = Layer.effect( + FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem; + return { + ...base, + exists: (path: string) => + Effect.sleep("10 millis").pipe(Effect.andThen(base.exists(path))), + readFile: (path: string) => + Effect.sleep("10 millis").pipe(Effect.andThen(base.readFile(path))), + }; + }), + ).pipe(Layer.provide(createFileSystemTest(files))); + + await Effect.runPromise( + Effect.provide( + Effect.gen(function* () { + const svc = yield* SettingsService; + yield* Effect.all( + [svc.saveSettings({ theme: "Nord" }), svc.saveSettings({ favoriteDrives: ["a", "b"] })], + { concurrency: "unbounded" }, + ); + }), + SettingsServiceLive.pipe(Layer.provide(slowReads), Layer.provide(LoggerLive)), + ), + ); + + const saved = JSON.parse(new TextDecoder().decode(files.get(settingsFile))); + expect(saved.theme).toBe("Nord"); + expect(saved.favoriteDrives).toEqual(["a", "b"]); + }); + it("creates a missing settings file via temp file + rename", async () => { const files = new Map(); await run( diff --git a/src/__tests__/useAppLogic.test.ts b/src/__tests__/useAppLogic.test.ts index fd3054b..9e5a327 100644 --- a/src/__tests__/useAppLogic.test.ts +++ b/src/__tests__/useAppLogic.test.ts @@ -6,6 +6,7 @@ import { syncSuccessMessage, useAppLogic, } from "@/hooks/useAppLogic"; +import { DriveDetection, DriveDetectionError } from "@/services/effects/DriveDetection"; import { DriveScan, DriveScanError } from "@/services/effects/DriveScan"; import { CleanupError, SyncEngine, SyncError } from "@/services/effects/SyncEngine"; import type { SyncProgress } from "@/types/sync"; @@ -595,6 +596,150 @@ describe("useAppLogic free-space check", () => { }); }); +/** A DriveDetection whose free-space read the test controls; `free` is read on every call. */ +const detectionWith = (free: { value: number | "fail" }) => + Layer.succeed(DriveDetection, { + scanDrives: () => Effect.succeed([]), + getDriveInfo: () => Effect.succeed(null), + getFreeSpace: () => + free.value === "fail" + ? Effect.fail(new DriveDetectionError({ cause: new Error("statfs failed") })) + : Effect.succeed(free.value), + isScanning: () => Effect.succeed(false), + driveEvents: Stream.empty, + }); + +describe("useAppLogic free-space refresh", () => { + beforeEach(() => { + actions.resetState(); + actions.setAppView("normal"); + }); + + it("uses fresh free space, not the value captured at detection", async () => { + const executed = { count: 0 }; + const free = { value: (2 * 1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + // Stale: detected when the drive was nearly full. + const stale = { ...drive, freeSpace: 1024 ** 3 }; + actions.setDrives([stale]); + actions.setCurrentDrive(stale); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(1); + expect(state.errorMsg).toBe(""); + expect(state.currentDrive?.freeSpace).toBe(2 * 1024 ** 3); + expect(state.drives[0]?.freeSpace).toBe(2 * 1024 ** 3); + }); + + it("refuses when fresh free space is smaller than the stale value", async () => { + const executed = { count: 0 }; + const free = { value: (1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 16 * 1024 ** 3 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(0); + expect(state.errorMsg).toBe("Not enough space on USB: need 2.0 GB, have 1.0 GB"); + }); + + it("falls back to the previous value when the refresh fails", async () => { + const executed = { count: 0 }; + const free = { value: "fail" as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: planEngine({ totalBytes: 2 * 1024 ** 3, executed }), + }), + ); + actions.setCurrentDrive({ ...drive, freeSpace: 1024 ** 3 }); + + logic.startSync([episode("1")]); + await settle(); + + expect(executed.count).toBe(0); + expect(state.errorMsg).toBe("Not enough space on USB: need 2.0 GB, have 1.0 GB"); + expect(state.currentDrive?.freeSpace).toBe(1024 ** 3); + }); + + it("does not write free space onto a drive that is no longer current", async () => { + const other: Drive = { ...drive, id: "d2", name: "Other", mountPoint: "/Volumes/Other" }; + // The user switches drives while the free-space read is in flight. + const switching = Layer.succeed(DriveDetection, { + scanDrives: () => Effect.succeed([]), + getDriveInfo: () => Effect.succeed(null), + getFreeSpace: () => + Effect.sync(() => { + actions.setCurrentDrive(other); + return 5 * 1024 ** 3; + }), + isScanning: () => Effect.succeed(false), + driveEvents: Stream.empty, + }); + const logic = useAppLogic( + makeAppLayerWith({ detection: switching, scan: driveScanDouble(false) }), + ); + actions.setDrives([drive, other]); + actions.setCurrentDrive(drive); + + logic.deleteSelectedFromDrive(); + await settle(); + + expect(state.currentDrive?.id).toBe("d2"); + expect(state.currentDrive?.freeSpace).toBe(other.freeSpace); + expect(state.drives.map((d) => d.freeSpace)).toEqual([drive.freeSpace, other.freeSpace]); + }); + + it("refreshes free space after episodes are deleted from the drive", async () => { + const free = { value: (9 * 1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ detection: detectionWith(free), scan: driveScanDouble(false) }), + ); + actions.setDrives([drive]); + actions.setCurrentDrive(drive); + + logic.deleteSelectedFromDrive(); + await settle(); + + expect(state.currentDrive?.freeSpace).toBe(9 * 1024 ** 3); + expect(state.drives[0]?.freeSpace).toBe(9 * 1024 ** 3); + }); + + it("refreshes free space after a sync completes", async () => { + const free = { value: (7 * 1024 ** 3) as number | "fail" }; + const logic = useAppLogic( + makeAppLayerWith({ + detection: detectionWith(free), + scan: driveScanDouble(false), + sync: emptyPlanEngine, + }), + ); + actions.setDrives([drive]); + actions.setCurrentDrive(drive); + + logic.startSync([episode("1")]); + await settle(); + + expect(state.currentDrive?.freeSpace).toBe(7 * 1024 ** 3); + }); +}); + describe("useAppLogic sync re-entrancy", () => { beforeEach(() => { actions.resetState(); diff --git a/src/hooks/useAppLogic.ts b/src/hooks/useAppLogic.ts index 48c5d5f..4132109 100644 --- a/src/hooks/useAppLogic.ts +++ b/src/hooks/useAppLogic.ts @@ -85,13 +85,14 @@ type AppRequirements = * test. Passed the live layers, the result is the app's production wiring. */ const buildLayer = (services: { + detection: Layer.Layer; scan: Layer.Layer; sync: Layer.Layer; scanDeps: Layer.Layer; syncDeps: Layer.Layer; }): Layer.Layer => Layer.mergeAll( - Detection.pipe(Layer.provide(services.scanDeps)), + services.detection, Podcast.pipe(Layer.provide(services.scanDeps)), services.scanDeps, services.scan, @@ -112,6 +113,7 @@ export const makeAppLayer = (): Layer.Layer => * leaves, and a double for one would need its own dependencies wired in. */ export const makeAppLayerWith = (overrides: { + detection?: Layer.Layer; scan?: Layer.Layer; sync?: Layer.Layer; }): Layer.Layer => { @@ -124,7 +126,14 @@ export const makeAppLayerWith = (overrides: { const syncLayer = overrides.sync ? (Layer.mergeAll(Sync, overrides.sync) as Layer.Layer) : Sync; + const detectionLayer = overrides.detection + ? (Layer.mergeAll( + Detection.pipe(Layer.provide(Base)), + overrides.detection, + ) as Layer.Layer) + : Detection.pipe(Layer.provide(Base)); return buildLayer({ + detection: detectionLayer, scan: scanLayer, sync: syncLayer, scanDeps: Base, @@ -246,6 +255,29 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() const isCurrentDrive = (drive: Drive): boolean => state.currentDrive?.id === drive.id; + /** + * Re-reads the drive's free space, which is otherwise frozen at detection + * time and goes stale after a sync or delete. The store is updated only if + * the drive is still current. If the read fails, the last known value stands. + * Resolves to the best known free space for `drive`. + */ + const refreshFreeSpace = (drive: Drive): Effect.Effect => + Effect.gen(function* () { + const detection = yield* DriveDetection; + const known = + state.currentDrive?.id === drive.id ? state.currentDrive.freeSpace : drive.freeSpace; + const fresh = yield* detection + .getFreeSpace(drive.mountPoint) + .pipe(Effect.catch(() => Effect.succeed(undefined))); + if (fresh === undefined) return known; + if (isCurrentDrive(drive)) { + const withFree = (d: Drive): Drive => (d.id === drive.id ? { ...d, freeSpace: fresh } : d); + actions.setCurrentDrive((prev) => (prev ? withFree(prev) : prev)); + actions.setDrives((prev) => prev.map(withFree)); + } + return fresh; + }); + /** * With no readable drive listing, no Mac episode can claim to be on the drive. */ @@ -595,10 +627,11 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() // Refuse up front rather than fail midway with a half-copied drive. A // negative freeSpace means the drive could not report it, so no check. - if (drive.freeSpace >= 0 && plan.totalBytes > drive.freeSpace) { + const freeSpace = yield* refreshFreeSpace(drive); + if (freeSpace >= 0 && plan.totalBytes > freeSpace) { return { success: false, - message: `Not enough space on ${drive.name}: need ${formatBytes(plan.totalBytes)}, have ${formatBytes(drive.freeSpace)}`, + message: `Not enough space on ${drive.name}: need ${formatBytes(plan.totalBytes)}, have ${formatBytes(freeSpace)}`, }; } @@ -654,6 +687,10 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() const exit = yield* Fiber.await(fiber); activeSyncFiber = undefined; + // Whatever the outcome, files may have landed: the cached free space + // is stale. + yield* refreshFreeSpace(drive); + if (Exit.isSuccess(exit)) { if (exit.value.success) { const rescanOk = yield* loadDrivePodcastsEffect(drive); @@ -736,6 +773,7 @@ export const useAppLogic = (layer: Layer.Layer = makeAppLayer() ); if (drive) { + yield* refreshFreeSpace(drive); yield* loadDrivePodcastsEffect(drive); } actions.setDrivePodcasts((prev) => prev.map((ep) => ({ ...ep, selected: false }))); diff --git a/src/services/effects/DriveDetection.ts b/src/services/effects/DriveDetection.ts index c268faf..cf81b9d 100644 --- a/src/services/effects/DriveDetection.ts +++ b/src/services/effects/DriveDetection.ts @@ -5,6 +5,7 @@ * Uses Bun.spawn for native process execution and parsing of 'diskutil activity'. */ +import { statfs } from "node:fs/promises"; import { Context, Data, Effect, Layer, Option, Ref, Result, Stream } from "effect"; import type { Drive } from "@/types/drive"; import { @@ -34,6 +35,8 @@ export class DriveDetection extends Context.Service< readonly scanDrives: () => Effect.Effect; /** Gets detailed information for a specific drive by its mount point */ readonly getDriveInfo: (mountPoint: string) => Effect.Effect; + /** Reads the bytes currently available to the user at a mount point (cheap; no diskutil) */ + readonly getFreeSpace: (mountPoint: string) => Effect.Effect; /** Checks if a background activity scan is currently active */ readonly isScanning: () => Effect.Effect; /** A stream of drive appearance and disappearance events */ @@ -270,7 +273,10 @@ export const DriveDetectionLive = Layer.effect( try: async () => { const glob = new Bun.Glob("*"); const names: string[] = []; - for await (const name of glob.scan({ cwd: "/Volumes", onlyFiles: false })) { + for await (const name of glob.scan({ + cwd: "/Volumes", + onlyFiles: false, + })) { if (!EXCLUDED_NAMES.has(name)) { names.push(name); } @@ -290,6 +296,17 @@ export const DriveDetectionLive = Layer.effect( const getDriveInfo = (mountPoint: string) => getDriveDetails(mountPoint, logger).pipe(Effect.map((opt) => Option.getOrNull(opt))); + const getFreeSpace = (mountPoint: string) => + Effect.tryPromise({ + try: async () => { + const stats = await statfs(mountPoint); + const free = Number(stats.bavail) * Number(stats.bsize); + if (!Number.isFinite(free) || free < 0) throw new Error("statfs reported no free space"); + return free; + }, + catch: (cause) => new DriveDetectionError({ cause }), + }); + const driveEvents = Stream.unwrap( Effect.gen(function* () { const initialDrives = yield* scanDrives(); @@ -433,6 +450,7 @@ export const DriveDetectionLive = Layer.effect( isScanning: () => Ref.get(scanningRef), scanDrives, getDriveInfo, + getFreeSpace, driveEvents, }); }), @@ -450,5 +468,13 @@ export const createDriveDetectionTest = ( scanDrives: () => Effect.succeed(drives), getDriveInfo: (mountPoint) => Effect.succeed(drives.find((d) => d.mountPoint === mountPoint) ?? null), + getFreeSpace: (mountPoint) => + Effect.succeed(drives.find((d) => d.mountPoint === mountPoint)?.freeSpace ?? -1).pipe( + Effect.flatMap((free) => + free >= 0 + ? Effect.succeed(free) + : Effect.fail(new DriveDetectionError({ cause: "unknown" })), + ), + ), driveEvents: events, }); diff --git a/src/services/effects/DriveScan.ts b/src/services/effects/DriveScan.ts index 7d579d1..ffa8bf0 100644 --- a/src/services/effects/DriveScan.ts +++ b/src/services/effects/DriveScan.ts @@ -4,7 +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"; +import { driveEpisodeKey, UNKNOWN_AUTHOR, UNKNOWN_DATE } from "@/utils/formatting"; export class DriveScanError extends Data.TaggedError("DriveScanError")<{ readonly cause: unknown; @@ -38,11 +38,20 @@ export class DriveScan extends Context.Service< /** * Helper to parse podcast file info from path. * extracts show name, title, and date if available. + * Names are normalized to NFC: macOS can hand back NFD names, and the same show + * must not split into two groups depending on the form. + * + * `dateStr` is the literal date from the filename ("YYYY-MM-DD" or "Unknown"), or "" + * when the file has no date prefix. It is what drive matching keys on. */ -function parsePodcastFile( - path: string, -): { showName: string; title: string; date?: Date; rawTitle: string } | null { - const normalizedPath = path.replace(/\\/g, "/"); +function parsePodcastFile(path: string): { + showName: string; + title: string; + date?: Date; + dateStr: string; + rawTitle: string; +} | null { + const normalizedPath = path.replace(/\\/g, "/").normalize("NFC"); const parts = normalizedPath.split("/"); const filename = parts[parts.length - 1]; if (!filename) return null; @@ -53,21 +62,35 @@ function parsePodcastFile( const rawTitle = filename.slice(0, -ext.length); let title = rawTitle; let date: Date | undefined; + let dateStr = ""; - const dateMatch = rawTitle.match(/^(\d{4}-\d{2}-\d{2}) - (.+)$/); + const dateMatch = rawTitle.match(/^(\d{4}-\d{2}-\d{2}|Unknown) - (.+)$/); if (dateMatch) { const titlePart = dateMatch[2]; - if (titlePart) { - const dateStr = dateMatch[1]; - const parsedDate = new Date(dateStr ?? ""); - if (!isNaN(parsedDate.getTime())) { + const literal = dateMatch[1]; + if (titlePart && literal) { + if (literal === UNKNOWN_DATE) { title = titlePart; - date = parsedDate; + dateStr = literal; + } else { + const [y, m, d] = literal.split("-").map(Number) as [number, number, number]; + // Local noon: formatDate reads local fields, so a UTC-midnight parse would + // show the previous day west of UTC. Noon keeps the UTC date equal too. + const parsedDate = new Date(y, m - 1, d, 12); + if ( + parsedDate.getFullYear() === y && + parsedDate.getMonth() === m - 1 && + parsedDate.getDate() === d + ) { + title = titlePart; + date = parsedDate; + dateStr = literal; + } } } } - return { showName, title, date, rawTitle }; + return { showName, title, date, dateStr, rawTitle }; } /** @@ -152,7 +175,7 @@ export const DriveScanLive = Layer.succeed( candidates, ({ file, info }) => Effect.gen(function* () { - const { showName, title, date } = info; + const { showName, title, date, dateStr } = info; const fullPath = join(podcastsDir, file); let published = date; @@ -173,7 +196,7 @@ export const DriveScanLive = Layer.succeed( return { showName, episode: { - id: `${showName}-${title}`, + id: `${showName}-${dateStr ? `${dateStr} - ` : ""}${title}`, title: title.replace(/_/g, " "), // Drive-derived episodes have no ZAUTHOR to read. author: UNKNOWN_AUTHOR, @@ -182,6 +205,7 @@ export const DriveScanLive = Layer.succeed( onDrive: true, filePath: fullPath, fileSize: size, + driveDate: dateStr, }, }; }), @@ -243,8 +267,8 @@ export const DriveScanLive = Layer.succeed( candidates, ({ file, info }) => Effect.gen(function* () { - const { showName, title } = info; - const key = matcher.buildExpectedDrivePath(showName, title); + const { showName, title, dateStr } = info; + const key = matcher.buildExpectedDrivePath(showName, title, dateStr); const fullPath = join(podcastsDir, file); let size = 0; @@ -297,6 +321,8 @@ interface MockDrivePodcast { title: string; path: string; size: number; + /** Date string as in the filename ("YYYY-MM-DD" or "Unknown"); omit for an undated file. */ + date?: string; }>; } @@ -320,6 +346,7 @@ export const createDriveScanTest = (mockPodcasts: MockDrivePodcast[] = []) => onDrive: true, filePath: ep.path, fileSize: ep.size, + driveDate: ep.date ?? "", })), })); return Effect.succeed(podcasts); @@ -329,7 +356,7 @@ export const createDriveScanTest = (mockPodcasts: MockDrivePodcast[] = []) => const index = new Map(); for (const podcast of mockPodcasts) { for (const ep of podcast.episodes) { - const key = `${podcast.name}/${ep.title}`; + const key = driveEpisodeKey(podcast.name, ep.title, ep.date ?? ""); index.set(key, { id: ep.id, title: ep.title, diff --git a/src/services/effects/EpisodeMatcher.ts b/src/services/effects/EpisodeMatcher.ts index f508adb..7bfc4b3 100644 --- a/src/services/effects/EpisodeMatcher.ts +++ b/src/services/effects/EpisodeMatcher.ts @@ -1,5 +1,5 @@ import { Context, Layer } from "effect"; -import { sanitizeFilename } from "@/utils/formatting"; +import { driveEpisodeKey } from "@/utils/formatting"; /** * EpisodeMatcher Service Tag @@ -7,32 +7,54 @@ import { sanitizeFilename } from "@/utils/formatting"; export class EpisodeMatcher extends Context.Service< EpisodeMatcher, { - /** Builds the expected relative path for an episode on the drive */ - readonly buildExpectedDrivePath: (showName: string, title: string) => string; + /** + * Builds the expected relative path for an episode on the drive: + * "Show/YYYY-MM-DD - Title.mp3". `date` is a Date (formatted like formatDestPath) + * or the literal date string from a filename; "" gives the undated legacy path. + */ + readonly buildExpectedDrivePath: ( + showName: string, + title: string, + date: Date | string, + ) => string; /** Attempts to match a Mac episode with a file on the drive using path, size, or duration */ readonly matchEpisode: ( showName: string, - episode: { title: string; fileSize?: number; duration?: number }, + episode: { title: string; published: Date; fileSize?: number; duration?: number }, driveIndex: Map, ) => boolean; } >()("EpisodeMatcher") {} -const buildExpectedDrivePathImpl = (showName: string, title: string): string => { - return `${sanitizeFilename(showName)}/${sanitizeFilename(title)}.mp3`; -}; +const buildExpectedDrivePathImpl = (showName: string, title: string, date: Date | string): string => + driveEpisodeKey(showName, title, date); + +/** " (2)", " (3)", ... appended by SyncEngine.dedupeDestPath (sanitized to "_(2)" in keys). */ +const DEDUPE_KEY_SUFFIX = /^[ _]\(\d+\)\.mp3$/; const matchEpisodeImpl = ( showName: string, - episode: { title: string; fileSize?: number; duration?: number }, + episode: { title: string; published: Date; fileSize?: number; duration?: number }, driveIndex: Map, ): boolean => { - // 1. Path-based matching (Strongest, handles tagged files where size changed) - const expectedPath = buildExpectedDrivePathImpl(showName, episode.title); + // 1. Path-based matching (Strongest, handles tagged files where size changed). + // The key includes the publish date so a new episode never matches an older one + // with the same title ("Trailer", "Bonus"). + const expectedPath = buildExpectedDrivePathImpl(showName, episode.title, episode.published); if (driveIndex.has(expectedPath)) return true; - const sanitizedShow = sanitizeFilename(showName); - const showPrefix = `${sanitizedShow}/`; + // Legacy drive files carry no date prefix, so show + title is all there is. + if (driveIndex.has(buildExpectedDrivePathImpl(showName, episode.title, ""))) return true; + + const showPrefix = `${expectedPath.slice(0, expectedPath.indexOf("/"))}/`; + + // A file that was deduped on copy (" - Title (2).mp3") still counts as synced. + const expectedBase = expectedPath.slice(0, -".mp3".length); + for (const key of driveIndex.keys()) { + if (key.startsWith(expectedBase) && DEDUPE_KEY_SUFFIX.test(key.slice(expectedBase.length))) { + return true; + } + } // 2. Cascade through other matches for (const [key, info] of driveIndex) { diff --git a/src/services/effects/SettingsService.ts b/src/services/effects/SettingsService.ts index a21f39c..1b27744 100644 --- a/src/services/effects/SettingsService.ts +++ b/src/services/effects/SettingsService.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { Context, Effect, Layer, Option } from "effect"; +import { Context, Effect, Layer, Option, Semaphore } from "effect"; import { FileSystem } from "@/services/effects/FileSystem"; import { Logger } from "@/services/effects/Logger"; @@ -35,6 +35,10 @@ export const SettingsServiceLive = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem; const logger = yield* Logger; + // saveSettings is a read-modify-write of one file. Serialize it so + // overlapping saves (a theme change racing a favorite toggle) cannot + // each merge into the same stale snapshot and lose one update. + const saveLock = yield* Semaphore.make(1); const DEFAULTS: Settings = { theme: "Catppuccin", favoriteDrives: [] }; @@ -84,34 +88,39 @@ export const SettingsServiceLive = Layer.effect( }); const saveSettings = (newSettings: Partial) => - Effect.gen(function* () { - yield* logger.debug(`Saving settings to: ${SETTINGS_FILE}`); - // Strict read: if the file exists but is unreadable/corrupt, fail rather - // than overwrite the user's (possibly hand-edited) settings with defaults. - const current = yield* readSettings; - const mergedSettings = { ...(Option.getOrUndefined(current) ?? DEFAULTS), ...newSettings }; + saveLock.withPermits(1)( + Effect.gen(function* () { + yield* logger.debug(`Saving settings to: ${SETTINGS_FILE}`); + // Strict read: if the file exists but is unreadable/corrupt, fail rather + // than overwrite the user's (possibly hand-edited) settings with defaults. + const current = yield* readSettings; + const mergedSettings = { + ...(Option.getOrUndefined(current) ?? DEFAULTS), + ...newSettings, + }; - const dirExists = yield* fs.exists(SETTINGS_DIR); - if (!dirExists) { - yield* logger.debug(`Creating settings directory: ${SETTINGS_DIR}`); - yield* fs.mkdir(SETTINGS_DIR); - } - const settingsWithSchema = { - $schema: SCHEMA_URL, - ...mergedSettings, - }; - const data = new TextEncoder().encode(JSON.stringify(settingsWithSchema, null, 2)); - // Write to a temp file and rename so a crash mid-write never leaves a - // truncated settings file behind. - const tmpFile = `${SETTINGS_FILE}.${process.pid}.tmp`; - yield* fs.writeFile(tmpFile, data); - yield* fs - .rename(tmpFile, SETTINGS_FILE) - .pipe(Effect.tapError(() => fs.remove(tmpFile).pipe(Effect.ignore))); - yield* logger.info("Settings saved successfully"); - }).pipe( - Effect.tapError((err) => logger.error("Failed to save settings", err)), - Effect.mapError((err) => new Error(`Failed to save settings: ${err}`)), + const dirExists = yield* fs.exists(SETTINGS_DIR); + if (!dirExists) { + yield* logger.debug(`Creating settings directory: ${SETTINGS_DIR}`); + yield* fs.mkdir(SETTINGS_DIR); + } + const settingsWithSchema = { + $schema: SCHEMA_URL, + ...mergedSettings, + }; + const data = new TextEncoder().encode(JSON.stringify(settingsWithSchema, null, 2)); + // Write to a temp file and rename so a crash mid-write never leaves a + // truncated settings file behind. + const tmpFile = `${SETTINGS_FILE}.${process.pid}.tmp`; + yield* fs.writeFile(tmpFile, data); + yield* fs + .rename(tmpFile, SETTINGS_FILE) + .pipe(Effect.tapError(() => fs.remove(tmpFile).pipe(Effect.ignore))); + yield* logger.info("Settings saved successfully"); + }).pipe( + Effect.tapError((err) => logger.error("Failed to save settings", err)), + Effect.mapError((err) => new Error(`Failed to save settings: ${err}`)), + ), ); return SettingsService.of({ diff --git a/src/types/podcast.ts b/src/types/podcast.ts index 7a2c15a..4110a62 100644 --- a/src/types/podcast.ts +++ b/src/types/podcast.ts @@ -8,6 +8,12 @@ export interface Episode { onDrive: boolean; filePath: string; fileSize: number; + /** + * Drive-scan only: the date string exactly as it appears in the filename + * ("YYYY-MM-DD" or "Unknown"), or "" when the file has no date prefix. + * Used for drive matching so no timezone round-trip is involved. + */ + driveDate?: string; } export interface PodcastEpisode extends Episode { diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index d9e8018..fc94886 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -8,6 +8,8 @@ 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"; +/** Date label used in drive filenames when an episode has no usable publish date. */ +export const UNKNOWN_DATE = "Unknown"; /** * Formats bytes into human-readable string @@ -146,7 +148,7 @@ export function formatDuration(rawSeconds: number): string { */ export function formatDate(date: Date): string { if (Number.isNaN(date.getTime()) || date.getTime() === 0) { - return "Unknown"; + return UNKNOWN_DATE; } const year = date.getFullYear(); @@ -214,6 +216,7 @@ export const mapPodcastsToEpisodes = (podcasts: Podcast[]): PodcastEpisode[] => fileSize: ep.fileSize, selected: false, onDrive: ep.onDrive, + driveDate: ep.driveDate, })), ); }; @@ -248,23 +251,50 @@ export function mapDriveEpisodes( return episodes; } +/** + * Builds the canonical show/file key used to match an episode with a file on the + * drive: "Show/YYYY-MM-DD - Title.mp3", mirroring formatDestPath. `date` is either a + * Date (formatted with formatDate, exactly as formatDestPath does) or the literal + * date string from a filename ("YYYY-MM-DD" or "Unknown"). An empty string yields the + * undated legacy key "Show/Title.mp3". + */ +export function driveEpisodeKey(showName: string, title: string, date: Date | string): string { + const dateStr = typeof date === "string" ? date : formatDate(date); + const prefix = dateStr ? `${dateStr} - ` : ""; + return `${sanitizeFilename(showName)}/${prefix}${sanitizeFilename(title)}.mp3`; +} + +/** + * Removes the " (2)", " (3)", ... suffix that SyncEngine.dedupeDestPath appends to + * a title. Returns the title unchanged when there is no such suffix. + */ +export function stripDedupeSuffix(title: string): string { + const match = title.match(/^(.*\S)\s\((\d+)\)$/); + return match && Number(match[2]) >= 2 ? (match[1] ?? title) : title; +} + /** * Mark episodes as being on the drive based on file path matching. - * Matches on Show Name + Episode Title using sanitized keys for robustness. + * Matches on Show Name + publish date + Episode Title using sanitized keys, the same + * keys as DriveScan.buildDriveIndex. Drive files without a date prefix (legacy) match + * on show + title alone, and a " (2)" dedupe suffix on a drive title is ignored. */ export function markEpisodesOnDrive( macEpisodes: PodcastEpisode[], driveEpisodes: PodcastEpisode[], ): PodcastEpisode[] { - // Create a set of "SanitizedShow/SanitizedTitle" keys - const driveSet = new Set( - driveEpisodes.map((e) => `${sanitizeFilename(e.showName)}/${sanitizeFilename(e.title)}`), - ); + const driveSet = new Set(); + for (const e of driveEpisodes) { + const date = e.driveDate ?? e.published; + driveSet.add(driveEpisodeKey(e.showName, e.title, date)); + const stripped = stripDedupeSuffix(e.title); + if (stripped !== e.title) driveSet.add(driveEpisodeKey(e.showName, stripped, date)); + } return macEpisodes.map((episode) => ({ ...episode, - onDrive: driveSet.has( - `${sanitizeFilename(episode.showName)}/${sanitizeFilename(episode.title)}`, - ), + onDrive: + driveSet.has(driveEpisodeKey(episode.showName, episode.title, episode.published)) || + driveSet.has(driveEpisodeKey(episode.showName, episode.title, "")), })); }