Refactor application/ — LibraryContext + decomposed module - #663
Refactor application/ — LibraryContext + decomposed module#663justinf555 wants to merge 12 commits into
Conversation
Introduces the design for replacing the god-object MomentsApplication state container (13 RefCell<Option<T>> fields, 270-line load_library_async) with a typed LibraryContext owning the domain plus minimal-surface clients constructed via build() inside application/. Locks in the LibraryContext naming, the pub(in crate::application) privacy boundary, concrete sub-service dependencies (no preemptive traits), the build() constructor convention, and the 6-step migration plan. Future BackgroundContext and ClientContext scopes documented with named trigger conditions, intentionally deferred. Triggered by the engineering review at Downloads/moments_repository_engineering_review.md flagging god-object proliferation as the highest-severity architectural finding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `LibraryContext` — a pure-Rust container that owns the long-lived domain + infrastructure objects (`Arc<Library>`, `Arc<RenderPipeline>`, `tokio::runtime::Handle`, and the periodic-purge `JoinHandle`). The type is `pub(in crate::application)` so it is unnameable outside the `application/` module tree: UI widgets and clients cannot reach the domain by name, only through their Client facades. This is Step 1 of the migration plan documented in `docs/design-library-context.md` — purely additive. Existing `RefCell<Option<T>>` slots on `MomentsApplication::imp` keep being populated; the new `library_context` field is populated alongside them. Later steps will collapse the duplication and migrate clients to consume sub-services from the context. Deviation from the doc shape: `library_context` is stored as `RefCell<Option<Arc<LibraryContext>>>` (not `OnceCell`) so the existing `shutdown()` path can clear it before `main()` drops the Tokio runtime. The eventual `OnceCell` shape will return when the legacy slots are removed in Step 4. The purge-task `JoinHandle` moves into the context immediately — `JoinHandle` is not `Clone`, so it can only have one home, and the context is the canonical one per the design doc. `shutdown()` now aborts that handle explicitly before dropping the context. `MomentsApplication::render_pipeline()` is marked `#[deprecated]` with delegation through the context; the three existing callers (`client/media/client_v2.rs`, `ui/viewer/loading.rs` x2) carry narrow `#[allow(deprecated)]` annotations with TODOs pointing at Step 2 (Client constructors taking sub-services explicitly). Verified: `make lint`, `make test` (611 passing), `make typos` all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(step 2) Step 2 of the LibraryContext refactor (docs/design-library-context.md). Each client now has a single `build(...)` constructor that takes its concrete dependencies and returns a fully-wired GObject, replacing the two-call `new() + configure()` pattern. * MediaClientV2::build(library, tokio, render_pipeline) * AlbumClientV2::build(library, tokio, events_rx) * PeopleClientV2::build(library, tokio, events_rx) * ImportClient::build(library, originals_dir, thumbnails_dir, render_pipeline, mode, tokio) * SyncClient::build(events_rx, tokio) `load_library_async` now pulls sub-services (`library()`, `tokio()`, `render_pipeline()`) from the `LibraryContext` it built and passes them into each `build()`. The `#[allow(dead_code)]` on the context's owner struct and core accessors is lifted now that they have callers. `MediaClientV2` takes the `RenderPipeline` as a constructor argument and holds it in `MediaDeps`, so the pixel-edit save path no longer reaches back into the global `MomentsApplication::default().render_pipeline()` deprecated accessor. `new()` is kept on each client behind `#[cfg(test)]` so unit tests can exercise pure model-patching helpers without a real `Library`. The matching `Default` impls (unused) are removed. The `MomentsApplication::library_context()` accessor stays `#[allow(dead_code)]` — Step 5 introduces phase functions that need it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convert the five client GObject singleton slots on `MomentsApplication::imp` from `RefCell<Option<T>>` to `OnceCell<T>`, removing the runtime nullability ceremony for callers. Accessor signatures change to reflect the post-startup contract: - `import_client(&self) -> &ImportClient` - `album_client_v2(&self) -> &AlbumClientV2` - `people_client(&self) -> &PeopleClientV2` - `media_client_v2(&self) -> &MediaClientV2` - `sync_client(&self) -> Option<&SyncClient>` (Immich-only) Returning `&Client` instead of an owned `Option<Client>` matches the "set once, read for the rest of the application lifetime" reality and lets call sites drop the `if let Some(c) = …` guards. Sites that need ownership (closures captured into spawn_local, etc.) call `.clone()` explicitly — cheap on a GObject ref-count. `set_sync_client` and `set_import_client` are now `OnceCell::set` calls that panic on double-set; the existing notify-property pattern is preserved. The `import-client` property notification subscription in ActivityIndicator is dropped (the client is always set before any widget realizes); the `sync-client` subscription is kept because Immich sessions install it after the main window is already wired up. Shutdown no longer attempts to drain client `Arc<Library>` clones — `OnceCell::take` requires `&mut self` and `ApplicationImpl::shutdown` gives us `&self`. The canonical `Arc<Library>` drop still happens via the legacy `self.library` slot and the `self.library_context` clear, which releases the `SqlitePool` before `main()` drops the Tokio runtime. Step 4 of the refactor will remove the legacy slots and can revisit the storage shape if a leak is observed. See `docs/design-library-context.md` Migration Plan → Step 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 of the LibraryContext refactor. The context is now the single
canonical home for `Arc<Library>` and `Arc<RenderPipeline>`; the
mirrored `RefCell<Option<...>>` slots and the `#[deprecated]`
`Application::render_pipeline()` accessor that Step 1 left in place as
shims are removed.
- Drop `imp::MomentsApplication::{library, render_pipeline}` legacy
fields. The two in-tree readers (`load_library_async`'s purge-task
and sync-engine setup) now pull `Arc<Library>` from
`library_context.library()` directly.
- Drop the deprecated `MomentsApplication::render_pipeline()`
accessor. The two UI call sites in `viewer/loading.rs` now go
through `MediaClientV2::render_pipeline()` — the client already
holds the pipeline as a build-time dependency, so widgets that use
the client never need a separate path to it.
- Promote `MediaClientV2::render_pipeline()` from private to `pub`.
- Remove the two `#[allow(deprecated)]` annotations that pointed at
the now-deleted accessor.
- Tighten the shutdown path: the redundant
`self.library.borrow_mut().take()` is gone; clearing
`library_context` releases the canonical `Arc<Library>` before the
Tokio runtime is dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 5 of the LibraryContext refactor. Extracts the 270-line
`load_library_async` mega-function into `src/application/startup.rs`
with four typed phase functions:
phase1_domain - open Library, build RenderPipeline,
wrap in LibraryContext
phase2_background_services - conditionally start the Immich
SyncHandle (Step 6 will reshape it
into a dedicated SyncEngine; today
the SyncHandle shape is unchanged)
phase3_clients - build UI client GObjects with
minimal concrete dependencies
phase4_install - populate Application fields, start
the periodic purge task, wire the
main window
Each phase consumes the previous phase's typed output, so the
compiler enforces the ordering: building a client before the
LibraryContext exists is no longer expressible. `start()` is
`pub(in crate::application)` and replaces both call sites in
`on_setup_complete` and `open_library`.
mod.rs shrinks from 1005 to 720 lines and now holds only the
GObject subclass, accessors, GTK lifecycle hooks, and the public
API. `load_library_async` is deleted; stale doc references to it
in context.rs and activity_indicator.rs are updated to point at
the new phase function names.
See docs/design-library-context.md, Migration Plan -> Step 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 of the LibraryContext refactor. The existing `SyncHandle` already owned the long-running work (pull/push managers + watch channels for shutdown and live interval), but it lived behind a `RefCell<Option<...>>` on `MomentsApplication` and was accessed directly by `set_sync_interval`. Reshape it into a dedicated `SyncEngine` with the architectural property the design doc calls for: an owned identity on `Application`, with `SyncClient` holding an `Arc<SyncEngine>` reference for UI binding. - Rename `SyncHandle` -> `SyncEngine`; `start(...)` -> `build(...)` returning `Arc<Self>` to match the construction convention from Step 2. - `SyncClient::build` now takes `Arc<SyncEngine>` alongside the events receiver and tokio handle; it stashes the engine ref so `SyncClient::set_interval` can forward to the engine directly. - `imp::MomentsApplication.sync_handle: RefCell<Option<SyncHandle>>` becomes `sync_engine: OnceCell<Arc<SyncEngine>>`, with `pub(in crate::application)` visibility so widgets cannot reach it. - `set_sync_interval` on `MomentsApplication` now routes through the `SyncClient` (which holds the engine), keeping the preferences dialog one hop further from the engine. - Shutdown: `ApplicationImpl::shutdown` reads the engine via `OnceCell::get` and calls `shutdown()` on it, signalling the pull/push tasks to exit at their next polling boundary. The `Arc<SyncEngine>` clones are dropped when the application itself is dropped, after `shutdown` returns. - Phase 2 of startup now produces `Option<Arc<SyncEngine>>` and phase 3 receives a clone to hand to `SyncClient::build`; phase 4 installs the engine on the application via `OnceCell::set`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The setup-complete and open-on-launch entry points each ran the same ~60-line bundle-open + Immich-token-resolve pipeline, differing only in error-dialog wording, which window parents the dialog, and the GSettings path side effect. Extract that pipeline into a pure-Rust LibraryLoader that returns a typed LoadOutcome (Ready / Failed(LoadFailure)); the callers own all GTK presentation and the path write/clear decision. This keeps the loader widget-free and unit-testable while preserving the exact per-context error messages and path-clearing rules. Step 7 of the LibraryContext refactor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the four app-level GAction/dialog handlers (setup_gactions, show_shortcuts, show_about, show_preferences) into actions.rs and the folder-import entry points (show_import_dialog, run_import) plus their GIO helper functions (resolve_folder_via_gio, gio_walk) into import.rs. Pure code movement to shrink application/mod.rs — no behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move show_setup_window, on_setup_complete, and open_library off the impl MomentsApplication block in mod.rs into a new lifecycle.rs, along with the setup_failure_dialog, open_failure_dialog, and show_library_error_dialog helpers (all used only by those methods). Pure code movement — no behavior changes. The three lifecycle methods gain pub(in crate::application) visibility so the imp::activate and startup::start call sites still resolve them across module boundaries; the dialog helpers stay private to lifecycle.rs. mod.rs drops from 530 to 369 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AlbumClientV2 and PeopleClientV2 were each handed the whole Arc<Library> even though they use only one or two sub-services. The design doc's principle is that a client's constructor signature should declare the minimal concrete sub-services it actually uses. AlbumClientV2::build now takes (AlbumService, ThumbnailService); the listener needs only AlbumService. PeopleClientV2::build now takes FacesService. LibraryContext gains album_service / thumbnail_service / faces_service accessors returning owned clones (the services are cheap Clone handles over the DB pool + event emitter). Neither client holds Arc<Library> any longer. Behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @justinf555's task in 3m 27s —— View job PR Review: Refactor application/ — LibraryContext + decomposed moduleTasks
Overall AssessmentThis is a well-executed architectural refactor. The Four issues worth addressing before merge: Issues1. let immich_client = immich_info
.as_ref()
.and_then(|info| ImmichClient::new(&info.server_url, &info.access_token).ok());If let immich_client = immich_info.as_ref().and_then(|info| {
match ImmichClient::new(&info.server_url, &info.access_token) {
Ok(c) => Some(c),
Err(e) => {
warn!("failed to build Immich HTTP client: {e}; continuing without sync");
None
}
}
});2.
3.
// In insert_into_models, before store.append:
if find_by_id(&store, id.as_str()).is_some() {
continue; // already present — skip
}4. #[allow(dead_code)]
pub(in crate::application) fn library_context(&self) -> Option<Arc<LibraryContext>> {
self.imp().library_context.borrow().clone()
}
Minor notes (no action required)
|
📊 Code Complexity ReportTop 20 functions by cognitive complexity · job log |
🔒 Security Report
|
CI Summary
|
- Log Immich client build failures via warn! instead of silently swallowing the error and falling back to the Local code path. - Drop the duplicate `thumbnails_dir_for_sync` field from `DomainArtifacts`; phase 2 now reads `paths.thumbnails_dir`. - Add an idempotency guard to `PeopleClientV2::insert_into_models` mirroring the album-side one, with a matching unit test. - Remove the dead `library_context()` accessor on `MomentsApplication`; `shutdown` accesses the field directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @justinf555's task in 51s —— View job Re-review: PR #663 review findings addressedTasks
All four issues flagged in the prior review are cleanly resolved in 1. 2. 3. 4. Dead No new issues found. The PR is in good shape — the architectural goals are achieved, the deviations are honestly documented, CI is green, and all prior review findings are resolved. Ready to merge pending manual smoke-test sign-off (Local setup flow, Immich flow, stale-path recovery, import dialog) as noted in the test plan. |
Summary
LibraryContext, a pure-Rustpub(in crate::application)container for the domain + infrastructure (Library,RenderPipeline, Tokio handle, purge-task handle). UI code physically cannot name the type — the architectural boundary is now enforced by the type system, not by convention.application/mod.rsgod-object into seven sibling files with clear roles (context.rs,startup.rs,lifecycle.rs,library_loader.rs,import.rs,actions.rs, plus the existingkeyring.rs).mod.rsdrops to 369 lines — essentially the GObject subclass machinery + public accessor API.RefCell<Option<T>>fields onMomentsApplicationwithOnceCell<T>for clients andLibraryContext-owned shapes for domain state. Tightens client constructor signatures to declare minimal sub-service dependencies via abuild()convention enforced insideapplication/.Triggered by an external engineering review (
Downloads/moments_repository_engineering_review.md) which flagged "Application State Container Is Trending Toward a God Object" as the highest-severity architectural finding.Full design doc landed as the first commit of this PR:
docs/design-library-context.md.Architectural property achieved
LibraryContextispub(in crate::application)— unspellable inui/,client/,library/. UI code cannot reach the domain except through a Client.Client::build(...)insideapplication/only. UI code cannot construct a Client because it cannot acquire the constructor arguments (sub-services live behindpub(in crate::application)accessors onLibraryContext).OnceCell<T>(post-startup contract), withOption<&SyncClient>exposed for the Immich-only sync client.phase1_domain,phase2_background_services,phase3_clients,phase4_install) — compiler enforces ordering.SyncEnginehas its own typed identity onApplication;SyncClientholds anArc<SyncEngine>for UI-driven engine control.Per-step breakdown (11 commits)
97dcb41docs/design-library-context.md— the design7ea02bcLibraryContext; populate alongside legacy fields05b18a1build()taking concrete deps85cc084OnceCell<T>395e2e80f84c33startup.rs)8d4abb0SyncHandle→SyncEnginetop-level service98c018dLibraryLoaderfrom duplicated wizard/open pathscac9c2cactions.rs+import.rsfrommod.rsc97b226lifecycle.rs3923cf5Steps 7–10 emerged during implementation as architecturally-adjacent cleanups (Step 7 is documented in the design doc; Steps 8–10 are pure decomposition / dependency-tightening). They were bundled into this PR because they live in
application/and the cost of doing them separately later would be higher.application/module shape afterNotable deviations from the design doc
Two deliberate deviations are documented inline in the code:
library_contextfield isRefCell<Option<Arc<LibraryContext>>>, not the doc's sketchedOnceCell<Arc<...>>. GObjectshutdowntakes&self, butOnceCell::takerequires&mut self. The existing shutdown path needs to clear theArc<Library>(and theSqlitePoolit wraps) beforemain()drops the Tokio runtime. Converting requires a shutdown-ordering redesign — out of scope for this refactor. Documented at the field declaration inmod.rs.MediaClientV2andImportClientkeepArc<Library>rather than splitting into N sub-services. Both genuinely touch 6+ services plus Library-level cross-service methods; splitting would harm readability without architectural benefit. The doc explicitly allowed this ("If a client uses the wholeArc<Library>because it touches many services, that's acceptable").AlbumClientV2,PeopleClientV2, andSyncClientwere tightened (Step 10).Out of scope / deferred
These were flagged in the design doc as future work:
BackgroundContext— anticipated holder for long-running background services (SyncEngine,PurgeTask) once a coordinated operation (pause_all,shutdown_in_order,apply_config) is needed. Trigger condition: ≥3 background-task fields or first concrete coordination need.ClientContext— speculative, depends on D-Bus design. Deferred until cross-cutting policy actually differs between callers.AppConfig— typed config struct read once fromgio::Settings. Open question in the design doc.Test plan
make lintclean (cargo fmt + clippy-D warnings, both default and--features dhat-heap)make test— 615 passed, 0 failed (baseline was 611; added 4LibraryLoaderunit tests in Step 7)make typoscleanmake test-integrationpassesmake run-dev, verify Local-backend setup flow (new library creation) — reviewer to confirmmake run-dev, verify Immich-backend setup flow and sync — reviewer to confirm🤖 Generated with Claude Code