Skip to content

feat(categories): add mutually exclusive categories and move config to .hito.json#40

Merged
iomz merged 3 commits intodevfrom
feat/mutually-exclusive-categories-and-directory-specific-config
Nov 27, 2025
Merged

feat(categories): add mutually exclusive categories and move config to .hito.json#40
iomz merged 3 commits intodevfrom
feat/mutually-exclusive-categories-and-directory-specific-config

Conversation

@iomz
Copy link
Owner

@iomz iomz commented Nov 27, 2025

Description

This PR implements mutually exclusive category groups and refactors the data storage architecture to use directory-specific configuration files.

Features

Mutually Exclusive Categories

  • Categories can be configured to be mutually exclusive with other categories
  • Bidirectional mutual exclusivity (if A excludes B, B also excludes A)
  • When assigning a category, mutually exclusive categories are automatically removed
  • Works in both single image and batch operations
  • UI shows checkboxes with indeterminate state for batch operations

Directory-Specific Configuration

  • Categories and hotkeys are now stored in .hito.json files (directory-specific)
  • Each directory can have its own set of categories and hotkeys
  • app-config.json now only stores data file path mappings
  • Removed backward compatibility migration code

Changes

  • Added mutuallyExclusiveWith field to Category type
  • Updated CategoryDialog to allow selecting mutually exclusive categories
  • Updated category assignment logic to handle mutual exclusivity
  • Refactored data storage: moved categories/hotkeys from app-config.json to .hito.json
  • Updated all tests to reflect new architecture
  • Updated README documentation

Testing

  • All 581 TypeScript tests pass
  • All 53 Rust tests pass
  • Build passes successfully

Resolves #22

Summary by CodeRabbit

  • New Features

    • Mutually Exclusive Categories: assigning a category will automatically remove conflicting categories.
    • Per-Directory Configuration: categories, hotkeys, and image assignments are stored per-directory (.hito.json) with configurable data file paths.
    • Hotkey behavior: auto-assigned category hotkeys are shown with key+action and can be edited or removed.
  • Documentation

    • README updated to explain per-directory storage, mutual-exclusivity behavior, and hotkey details.
  • Chores

    • Version bumped to 0.3.6.

✏️ Tip: You can customize this high-level summary in your review settings.

…o .hito.json

Implement mutually exclusive category groups:
- Add mutuallyExclusiveWith field to Category type
- Update CategoryDialog to allow selecting mutually exclusive categories
- Implement bidirectional mutual exclusivity (if A excludes B, B also excludes A)
- Update toggleImageCategory and assignImageCategory to automatically remove
  mutually exclusive categories when assigning
- Support mutually exclusive categories in batch operations

Refactor data storage architecture:
- Move categories and hotkeys from app-config.json to directory-specific .hito.json files
- Update HitoFile struct to include categories and hotkeys fields
- Update loadHitoConfig and saveHitoConfig to handle categories and hotkeys
- Replace all loadAppData/saveAppData calls with loadHitoConfig/saveHitoConfig
- AppData now only stores data_file_paths mapping
- Remove backward compatibility migration code

Update tests:
- Fix all test expectations to use saveHitoConfig instead of saveAppData
- Update test mocks and state setup
- Fix Rust test HitoFile initializations
- Remove unnecessary skipped test

Update documentation:
- Update README to reflect directory-specific configuration
- Document mutually exclusive categories feature
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 27, 2025

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Walkthrough

Adds bidirectional "mutually exclusive" category relationships and moves category/hotkey persistence from centralized app data to per-directory .hito.json via new loadHitoConfig/saveHitoConfig flows; updates Rust and TypeScript models, UI dialogs, assign/delete logic, tests, and styles to support the change.

Changes

Cohort / File(s) Summary
Documentation
README.md
Documented mutually exclusive categories, per-directory .hito.json storage for categories/hotkeys, and updated hotkey behavior/shortcuts notes.
Tauri / Rust backend
src-tauri/src/lib.rs
Added mutually_exclusive_with to CategoryData (serde rename mutuallyExclusiveWith); HitoFile and AppData now optionally include categories and hotkeys; save_hito_config signature expanded to accept/persist categories and hotkeys; AppData derives Default; tests adjusted.
Type declarations
src/types.ts, src/ui/categories.ts
Category interface now includes optional mutuallyExclusiveWith field; HitoFile and AppData types extended to include optional categories and hotkeys.
Category UI / Logic
src/components/CategoryDialog.tsx, src/ui/categories.ts
CategoryDialog tracks and edits bidirectional mutual-exclusion relationships (UI checkboxes, dedupe, reverse-link maintenance); category add/edit/delete flows update reverse links; assign/toggle logic enforces mutual exclusivity when assigning categories; persistence uses saveHitoConfig.
Hotkey UI / Logic
src/components/HotkeyDialog.tsx, src/ui/hotkeys.ts
Replaced saveAppData with saveHitoConfig when persisting hotkey changes; hotkey cleanup on category delete updated.
App startup & browsing
src/App.tsx, src/core/browse.ts, src/core/browse.test.ts
Removed centralized startup load of app data; load per-directory config early via loadHitoConfig (even for empty dirs) to avoid config leakage; adjusted browse flow and tests accordingly.
Persistence + API surface
src/ui/categories.ts, src/ui/categories.test.ts, src/ui/hotkeys.test.ts
Implemented loadHitoConfig/saveHitoConfig usage; save_hito_config payload now includes imageCategories, categories, and hotkeys; tests updated to reflect new payloads and renamed exported save function.
Styling & UX
src/styles.css
Added styles for mutually-exclusive category list and items; updated confirm dialog z-index and entry animations.
Version bump
package.json, src-tauri/Cargo.toml
Bumped version from 0.3.5 to 0.3.6.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CategoryDialog
    participant categories.ts
    participant Rust
    Note over CategoryDialog,categories.ts: Edit or add category (bidirectional setup)
    User->>CategoryDialog: Open & select mutual exclusions
    CategoryDialog->>CategoryDialog: Build direct + reverse exclusives set
    User->>CategoryDialog: Save
    CategoryDialog->>categories.ts: saveHitoConfig(directory, imageCategories, filename, categories, hotkeys)
    categories.ts->>Rust: invoke save_hito_config
    Rust->>Rust: persist `.hito.json` (image_categories, categories, hotkeys)
    Rust-->>categories.ts: success
    categories.ts-->>CategoryDialog: confirm saved
Loading
sequenceDiagram
    participant User
    participant ImageView
    participant categories.ts
    participant Rust
    Note over categories.ts: Assigning category enforces mutual exclusivity
    User->>ImageView: Assign category X to image
    ImageView->>categories.ts: assignImageCategory(image, X)
    categories.ts->>categories.ts: compute X's mutuallyExclusiveWith
    categories.ts->>categories.ts: remove conflicting assigned categories (forward+reverse)
    categories.ts->>categories.ts: update assignment
    categories.ts->>Rust: save_hito_config(... imageCategories, categories, hotkeys)
    Rust-->>categories.ts: persisted
    categories.ts-->>ImageView: updated state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Pay special attention to:
    • src/ui/categories.ts — mutual-exclusion enforcement, save payload composition, and hotkey cleanup logic.
    • src/components/CategoryDialog.tsx — bidirectional update correctness, ID handling for new categories, and UI-state consistency.
    • src-tauri/src/lib.rs — serde rename and default handling for new optional fields; ensure serialization shape matches frontend expectations.
    • Tests in src/ui/* and src/core/browse.test.ts — confirm mocks and expectations align with new save/load calls.

Possibly related PRs

Suggested labels

enhancement

Poem

🐇 I nibble code with careful paws,

Mutual rules without a cause,
Each folder keeps its tidy list,
No clashing tags — a net of bliss,
Hito hops clean, and I applaud applause. 🎉

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the two main features: adding mutually exclusive categories and moving config to .hito.json files.
Linked Issues check ✅ Passed The PR fully implements the requirement from issue #22 to add mutually exclusive categories as a configuration option on category definitions.
Out of Scope Changes check ✅ Passed The PR includes directory-specific configuration refactoring beyond issue #22's scope, but this appears intentional and is documented in the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Nov 27, 2025

Codecov Report

❌ Patch coverage is 93.10345% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.35%. Comparing base (3a30be5) to head (562cb04).
⚠️ Report is 3 commits behind head on dev.

Files with missing lines Patch % Lines
src/ui/categories.ts 91.20% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev      #40      +/-   ##
==========================================
- Coverage   94.46%   94.35%   -0.11%     
==========================================
  Files          19       19              
  Lines        3358     3456      +98     
  Branches      568      597      +29     
==========================================
+ Hits         3172     3261      +89     
- Misses        186      195       +9     
Flag Coverage Δ
rust 94.35% <93.10%> (-0.11%) ⬇️
unittests 94.35% <93.10%> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/core/browse.ts (1)

96-103: loadHitoConfig is skipped for empty directories, which can leak config from the previous directory

Because browseImages returns early when images.length === 0 && directories.length === 0, the new await loadHitoConfig() is never run for empty directories, and allImagePathsAtom/allDirectoryPathsAtom are left unchanged. This means:

  • Any .hito.json for an empty directory won’t be loaded.
  • Categories/hotkeys from the previously browsed directory can remain active while the UI shows the new path and “No images or directories found”.

Consider loading the config (and clearing arrays) before the early return, e.g.:

-    const contents = await invokeTauri<DirectoryContents>("list_images", { path });
-    
-    // Store directories and images - ensure they are arrays
-    const directories = Array.isArray(contents.directories) ? contents.directories : [];
-    const images = Array.isArray(contents.images) ? contents.images : [];
-    
-    if (images.length === 0 && directories.length === 0) {
-      showNotification("No images or directories found in this directory.");
-      store.set(isLoadingAtom, false);
-      return;
-    }
+    const contents = await invokeTauri<DirectoryContents>("list_images", { path });
+
+    // Store directories and images - ensure they are arrays
+    const directories = Array.isArray(contents.directories) ? contents.directories : [];
+    const images = Array.isArray(contents.images) ? contents.images : [];
+
+    // Load image category assignments, categories, and hotkeys for this directory,
+    // even if there are no images yet.
+    await loadHitoConfig();
+
+    if (images.length === 0 && directories.length === 0) {
+      showNotification("No images or directories found in this directory.");
+      store.set(allDirectoryPathsAtom, []);
+      store.set(allImagePathsAtom, []);
+      store.set(isLoadingAtom, false);
+      return;
+    }
@@
-    store.set(allDirectoryPathsAtom, directories);
-    store.set(allImagePathsAtom, images);
-    
-    // Load image category assignments, categories, and hotkeys for this directory
-    await loadHitoConfig();
+    store.set(allDirectoryPathsAtom, directories);
+    store.set(allImagePathsAtom, images);

This keeps per-directory configuration consistent even when the directory is empty.

Also applies to: 110-121

src/ui/categories.ts (1)

761-788: deleteCategory saves before updating categories/hotkeys (persistence bug)

Right now deleteCategory calls saveHitoConfig() before updating hotkeysAtom and categoriesAtom. Since saveHitoConfig now also persists categories/hotkeys, the .hito.json will continue to contain the deleted category and old hotkeys, even though in‑memory state has been updated. On the next load, stale categories/hotkeys will come back.

You should move the saveHitoConfig call to after all three atoms (imageCategoriesAtom, hotkeysAtom, categoriesAtom) have been updated, or call it a second time at the end and remove the earlier call.

 export async function deleteCategory(categoryId: string): Promise<void> {
   // ...build updatedImageCategories...
-  store.set(imageCategoriesAtom, updatedImageCategories);
-  
-  // Save the updated image category assignments to .hito.json
-  await saveHitoConfig();
+  store.set(imageCategoriesAtom, updatedImageCategories);
   // Clean up hotkeys that reference this category
   const hotkeys = store.get(hotkeysAtom);
   const updatedHotkeys = hotkeys.map((hotkey) => {
     // ...
   });
   store.set(hotkeysAtom, updatedHotkeys);

   // Remove category
   const categories = store.get(categoriesAtom);
   store.set(categoriesAtom, categories.filter((c) => c.id !== categoryId));

-  // Categories are now saved via saveHitoConfig (already called above)
+  // Persist updated image assignments, categories, and hotkeys to .hito.json
+  await saveHitoConfig();
 }
🧹 Nitpick comments (5)
src/core/browse.test.ts (1)

40-43: Tests correctly assert per-directory config loading via loadHitoConfig

Mocking loadHitoConfig and asserting it’s called in the “process valid directory contents” test matches the new browseImages behavior. You might optionally add expect(loadAppData).not.toHaveBeenCalled() here (or drop the loadAppData mock entirely) to guard against regressions back to the old API, but the current assertions are functionally sound.

Also applies to: 246-247

src/ui/categories.ts (3)

140-155: Saving default hotkeys via saveHitoConfig from loadAppData

Using saveHitoConfig() here to persist newly initialized default hotkeys into the per‑directory .hito.json is reasonable and matches the migration towards directory configs. Behavior is guarded by getDataFileDirectory() inside saveHitoConfig, so it’s a no‑op when no directory is active.


496-557: Mutual exclusivity handling in toggleImageCategory looks correct

The added logic:

  • Looks up the category being assigned;
  • Collects IDs to remove from both sides of the relation (the new category’s mutuallyExclusiveWith and any already‑assigned categories whose mutuallyExclusiveWith includes the new ID);
  • Filters out those assignments before adding the new one.

This enforces bidirectional exclusivity and also behaves correctly in asymmetric configs (legacy or hand‑edited). No functional issues found.

A small helper like resolveMutuallyExclusiveAssignments(currentAssignments, categoryId, categories) used by both toggleImageCategory and assignImageCategory would remove the duplicated block and keep the logic in one place.


583-635: assignImageCategory mutual exclusivity mirrors toggle behavior

The assign‑only path reuses the same exclusivity resolution (compute categoriesToRemove, filter, then append new assignment) before persisting and handling navigation, which keeps semantics consistent with toggleImageCategory. This is good for predictability.

Same as above: consider extracting the shared exclusivity‑resolution into a helper to avoid future divergence between toggle/assign.

src/components/CategoryDialog.tsx (1)

137-187: Editing flow maintains bidirectional mutual exclusivity with rollback

The editing branch:

  • Computes previousExclusiveSet vs newExclusiveSet;
  • Updates the edited category’s mutuallyExclusiveWith (using undefined for empty to keep JSON lean);
  • Adds reverse links on newly added exclusives;
  • Removes reverse links where exclusivity was removed;
  • Updates categoriesAtom optimistically and rolls back to originalCategories if saveHitoConfig fails.

This satisfies the bidirectional invariant while keeping persistence and error handling tidy.

You could extract the “update bidirectional exclusivity for edited category” into a pure helper to reuse in tests or other admin flows, but it’s fine inlined for now.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3a30be5 and bcb3d24.

📒 Files selected for processing (13)
  • README.md (5 hunks)
  • src-tauri/src/lib.rs (10 hunks)
  • src/App.tsx (1 hunks)
  • src/components/CategoryDialog.tsx (8 hunks)
  • src/components/HotkeyDialog.tsx (2 hunks)
  • src/core/browse.test.ts (1 hunks)
  • src/core/browse.ts (2 hunks)
  • src/styles.css (1 hunks)
  • src/types.ts (1 hunks)
  • src/ui/categories.test.ts (2 hunks)
  • src/ui/categories.ts (9 hunks)
  • src/ui/hotkeys.test.ts (9 hunks)
  • src/ui/hotkeys.ts (3 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursorrules)

Use soft tabs (spaces) for indentation, never hard tabs, following the existing codebase style of typically 2 spaces for TypeScript/React files

Files:

  • src/types.ts
  • src/core/browse.test.ts
  • src/App.tsx
  • src/components/HotkeyDialog.tsx
  • src/core/browse.ts
  • src/ui/hotkeys.ts
  • src/ui/categories.test.ts
  • src/ui/hotkeys.test.ts
  • src/components/CategoryDialog.tsx
  • src/ui/categories.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursorrules)

When calling Tauri commands from TypeScript, use camelCase for parameters (e.g., dataFilePath: value). Do not use snake_case in TypeScript

Files:

  • src/types.ts
  • src/core/browse.test.ts
  • src/App.tsx
  • src/components/HotkeyDialog.tsx
  • src/core/browse.ts
  • src/ui/hotkeys.ts
  • src/ui/categories.test.ts
  • src/ui/hotkeys.test.ts
  • src/components/CategoryDialog.tsx
  • src/ui/categories.ts
**/*.rs

📄 CodeRabbit inference engine (.cursorrules)

When defining Tauri commands in Rust, use snake_case for parameters (e.g., data_file_path: String). Tauri automatically converts to camelCase in TypeScript

Files:

  • src-tauri/src/lib.rs
🧬 Code graph analysis (7)
src/components/HotkeyDialog.tsx (1)
src/ui/categories.ts (1)
  • saveHitoConfig (285-327)
src/ui/hotkeys.ts (1)
src/ui/categories.ts (1)
  • saveHitoConfig (285-327)
src-tauri/src/lib.rs (1)
src/types.ts (1)
  • CategoryAssignment (44-47)
src/ui/categories.test.ts (2)
src/utils/jotaiStore.ts (1)
  • store (6-6)
src/state.ts (2)
  • categoriesAtom (14-14)
  • hotkeysAtom (12-12)
src/ui/hotkeys.test.ts (1)
src/ui/categories.ts (1)
  • saveHitoConfig (285-327)
src/components/CategoryDialog.tsx (2)
src/ui/categories.ts (1)
  • saveHitoConfig (285-327)
src/types.ts (1)
  • Category (37-42)
src/ui/categories.ts (4)
src/types.ts (2)
  • Category (37-42)
  • HotkeyConfig (30-35)
src/utils/jotaiStore.ts (1)
  • store (6-6)
src/state.ts (3)
  • imageCategoriesAtom (15-15)
  • categoriesAtom (14-14)
  • hotkeysAtom (12-12)
src/utils/tauri.ts (1)
  • invokeTauri (18-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: codecov
🔇 Additional comments (32)
src/styles.css (1)

2420-2476: Mutually-exclusive category styles look consistent with existing dialog UI

Layout, spacing, and colors align with the rest of the dark category dialog; scrollable list and empty state are handled cleanly. No functional issues from these styles.

src/ui/hotkeys.ts (1)

5-5: Hotkey persistence correctly routed through saveHitoConfig

Switching deleteHotkey and autoAssignHotkeyToCategory to await saveHitoConfig aligns these flows with the new per-directory config model, and error handling matches the expectations in src/ui/hotkeys.test.ts (rollback only for auto-assign, user-facing error for delete). No further changes needed here.

Also applies to: 191-198, 336-355

src/types.ts (1)

37-42: Category.mutuallyExclusiveWith extension is type-safe and backward compatible

Adding an optional mutuallyExclusiveWith?: string[] on Category cleanly models mutual exclusivity without breaking existing data or callers that don’t know about it.

src/components/HotkeyDialog.tsx (1)

6-6: HotkeyDialog save now persists via saveHitoConfig with reasonable error handling

Using saveHitoConfig here correctly routes hotkey edits into the per-directory config pipeline, and the try/catch with an inline error message provides a clear failure mode without breaking local state.

Also applies to: 216-222

src/App.tsx (1)

34-36: Startup comment now accurately reflects per-directory config loading

The revised comment in the App useEffect matches the new behavior where categories/hotkeys are loaded via loadHitoConfig when browsing a directory, rather than via an app-wide loadAppData on startup.

src/ui/hotkeys.test.ts (1)

15-17: Tests correctly exercise saveHitoConfig integration for hotkeys

Mocking saveHitoConfig and updating expectations in the deleteHotkey and autoAssignHotkeyToCategory suites line up with the new implementation: success paths assert that saveHitoConfig is invoked, and failure paths verify logging, user-facing errors, and rollback behavior. This gives good coverage of the new persistence wiring.

Also applies to: 1079-1083, 1207-1208, 1232-1233, 1266-1267, 1320-1321

README.md (5)

31-31: Mutually exclusive categories docs align with implementation

Description here matches the CategoryDialog UI and the toggle/assign logic that automatically removes mutually exclusive categories per image. No issues.


53-57: Per‑directory .hito.json description is consistent

These bullets correctly describe storing image assignments, categories, and hotkeys in per‑directory .hito.json plus separate app‑data mappings for custom paths. Reads clearly and matches the code.


131-131: Category creation section correctly explains mutual exclusivity

The behavior explained here (A excludes B ⇒ assigning A removes B and vice versa) matches the enforced logic in toggleImageCategory/assignImageCategory.


158-158: Note about directory‑specific categories/hotkeys is accurate

This note reflects the per‑directory persistence model in saveHitoConfig/loadHitoConfig. No action needed.


182-187: Automatic category hotkeys description matches behavior

Ordering and constraints (1‑9 then 0, only unassigned bare number keys, editable/removable) align with the hotkey auto‑assignment logic and tests.

src/ui/categories.ts (3)

21-30: Extending HitoFile/AppData with categories and hotkeys is consistent

Adding optional categories?: Category[] and hotkeys?: HotkeyConfig[] (plus AppData mirrors) lines up with the Rust HitoFile/AppData structs and the new per‑directory persistence path. No functional issues.


222-260: Loading categories/hotkeys from .hito.json and defaulting hotkeys

The extended loadHitoConfig logic correctly:

  • Logs counts for debugging;
  • Hydrates categoriesAtom and hotkeysAtom when present;
  • Normalizes hotkeys (id/key/modifiers/action);
  • Initializes default hotkeys and saves via saveHitoConfig when none are present.

The file‑not‑found branch only clears imageCategoriesAtom, intentionally leaving categories/hotkeys to act as a fallback, which is consistent with the tests.


300-320: Including categories and hotkeys in saveHitoConfig payload

Fetching categories/hotkeys from atoms, logging counts, and only sending them when non‑empty keeps the .hito.json lean while still persisting configuration. This matches the Rust save_hito_config signature that takes optional categories/hotkeys.

src/components/CategoryDialog.tsx (7)

11-14: Switch to saveHitoConfig is consistent with per‑directory persistence

Using saveHitoConfig from the dialog keeps category edits aligned with the new .hito.json‑centric model and avoids touching app‑wide app-config.json from here. Good move.


37-40: Local mutuallyExclusiveWith state is appropriate

Tracking mutuallyExclusiveWith in component state as a string[] mirrors the Category shape and simplifies the checkbox UI. No issues.


52-68: On-open initialization merges direct and reverse exclusivity correctly

Building allExclusive as the union of:

  • editingCategory.mutuallyExclusiveWith, and
  • Any categories whose mutuallyExclusiveWith already includes the current category ID

ensures the dialog represents the full bidirectional relation, even if the underlying data wasn’t perfectly symmetric before. This is exactly what you want for cleanup/migration.


80-85: Resetting mutuallyExclusiveWith on close avoids stale selections

Clearing the exclusivity list alongside name/color/error state when the dialog closes prevents leakage of previous selections into the next open. Looks correct.


210-241: New category flow correctly wires up exclusivity and persistence

For adds:

  • A stable UUID is generated (preferring crypto.randomUUID).
  • The new category’s mutuallyExclusiveWith is set from local state (or undefined when empty).
  • Existing categories referenced by mutuallyExclusiveWith are patched to include the new ID.
  • State is updated optimistically, saved via saveHitoConfig, and rolled back on failure.
  • Hotkey auto‑assignment runs after a successful save and is treated as best‑effort.

This behavior matches the feature description.


274-283: Including mutuallyExclusiveWith in handleSave dependencies

Adding mutuallyExclusiveWith to the dependency array ensures the callback sees the current selection of exclusive categories. Correct and necessary.


362-404: Mutually exclusive checkbox UI is clear and robust

The UI:

  • Lists all other categories (excluding the one being edited) with colored indicators;
  • Toggles membership in mutuallyExclusiveWith via checkbox;
  • Shows a “No other categories available” message when appropriate.

This aligns with the README description and the underlying data model.

src/ui/categories.test.ts (3)

417-418: Explicit categoriesAtom reset in UI tests

Resetting categoriesAtom explicitly before invoking resetStateAtom in the “categories UI and management” suite is harmless and makes the initial state for these tests explicit. Assuming resetStateAtom may touch other atoms, this is fine.


1240-1252: saveHitoConfig error‑handling test updated for new payload

Asserting that save_hito_config receives categories: store.get(categoriesAtom) and hotkeys: store.get(hotkeysAtom) aligns with the updated saveHitoConfig implementation (which includes these when non‑empty). This keeps the Rust/TS contract tested.


2774-2790: loadAppData test now verifies default hotkeys are persisted

The test case expecting:

  • two default hotkeys (J/K), and
  • at least one save_hito_config call

matches the new behavior where loadAppData initializes defaults and then persists them via saveHitoConfig. This is consistent with the production code.

src-tauri/src/lib.rs (8)

391-398: CategoryData mutual exclusivity field matches frontend model

Adding

#[serde(
  default,
  skip_serializing_if = "Option::is_none",
  rename = "mutuallyExclusiveWith"
)]
mutually_exclusive_with: Option<Vec<String>>,

keeps JSON shape (mutuallyExclusiveWith) aligned with the TS Category interface and safely defaults to None for old configs. Good use of serde attributes.


424-432: Extending HitoFile with optional categories/hotkeys is sound

Including categories: Option<Vec<CategoryData>> and hotkeys: Option<Vec<HotkeyData>> (with default + skip_serializing_if) allows .hito.json to grow to carry config while remaining backward compatible with files that only have image_categories.


435-440: Deriving Default for AppData fits synchronized app-config updates

Deriving Default simplifies initialization in update_app_data_sync/load_app_data and preserves existing semantics (empty categories/hotkeys, optional data_file_paths). No issues.


609-618: load_hito_config default return updated for new fields

When the .hito.json file is absent, returning

HitoFile {
  image_categories: Vec::new(),
  categories: None,
  hotkeys: None,
}

is compatible with TS expectations and keeps optionals unset rather than forcing empty arrays.


631-655: save_hito_config signature and body match TS caller

The updated command:

fn save_hito_config(
  directory: String,
  image_categories: Vec<(String, Vec<CategoryAssignment>)>,
  filename: Option<String>,
  categories: Option<Vec<CategoryData>>,
  hotkeys: Option<Vec<HotkeyData>>,
)

and the corresponding HitoFile construction align with the TS saveHitoConfig payload (imageCategories, categories?, hotkeys?). Serialization uses to_string_pretty and writes atomically to the requested path; behavior is correct.


1608-1610: HitoFile serialization test updated for optional fields

Explicitly setting categories: None and hotkeys: None in tests ensures serialization/deserialization remains stable when those fields are absent in JSON, which is important for backward compatibility.


1628-1634: AppData serialization covers new mutually_exclusive_with field

Including mutually_exclusive_with: None in the CategoryData used in test_app_data_serialization keeps that path tested with the extended struct and confirms serde handling for the renamed optional field.


1786-1792: save_hito_config tests updated for new parameters

Tests that pass None for the new categories/hotkeys parameters in both default and custom‑filename cases correctly exercise the extended signature while preserving prior behavior for “image categories only” scenarios.

Fix empty directory config loading:
- Move loadHitoConfig() before early return in browseImages
- Clear arrays when directory is empty to prevent config leakage
- Ensures per-directory configuration is loaded even for empty directories

Fix deleteCategory persistence bug:
- Move saveHitoConfig() call to after all atoms are updated
- Ensures categories, hotkeys, and image assignments are all saved correctly
- Prevents stale data from persisting after category deletion

Add comprehensive tests:
- Add tests for mutually exclusive categories feature
- Add test for deleteCategory saving all three atoms correctly
- Add test for empty directory config loading
- All 164 category tests and 19 browse tests pass
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/ui/categories.test.ts (1)

2920-2936: Make loadAppData tests independent of prior state

You now assert that creating default hotkeys results in a save_hito_config call, which goes through saveHitoConfig() and depends on currentDirectoryAtom being non-empty. Currently that relies on whatever previous describe left in the store; consider setting currentDirectoryAtom (and optionally dataFilePathAtom) explicitly in this describe’s beforeEach to avoid hidden coupling between tests.

src/ui/categories.ts (2)

222-260: Consider clearing categories/hotkeys on missing .hito.json to avoid leakage

loadHitoConfig’s success path now correctly loads image_categories, categories, and hotkeys from .hito.json, and initializes default hotkeys when none are present, which fits the per-directory config model. However, in the ENOENT branch you only clear imageCategoriesAtom, leaving categoriesAtom and hotkeysAtom untouched. That means opening a directory with no .hito.json will still show whatever categories/hotkeys were loaded for the previous directory, which conflicts with the goal of per-directory configuration and with the comment in browseImages about preventing config leakage.

If the intended semantics are “no .hito.json ⇒ no per-directory categories/hotkeys”, consider also resetting those atoms in the file-not-found branch, for example:

-    if (isFileNotFound) {
-      // File doesn't exist - clear assignments
-      console.log("[loadHitoConfig] Data file not found, clearing assignments");
-      store.set(imageCategoriesAtom, new Map());
-    } else {
+    if (isFileNotFound) {
+      // File doesn't exist - clear per-directory data
+      console.log("[loadHitoConfig] Data file not found, clearing assignments");
+      store.set(imageCategoriesAtom, new Map());
+      store.set(categoriesAtom, []);
+      store.set(hotkeysAtom, []);
+    } else {
       // Other errors (permission, parse, network, etc.) - log and rethrow
       console.error("[loadHitoConfig] Failed to load .hito.json:", error);
       throw error;
     }

Please confirm whether you want globals from loadAppData to persist in this case, or if this stricter per-directory isolation is preferred.


500-557: Mutual-exclusion logic in toggleImageCategory is correct but duplicated

The logic to resolve mutually exclusive categories—finding the category being assigned, scanning both its mutuallyExclusiveWith list and the assigned categories’ lists, building categoriesToRemove, then filtering and appending the new assignment—looks sound and is well-covered by the new tests.

The same block is effectively duplicated in assignImageCategory. Consider extracting a small helper such as:

function applyMutualExclusion(
  currentAssignments: CategoryAssignment[],
  categoryId: string,
  categories: Category[],
): CategoryAssignment[] { /* shared logic */ }

and using it from both functions. That would reduce the cognitive load and the risk of future divergence between the two paths.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bcb3d24 and 9c8fbe9.

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • package.json (1 hunks)
  • src-tauri/Cargo.toml (1 hunks)
  • src/core/browse.test.ts (1 hunks)
  • src/core/browse.ts (2 hunks)
  • src/ui/categories.test.ts (3 hunks)
  • src/ui/categories.ts (8 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src-tauri/Cargo.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/browse.test.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursorrules)

Use soft tabs (spaces) for indentation, never hard tabs, following the existing codebase style of typically 2 spaces for TypeScript/React files

Files:

  • src/ui/categories.test.ts
  • src/ui/categories.ts
  • src/core/browse.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursorrules)

When calling Tauri commands from TypeScript, use camelCase for parameters (e.g., dataFilePath: value). Do not use snake_case in TypeScript

Files:

  • src/ui/categories.test.ts
  • src/ui/categories.ts
  • src/core/browse.ts
🧬 Code graph analysis (2)
src/ui/categories.test.ts (3)
src/state.ts (5)
  • categoriesAtom (14-14)
  • hotkeysAtom (12-12)
  • currentDirectoryAtom (16-16)
  • dataFilePathAtom (17-17)
  • imageCategoriesAtom (15-15)
src/utils/dialog.ts (1)
  • confirm (15-104)
src/ui/categories.ts (3)
  • deleteCategory (735-786)
  • toggleImageCategory (496-578)
  • assignImageCategory (583-655)
src/ui/categories.ts (4)
src/types.ts (2)
  • Category (37-42)
  • HotkeyConfig (30-35)
src/utils/jotaiStore.ts (1)
  • store (6-6)
src/state.ts (3)
  • imageCategoriesAtom (15-15)
  • categoriesAtom (14-14)
  • hotkeysAtom (12-12)
src/utils/tauri.ts (1)
  • invokeTauri (18-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: codecov
🔇 Additional comments (8)
package.json (1)

4-4: Version bump aligns with new feature set

Incrementing to 0.3.6 is consistent with the scope of changes; scripts already coordinate version syncing, so this change is fine as-is.

src/core/browse.ts (1)

99-110: Per-directory config load ordering looks good

Calling await loadHitoConfig() after listing contents but before the empty-directory early-return ensures .hito.json (including categories/hotkeys) is loaded even when there are no images, and the additional clearing of allDirectoryPathsAtom, allImagePathsAtom, and currentIndexAtom on the empty case keeps browsing state consistent. No issues spotted with this flow.

src/ui/categories.test.ts (3)

1240-1252: saveHitoConfig payload expectation matches new contract

Asserting that save_hito_config receives categories and hotkeys alongside imageCategories when they’re non-empty matches the updated saveHitoConfig behavior. Once saveHitoConfig is adjusted to omit these keys entirely when arrays are empty (rather than sending undefined), this and the “empty categories” test will both be satisfied.


2766-2795: Good coverage of persistence after deleteCategory

This test usefully verifies that deleteCategory not only updates in-memory categoriesAtom and hotkeysAtom (clearing actions that referenced the deleted category) but also persists the updated arrays via save_hito_config. The expected payload shape matches the new saveHitoConfig contract.


2799-2911: Mutually exclusive categories tests are thorough

These tests exercise both toggleImageCategory and assignImageCategory across key scenarios: one-to-one, bidirectional, one-to-many mutual exclusion, and ensuring non-exclusive categories (like a “tagged” category) are preserved. They line up with the mutual-exclusion logic in categories.ts and should catch regressions.

src/ui/categories.ts (3)

141-154: Confirm saving default hotkeys via saveHitoConfig is always meaningful

Switching loadAppData to persist newly created default hotkeys through saveHitoConfig() aligns with the move to .hito.json. Just be aware this call is a no-op when getDataFileDirectory() returns an empty string (no current directory / data file path yet), which is a likely situation at app startup. If you rely on this initial save, ensure currentDirectoryAtom/dataFilePathAtom are set before loadAppData is used; otherwise, you’re effectively deferring the first write until the next operation that calls saveHitoConfig.


587-635: assignImageCategory mutual-exclusion behavior matches toggleImageCategory

The mutual-exclusion handling here mirrors toggleImageCategory (checking both the assigned category’s mutuallyExclusiveWith and the existing assignments’ lists), ensuring consistent behavior whether the caller is “toggle” or “assign only”. Given the new tests, this looks correct and complete.


784-785: Persisting after deleteCategory aligns with per-directory config

Calling await saveHitoConfig() after updating image assignments, categories, and hotkeys ensures .hito.json stays in sync with deletions, which is consistent with the new per-directory persistence strategy and validated by the added test in categories.test.ts.

@iomz
Copy link
Owner Author

iomz commented Nov 27, 2025

@coderabbitai pause

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 27, 2025

✅ Actions performed

Reviews paused.

… payload

Update saveHitoConfig to build payload object conditionally:
- Only include categories field when categories.length > 0
- Only include hotkeys field when hotkeys.length > 0
- Only include filename field when it exists
- Base payload always includes directory and imageCategories
- Console.log still prints all counts for debugging
- Actual payload sent omits empty optional fields

Add test to verify empty arrays are omitted from payload
@iomz iomz enabled auto-merge (rebase) November 27, 2025 02:17
@iomz iomz merged commit 2054b85 into dev Nov 27, 2025
4 checks passed
@iomz iomz deleted the feat/mutually-exclusive-categories-and-directory-specific-config branch November 27, 2025 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: constrain mutually exclusive categories

1 participant