Skip to content

Support Ctrl+Z undo/redo#19

Open
sharno wants to merge 3 commits intomasterfrom
ctrlz-undo
Open

Support Ctrl+Z undo/redo#19
sharno wants to merge 3 commits intomasterfrom
ctrlz-undo

Conversation

@sharno
Copy link
Owner

@sharno sharno commented Jan 4, 2026

Summary

  • split state into undoable AppModel + view/runtime
  • add reducer/effects layer with history replay
  • wire undo/redo hotkeys and shortcuts overlay

Testing

  • cargo test
  • cargo clippy --all-targets --all-features -- -D warnings

Closes #4

Summary by CodeRabbit

  • New Features

    • Undo/Redo with focus-aware history (restores data and selection)
    • Keyboard shortcuts: Cmd+Z (Undo), Cmd+Y and Cmd+Shift+Z (Redo)
    • Shortcuts panel updated to include Undo/Redo
  • Refactor

    • App state reorganized into model/view/runtime/history for clearer state flow
  • Bug Fixes

    • More consistent undo/redo behavior across the UI (selection and view restored correctly)

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

@coderabbitai
Copy link

coderabbitai bot commented Jan 4, 2026

Walkthrough

This PR refactors application state into model/view/runtime/history, adds a snapshot-based, focus-aware History with undo/redo, and introduces a Redux-style reducer (Action/Effect) plus a centralized update pipeline that records actions to History and runs effects. It adds Message variants and hotkeys for Undo/Redo, moves many UI paths to app.model/app.view/app.runtime, and introduces state, reducer, history, and update modules along with associated tests.

Sequence Diagram(s)

sequenceDiagram
    participant UI as User / UI
    participant Msg as Message Handler
    participant Reducer as Reducer
    participant History as History
    participant Effects as Effects Runner

    UI->>Msg: User action (Edit / Send / Save)
    Msg->>Reducer: reduce(model, Action)
    Reducer-->>Msg: (updated_model, [Effects])
    Msg->>History: record(Action, Focus, &model)
    History-->>Msg: store entry / snapshot
    Msg->>Effects: run_effects([Effects])
    Effects-->>Msg: async results (e.g., ResponseReady)
    Msg-->>UI: update view with updated_model / results
Loading
sequenceDiagram
    participant UI as User / UI
    participant Update as Update Handler
    participant History as History
    participant Reducer as Reducer
    participant View as View / ApplyFocus

    UI->>Update: Trigger Undo or Redo (Message)
    Update->>History: undo(apply_focus) or redo(apply_focus)
    History->>History: compute target cursor and nearest snapshot
    History->>Reducer: replay actions from snapshot to target
    loop apply actions
        Reducer-->>History: (model, _effects)
    end
    History->>View: apply_focus(&Focus, &mut model)
    View-->>Update: restored model + Focus (HistoryResult)
    Update-->>UI: render restored model and focus
Loading

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing undo/redo functionality with Ctrl+Z support.
Linked Issues check ✅ Passed The PR implements all primary coding requirements from issue #4: event-sourced reducer design, AppModel/ViewState/Runtime split, History with snapshots and replay, Action/Effect types, and undo/redo integration with focus tracking.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #4 objectives; no out-of-scope code modifications detected. Module reorganization, state splitting, and reducer/history implementation are all specified requirements.
✨ Finishing touches
  • 📝 Generate docstrings

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

Copy link

@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

🧹 Nitpick comments (5)
src/app/reducer.rs (1)

101-129: Consider logging or asserting on invalid header indices.

While HeaderIndex::new prevents invalid construction, these handlers silently ignore out-of-bounds access. If the header_rows list changes between HeaderIndex creation and action dispatch, stale indices will be silently dropped.

This is memory-safe but could hide bugs during development. Consider adding debug assertions or logging for invalid index cases to aid debugging.

Optional: Add debug assertion
 Action::HeaderNameChanged(idx, value) => {
-    if let Some(row) = model.header_rows.get_mut(idx.get()) {
+    if let Some(row) = model.header_rows.get_mut(idx.get()) {
         row.name = value;
         model.rebuild_headers_from_rows();
+    } else {
+        debug_assert!(false, "HeaderIndex out of bounds: {} >= {}", idx.get(), model.header_rows.len());
     }
     (model, Vec::new())
 }
src/app/update.rs (3)

226-278: Minor edge case: stale collection index could cause silent failure.

In the Collection branch (line 233), if the collection index in the selection becomes stale (e.g., collections list is modified elsewhere), get_mut(collection) returns None and the request silently fails to be added without user feedback.

Consider adding status feedback for the None case, similar to the default branch.

🔎 Proposed enhancement
             Some(RequestId::Collection { collection, .. }) => {
                 let new_draft = RequestDraft {
                     title: "New request".to_string(),
                     ..Default::default()
                 };
                 if let Some(col) = self.view.collections.get_mut(collection) {
                     col.requests.push(new_draft);
                     let new_idx = col.requests.len() - 1;
                     let new_id = RequestId::Collection {
                         collection,
                         index: new_idx,
                     };
                     self.view.selection = Some(new_id.clone());
                     if let Some(loaded) = self.view.resolve_request(&new_id) {
                         return self.apply_model_action(Action::LoadDraft(loaded), false, Some("Ready"));
                     }
+                } else {
+                    self.view.update_status_with_model("Collection not found", &self.model);
                 }
                 Task::none()
             }

293-309: Header check is case-sensitive, may miss lowercase variants.

Line 303 uses draft.headers.contains("Content-Type") which is case-sensitive. HTTP headers are case-insensitive, so content-type: application/json would not be detected, causing a duplicate header to be added.

🔎 Proposed fix
-        if !draft.headers.contains("Content-Type") {
+        if !draft.headers.to_lowercase().contains("content-type") {
             draft.headers.push_str("\nContent-Type: application/json");
         }

348-348: Unnecessary Task::none() in batch.

Task::batch([Task::none(), self.rescan_files()]) includes a no-op task. This can be simplified.

🔎 Proposed fix
-                Task::batch([Task::none(), self.rescan_files()])
+                self.rescan_files()
src/app/lifecycle.rs (1)

111-117: Double space in window title - likely a typo.

Line 113 has "Zagel REST workbench" with two spaces between "Zagel" and "REST". This appears unintentional.

🔎 Proposed fix
-        .title("Zagel  REST workbench")
+        .title("Zagel REST workbench")
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between aa8d07b and f6352d0.

📒 Files selected for processing (10)
  • src/app/history.rs
  • src/app/hotkeys.rs
  • src/app/lifecycle.rs
  • src/app/messages.rs
  • src/app/mod.rs
  • src/app/reducer.rs
  • src/app/state.rs
  • src/app/update.rs
  • src/app/view/mod.rs
  • src/app/view/workspace.rs
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Run cargo clippy --all-targets --all-features -- -D warnings (or cargo clippy-strict) before committing to enforce the strict lint baseline
Make invalid state unrepresentable (embrace ADTs - Algebraic Data Types)
Parse, don't validate: parse input at the edges so downstream state stays valid and doesn't need repeated validation
Push ifs up, push fors (loops) down
Prefer immutability and functional programming style when you can without sacrificing code cleanliness

Files:

  • src/app/hotkeys.rs
  • src/app/reducer.rs
  • src/app/messages.rs
  • src/app/view/workspace.rs
  • src/app/view/mod.rs
  • src/app/mod.rs
  • src/app/update.rs
  • src/app/lifecycle.rs
  • src/app/history.rs
  • src/app/state.rs
🧬 Code graph analysis (6)
src/app/view/workspace.rs (2)
src/app/view/mod.rs (2)
  • section (23-34)
  • view (36-60)
src/app/view/response.rs (2)
  • response_tab_toggle (43-60)
  • response_panel (71-146)
src/app/mod.rs (1)
src/app/lifecycle.rs (1)
  • run (111-117)
src/app/update.rs (6)
src/app/options.rs (2)
  • apply_auth_headers (79-105)
  • build_graphql_body (69-77)
src/app/reducer.rs (2)
  • reduce (62-136)
  • new (14-20)
src/app/history.rs (3)
  • new (63-74)
  • record (76-92)
  • from_selection (16-18)
src/app/state.rs (4)
  • new (21-24)
  • new (35-41)
  • new (161-188)
  • default (77-93)
src/app/view/mod.rs (1)
  • view (36-60)
src/net.rs (1)
  • send_request (8-80)
src/app/lifecycle.rs (4)
src/parser.rs (2)
  • scan_env_files (31-51)
  • scan_http_files (11-29)
src/app/view/mod.rs (1)
  • view (36-60)
src/app/state.rs (4)
  • default (77-93)
  • new (21-24)
  • new (35-41)
  • new (161-188)
src/app/history.rs (1)
  • new (63-74)
src/app/history.rs (2)
src/app/reducer.rs (1)
  • reduce (62-136)
src/model.rs (1)
  • from (46-55)
src/app/state.rs (3)
src/parser.rs (1)
  • suggest_http_path (167-194)
src/app/status.rs (3)
  • default_environment (5-10)
  • status_with_missing (19-35)
  • with_default_environment (12-17)
src/app/view/response.rs (1)
  • pretty_json (148-152)
⏰ 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). (2)
  • GitHub Check: Build and test (ubuntu-latest)
  • GitHub Check: Build and test (windows-latest)
🔇 Additional comments (42)
src/app/reducer.rs (3)

10-25: Excellent type-safe design for HeaderIndex.

The bounds-checked constructor and opaque wrapper prevent invalid indices from being constructed, following the "make invalid state unrepresentable" principle from the coding guidelines.


27-60: Well-structured Action/Effect separation.

The Action and Effect enums provide clear separation between pure state transitions and side effects, enabling deterministic reducer logic and testable state transitions as outlined in the PR objectives.


89-96: No action needed. RequestDraft does not include graphql_query or graphql_variables fields (only body, headers, url, method, and title). The GraphQL editors are intentionally separate from the request draft and do not sync to it by design. The apparent inconsistency with BodyEdited is justified: BodyEdited syncs because body is a RequestDraft field, but GraphQL editors don't sync because they're not part of the draft structure.

src/app/messages.rs (1)

43-44: LGTM! Undo/Redo message variants added.

The new message variants cleanly integrate with the existing Message enum to support the undo/redo functionality.

src/app/hotkeys.rs (1)

13-23: LGTM! Undo/redo shortcuts correctly implemented.

The pattern matching order is correct: Cmd+Shift+Z is checked before Cmd+Z, ensuring that shifted keypresses are properly distinguished. The addition of Cmd+Y as an alternative redo shortcut provides good cross-platform UX consistency.

src/app/view/mod.rs (2)

39-52: LGTM! Field access updated for new view structure.

The refactoring correctly updates all field access paths from the flat app.* structure to the nested app.view.* structure, maintaining consistency with the new model-view-runtime separation.


62-77: LGTM! Status bar updated to use view state.

The status bar correctly reads from app.view.show_shortcuts and app.view.status_line, maintaining consistency with the view state separation.

src/app/mod.rs (1)

3-14: LGTM! Module organization updated for new architecture.

The addition of history, reducer, and state modules and the relocation of HeaderRow to state align with the architectural refactoring. The public API surface remains compatible—HeaderRow is still exported, just from its new home in the state module.

src/app/view/workspace.rs (5)

34-55: LGTM!

The workspace function correctly accesses app.view.workspace_panes for the PaneGrid state, aligning with the model-view separation architecture.


70-184: LGTM!

The builder_form function correctly separates concerns:

  • View state (app.view) for environments and selection display
  • Model state (app.model) for editable draft fields, headers, mode, and auth

The #[allow(clippy::too_many_lines)] is reasonable for this UI builder function.


186-225: LGTM!

The builder_body function correctly accesses app.model.mode and the respective editor content fields from the model, maintaining the undoable state separation.


227-277: LGTM!

The response function correctly accesses:

  • app.view for response display state, tabs, and shortcuts visibility
  • app.runtime.state.theme for the highlight theme

This aligns well with the architecture where response preview is transient view state.


279-300: LGTM!

The shortcuts panel correctly displays the new undo/redo hotkeys, matching the implementation in hotkeys.rs.

src/app/update.rs (7)

17-53: LGTM!

The basic message handlers correctly:

  • Use SplitRatio::new to clamp pane resize ratios
  • Update view state for non-recordable changes
  • Save runtime state after environment changes

55-99: LGTM!

The model action handlers follow a consistent pattern with proper validation:

  • HeaderIndex::new validates indices before creating actions
  • User edits are marked record: true for undo/redo support
  • Status base is appropriately set for feedback

153-178: LGTM!

The apply_model_action function correctly:

  • Uses std::mem::take to move model ownership to the reducer
  • Records actions with focus derived from selection
  • Trims history after recording with the appropriate focus applier

The borrow pattern with let view = &self.view before calling self.history.trim_if_needed is correct since the closure only borrows view immutably.


180-213: LGTM!

The run_effects function cleanly maps Effect variants to concrete tasks, using Task::batch to combine multiple effects. The early return for empty effects avoids unnecessary allocations.


216-224: LGTM!

The handle_selection function correctly:

  • Sets the selection in view state (non-recordable)
  • Loads the draft via action without recording (selection changes are view-only per PR objectives)
  • Updates the response viewer after selection change

358-384: LGTM!

The handle_undo and handle_redo functions correctly:

  • Call history methods with the focus applier closure
  • Restore model state and selection from the history result
  • Update view state (status, response viewer) after restoration

388-395: LGTM!

The apply_focus_with_view helper correctly bridges the Focus and ViewState to apply the appropriate draft to the model during history replay.

src/app/history.rs (8)

9-33: LGTM!

The Focus enum provides a clean abstraction for tracking request context with useful conversion methods between selection and focus representations.


35-52: LGTM!

The internal data structures are well-designed:

  • HistoryEntry captures action and focus for replay
  • Snapshot stores full model state at checkpoints
  • HistoryResult provides the public interface for undo/redo results

62-74: LGTM!

The History::new function correctly initializes the history with a baseline snapshot at index 0, allowing replay from the initial state.


76-92: LGTM!

The record function correctly:

  • Truncates future history when recording after an undo
  • Retains snapshots up to the current cursor position
  • Creates periodic snapshots at SNAPSHOT_INTERVAL boundaries

94-114: LGTM!

The undo and redo functions have correct boundary checks and replay to the appropriate cursor position after adjustment.


116-134: LGTM!

The trim_if_needed function correctly manages history size by:

  • Replaying to the overflow point to establish the new base state
  • Draining old entries and adjusting the cursor with saturating_sub
  • Creating a fresh snapshot representing the new baseline

136-164: Consider the edge case where target equals snapshot.index.

When target == snapshot.index, the loop self.entries.iter().take(target).skip(snapshot.index) produces an empty iterator, so last_focus remains snapshot.focus.clone() without ever being updated by the loop body. This is likely correct behavior, but worth noting.

Verify this edge case is handled correctly: when target equals the snapshot index, the returned focus should be the snapshot's focus, not Focus::None.


167-251: LGTM!

The test history_replay_respects_focus provides good coverage for the core undo/redo behavior with focus switching between different requests, verifying both the model state and focus are correctly restored.

src/app/lifecycle.rs (4)

18-23: LGTM!

The refactored Zagel struct cleanly separates concerns with the model-view-runtime-history composition, with appropriate pub(super) visibility.


26-84: LGTM!

The init function correctly initializes all components in the right order:

  1. Creates pane layouts with sensible default ratios
  2. Initializes AppModel with defaults
  3. Creates ViewState with the root path and pane states
  4. Assembles Runtime with client and persisted state
  5. Initializes History with a clone of the model for the baseline snapshot

86-95: LGTM!

The subscription and theme functions correctly access the refactored state structure, with theme remaining a const fn.


97-108: LGTM!

The rescan_files function correctly uses self.view.http_root as the scan root and batches both file scans for concurrent execution.

src/app/state.rs (10)

15-29: LGTM!

SplitRatio correctly clamps values to [0.2, 0.8] to prevent extreme pane splits, following the "make invalid state unrepresentable" principle.


31-50: LGTM!

EnvironmentIndex provides safe bounds-checked access to environments, following the "make invalid state unrepresentable" principle. The find method handles missing environments gracefully.


52-62: LGTM!

HeaderRow and LoadedDraft are clean data structures supporting header editing and draft loading workflows.


64-94: LGTM!

AppModel and its Default implementation correctly initialize:

  • Editor content synchronized with draft
  • GraphQL variables with valid empty JSON {}
  • A single empty header row for immediate editing

96-138: LGTM!

The AppModel methods correctly handle bidirectional header conversion:

  • split_once(':') properly handles header values containing colons
  • Empty headers result in a single empty row for editing
  • rebuild_headers_from_rows filters out empty-name rows

140-158: LGTM!

ViewState appropriately groups all UI-related state that should not be part of undo/redo history, including pane states, response display, and environment selection.


160-188: LGTM!

ViewState::new correctly initializes all fields with sensible defaults. The expect on line 169 is safe because the environments vector is guaranteed to have the default environment.


190-210: LGTM!

The status update methods correctly compute the status line, including GraphQL query and variables for missing variable detection when in GraphQL mode.


212-248: LGTM!

The methods correctly handle:

  • Response formatting with pretty JSON when applicable
  • Environment list management with default fallback
  • Safe environment name access through validated EnvironmentIndex

250-280: LGTM!

resolve_request correctly resolves drafts from both Collection and HttpFile sources, with appropriate save path derivation:

  • HttpFile: uses the existing file path
  • Collection: generates a suggested path using suggest_http_path

Runtime cleanly encapsulates the HTTP client and persisted application state.

Copy link

@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: 3

🧹 Nitpick comments (3)
src/app/history.rs (1)

116-134: Consider documenting that trim_if_needed rebuilds the base snapshot.

The method correctly handles overflow by replaying to the overflow point, draining old entries, and resetting snapshots. The saturating_sub on line 127 safely handles the cursor adjustment. This is a well-implemented trimming strategy.

One minor note: the method signature requires apply_focus even though it's only used internally for replay. This is consistent with the API, but a brief doc comment explaining the trim behavior would help future maintainers.

src/app/state.rs (2)

112-135: Header parsing may silently drop malformed lines.

In set_header_rows_from_draft(), lines without a : separator are silently skipped (line 122-127). While the fallback to an empty row at lines 129-134 ensures at least one row exists, malformed header lines in the original draft are lost without warning.

This may be intentional for robustness, but consider whether logging or preserving malformed lines would be preferable for debugging purposes.


205-214: Potential inefficiency: text() clones the entire editor content.

The calls to model.graphql_query.text() and model.graphql_variables.text() on lines 208 allocate new String instances. For status updates that may happen frequently (e.g., on every keystroke), this could add allocation pressure.

Consider whether the status needs to be updated on every model change, or if it could be debounced/throttled at a higher level.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f6352d0 and 3217f76.

📒 Files selected for processing (10)
  • src/app/history.rs
  • src/app/hotkeys.rs
  • src/app/lifecycle.rs
  • src/app/messages.rs
  • src/app/mod.rs
  • src/app/reducer.rs
  • src/app/state.rs
  • src/app/update.rs
  • src/app/view/mod.rs
  • src/app/view/workspace.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/app/reducer.rs
  • src/app/view/mod.rs
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Run cargo clippy --all-targets --all-features -- -D warnings (or cargo clippy-strict) before committing to enforce the strict lint baseline
Make invalid state unrepresentable (embrace ADTs - Algebraic Data Types)
Parse, don't validate: parse input at the edges so downstream state stays valid and doesn't need repeated validation
Push ifs up, push fors (loops) down
Prefer immutability and functional programming style when you can without sacrificing code cleanliness

Files:

  • src/app/messages.rs
  • src/app/hotkeys.rs
  • src/app/update.rs
  • src/app/view/workspace.rs
  • src/app/lifecycle.rs
  • src/app/state.rs
  • src/app/mod.rs
  • src/app/history.rs
🧬 Code graph analysis (4)
src/app/update.rs (5)
src/app/options.rs (2)
  • apply_auth_headers (79-105)
  • build_graphql_body (69-77)
src/app/reducer.rs (2)
  • reduce (61-135)
  • new (14-20)
src/app/state.rs (4)
  • new (22-25)
  • new (36-42)
  • new (172-203)
  • default (85-101)
src/app/history.rs (2)
  • new (63-74)
  • from_selection (16-18)
src/model.rs (3)
  • error (106-114)
  • default (68-76)
  • from (46-55)
src/app/state.rs (6)
src/app/status.rs (3)
  • default_environment (5-10)
  • status_with_missing (19-35)
  • with_default_environment (12-17)
src/app/view/mod.rs (1)
  • view (36-61)
src/app/reducer.rs (2)
  • new (14-20)
  • get (22-24)
src/app/history.rs (1)
  • new (63-74)
src/model.rs (1)
  • as_str (27-36)
src/app/view/response.rs (1)
  • pretty_json (148-152)
src/app/mod.rs (3)
src/app/update.rs (1)
  • update (87-484)
src/app/view/mod.rs (1)
  • view (36-61)
src/app/lifecycle.rs (1)
  • run (116-122)
src/app/history.rs (3)
src/app/reducer.rs (2)
  • reduce (61-135)
  • new (14-20)
src/app/state.rs (4)
  • new (22-25)
  • new (36-42)
  • new (172-203)
  • default (85-101)
src/model.rs (1)
  • from (46-55)
⏰ 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). (3)
  • GitHub Check: Build and test (windows-latest)
  • GitHub Check: Build and test (ubuntu-latest)
  • GitHub Check: Build and test (windows-latest)
🔇 Additional comments (30)
src/app/messages.rs (1)

57-58: LGTM! Clean addition of Undo/Redo message variants.

The new Undo and Redo message variants are well-positioned in the enum and align with the PR's focus on implementing undo/redo functionality through an event-sourced reducer design.

src/app/hotkeys.rs (1)

13-23: LGTM! Hotkey ordering is correct.

The undo/redo hotkeys are properly implemented with correct precedence:

  • Cmd+Shift+Z (line 14) is checked before Cmd+Z (line 18), ensuring the more specific modifier combination takes priority
  • Cmd+Y (line 21) provides standard Windows-style redo
  • Case-insensitive matching ensures both 'Z'/'z' and 'Y'/'y' work

This aligns well with the PR's focus-aware undo/redo system.

src/app/mod.rs (1)

3-16: LGTM! Clean module organization supporting the new architecture.

The additions of history, reducer, and state modules along with the reorganized re-exports properly establish the separation between model/view/runtime/history as outlined in the PR objectives. Moving EditState and HeaderRow from lifecycle to state improves cohesion.

src/app/view/workspace.rs (6)

35-79: LGTM! Consistent refactoring to view state paths.

The workspace and builder pane access, along with environment fields, have been properly migrated to use app.view.* paths, aligning with the new model/view/runtime architecture.


90-132: LGTM! Draft and mode fields correctly migrated to model.

The method, URL, title, save path, and mode fields are now accessed via app.model.draft.* and app.model.mode, which correctly places undoable application state in the model layer.


139-171: LGTM! Auth and header fields properly moved to model.

Authentication state and header rows are now accessed via app.model.auth and app.model.header_rows, maintaining consistency with the undoable state architecture.


187-217: LGTM! Body editor fields correctly placed in model.

The GraphQL query/variables and body editor are now accessed via app.model.*, ensuring these undoable editing states are properly managed through the reducer pipeline.


229-244: LGTM! Response view fields correctly separated.

Response display state is accessed via app.view.* (non-undoable UI state) while the theme comes from app.runtime.state.theme, demonstrating proper separation of concerns.


288-290: LGTM! Shortcuts documentation updated for undo/redo.

The shortcuts panel now properly documents the new undo/redo hotkeys (Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl/Cmd+Y), providing clear user guidance.

src/app/update.rs (9)

13-17: LGTM! Imports properly support the new architecture.

The new imports for Focus, Action, Effect, reduce, AppModel, ViewState, and SplitRatio align with the PR's event-sourced reducer design and model/view/runtime separation.


387-432: LGTM! Message handlers correctly converted to action/effect flow.

The handlers for MethodSelected, UrlChanged, TitleChanged, ModeChanged, editor actions, and header operations have been properly converted to use apply_model_action, with appropriate record flags and status messages. The HeaderIndex::new() validation (lines 413, 419, 428) guards against out-of-bounds access before creating actions.


486-512: LGTM! Action/effect pipeline correctly implemented.

The apply_model_action method properly:

  • Captures focus from the current selection before applying the action (lines 492-496)
  • Uses std::mem::take to move the model without cloning (line 497)
  • Records the action in history with the captured focus (line 501)
  • Trims history using the apply_focus_with_view closure to maintain bounded history (lines 503-504)
  • Updates status and executes effects (lines 507-511)

This aligns with the PR's event-sourced reducer design.


514-546: LGTM! Effect execution properly centralized.

The run_effects method correctly:

  • Early returns for empty effect lists (lines 515-517)
  • Maps each effect variant to its corresponding task (lines 519-543)
  • Uses Task::batch to execute all effects in parallel (line 545)

This centralized execution point simplifies effect handling and aligns with the reducer pattern.


659-677: File rescan after save ensures consistency.

Line 669 correctly triggers a file rescan after successful save, which reconciles the in-memory http_files state with the persisted disk state. This is necessary given that persistence happens asynchronously through effects.


614-630: LGTM! Request preparation correctly handles GraphQL and auth.

The prepare_send method properly:

  • Converts GraphQL requests to POST with a JSON body (lines 617-626)
  • Applies authentication headers (line 628)
  • Tracks extra inputs (query/variables) for status display (lines 621-622)

679-705: LGTM! Undo/redo correctly integrated with history and focus.

Both handle_undo and handle_redo properly:

  • Invoke the history system with the apply_focus_with_view closure (lines 682-683, 696-697)
  • Update self.model with the replayed state (lines 685, 699)
  • Synchronize self.view.selection from the focus (lines 686, 700)
  • Update status and response viewer to reflect the new state (lines 687-688, 701-702)

This implements the focus-aware undo/redo specified in the PR objectives.


708-715: LGTM! Focus application correctly loads draft.

The apply_focus_with_view helper properly:

  • Extracts the request ID from the focus (line 709)
  • Resolves the request draft from the view state (line 712)
  • Loads the draft into the model (line 713)

This ensures that undoing/redoing actions restores the correct request draft based on the recorded focus.


136-148: The SplitRatio wrapper in lines 136-148 is necessary. The SplitRatio::new() method clamps the ratio value to [MIN_SPLIT_RATIO, 1.0 - MIN_SPLIT_RATIO], ensuring invalid states are unrepresentable. This pattern aligns with the coding guideline to embrace ADTs and validate at boundaries, so the code is correct as-is.

src/app/history.rs (5)

1-8: LGTM! Constants and imports are well-defined.

The snapshot interval (50) and max history (500) are reasonable defaults for balancing memory usage against replay performance.


9-33: LGTM! Clean ADT for focus state.

The Focus enum follows the "make invalid state unrepresentable" principle from the coding guidelines. The helper methods provide ergonomic conversions without exposing internal structure unnecessarily.


136-164: last_focus may not reflect the correct final focus when target == snapshot.index.

When replay_to is called with target == snapshot.index, the loop for entry in self.entries.iter().take(target).skip(snapshot.index) iterates zero times. In this case, last_focus remains equal to snapshot.focus.clone() (from line 148), which is correct.

However, when there are entries to replay, last_focus is updated inside the loop to current_focus.clone() (line 157) after the action is applied. This means last_focus captures the focus of the last processed entry, which is the entry at index target - 1. This appears intentional—the result focus should be the focus associated with the action that produced the current state.


167-251: Good test coverage for focus-aware replay.

The test validates that undo correctly restores focus to the previous request and redo advances to the next. The test helper apply_focus properly simulates loading drafts based on focus changes.


76-92: Snapshot retention logic is sound—no stale snapshots after truncation.

The concern about snapshots at index == cursor after truncation is unfounded. The snapshot.index represents the count of entries applied to produce that state. After entries.truncate(cursor), we have exactly cursor entries (indexed 0 to cursor-1), making a snapshot at index=cursor valid. The replay_to logic correctly handles this: when replaying to target == snapshot.index, it applies take(target).skip(snapshot.index) (an empty range), using the snapshot model directly. This is the correct behavior and extends consistently to all truncation and replay scenarios.

src/app/lifecycle.rs (2)

16-21: Clean separation of concerns with model/view/runtime/history composition.

The new structure aligns well with the PR objectives for separating undoable state (model), UI state (view), and runtime resources (runtime/history). This enables the event-sourced undo/redo design.


60-78: LGTM! Initialization correctly constructs all state components.

The flow properly:

  1. Creates default AppModel
  2. Builds ViewState with panes and http_root
  3. Updates status with the model
  4. Persists state
  5. Initializes Runtime with client and state
  6. Creates History with initial model snapshot

This ensures a consistent starting state for undo/redo.

src/app/state.rs (5)

16-30: Good use of newtype pattern for SplitRatio.

The clamping in new() ensures the ratio stays within valid bounds, following the "parse, don't validate" principle from the coding guidelines. This prevents invalid pane splits at construction time.


32-51: EnvironmentIndex provides compile-time safety for environment access.

The new() constructor validates bounds, and find() provides a safe lookup. This is a clean ADT approach that makes invalid indices unrepresentable. As per coding guidelines, this embraces ADTs effectively.


137-146: Header rebuilding filters empty names, which is correct behavior.

The filter on line 141 ensures only headers with non-empty names are serialized. This prevents accidentally adding empty headers to the request. The trimming on both name and value is a good defensive measure.


243-259: set_environments correctly handles environment persistence.

The method properly:

  1. Prepends the default environment
  2. Restores the previously active environment by name if it exists
  3. Falls back to the default environment
  4. Persists the active environment name back to state

The expect on line 251 is safe because with_default_environment guarantees at least one environment exists.


265-276: The resolve_request code is correct as-is. RequestId is defined as a single-variant enum with only HttpFile { path: PathBuf, index: usize }. The refutable pattern will not panic because there are no other variants to handle. This design appropriately makes invalid states unrepresentable, aligning with the ADT principle from the coding guidelines.

pub fn run() -> iced::Result {
application(Zagel::init, Zagel::update, view::view)
.title("Zagel  REST workbench")
.title("Zagel REST workbench")
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Typo: Double space in window title.

The title "Zagel REST workbench" has two spaces between "Zagel" and "REST".

🔎 Proposed fix
-        .title("Zagel  REST workbench")
+        .title("Zagel REST workbench")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.title("Zagel REST workbench")
.title("Zagel REST workbench")
🤖 Prompt for AI Agents
In src/app/lifecycle.rs around line 118, the window title string contains a
double space ("Zagel  REST workbench"); change it to a single space ("Zagel REST
workbench") to correct the typo and ensure consistent UI text.

Comment on lines +279 to +283
#[derive(Debug, Clone)]
pub struct Runtime {
pub client: Client,
pub state: AppState,
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if Runtime is cloned anywhere in the codebase
rg -n 'runtime\.clone\(\)' src/

Repository: sharno/zagel

Length of output: 38


🏁 Script executed:

# Also check for broader clone patterns on Runtime or where Runtime is defined
rg -n 'Runtime' src/app/state.rs | head -20

Repository: sharno/zagel

Length of output: 80


🏁 Script executed:

# Check the full content of state.rs to understand AppState and Runtime definitions
wc -l src/app/state.rs

Repository: sharno/zagel

Length of output: 76


🏁 Script executed:

# Read the full state.rs file to see Runtime and AppState definitions
cat -n src/app/state.rs

Repository: sharno/zagel

Length of output: 10941


🏁 Script executed:

# Search for Runtime usage patterns more broadly (not just clone)
rg -n 'Runtime' src/ -A 2 -B 2

Repository: sharno/zagel

Length of output: 991


🏁 Script executed:

# Check if AppState derives Clone
rg -n 'pub struct AppState' src/ -A 5

Repository: sharno/zagel

Length of output: 324


🏁 Script executed:

# Check AppState derive attributes and full definition
rg -n 'pub struct AppState' src/state.rs -A 10

Repository: sharno/zagel

Length of output: 347


🏁 Script executed:

# Search for any clone() calls on Runtime or its members in the codebase
rg -n '\.clone\(\)' src/app/lifecycle.rs -B 2 -A 2

Repository: sharno/zagel

Length of output: 1181


🏁 Script executed:

# Check if AppState actually derives Clone by looking at the full line before line 9
head -20 src/state.rs

Repository: sharno/zagel

Length of output: 530


🏁 Script executed:

# Search for the derive macro on AppState
rg -n 'AppState' src/state.rs | head -5

Repository: sharno/zagel

Length of output: 98


🏁 Script executed:

# Also check if ThemeChoice derives Clone since it's a field in AppState
rg -n 'enum ThemeChoice' src/ -A 3

Repository: sharno/zagel

Length of output: 197


🏁 Script executed:

# Check the full ThemeChoice derive attributes
rg -n 'enum ThemeChoice' src/theme.rs -B 2

Repository: sharno/zagel

Length of output: 198


🏁 Script executed:

# Search for actual Runtime.clone() calls in the codebase
rg -n 'runtime\.clone\(\)' src/ -i

Repository: sharno/zagel

Length of output: 38


🏁 Script executed:

# Check how Runtime is actually used - is it moved, borrowed, or cloned?
rg -n 'self\.runtime' src/app/lifecycle.rs -B 1 -A 1

Repository: sharno/zagel

Length of output: 38


Remove the Clone derive from Runtime unless it's actually cloned.

Runtime is never cloned in the codebase, and it's stored as an owned value in the App struct. AppState explicitly derives Clone, so there's no uncertainty about its cloneability. If Runtime doesn't need to be cloned, remove the derive to keep the API clear about ownership semantics.

🤖 Prompt for AI Agents
In src/app/state.rs around lines 279 to 283, the Runtime struct is derived with
Clone but the type is never cloned and is stored as an owned value in App;
remove the Clone derive from Runtime to reflect correct ownership semantics.
Edit the struct declaration to drop Clone from the #[derive(...)] list (leaving
Debug) and run cargo check to ensure no code relies on Runtime::clone; if any
call sites attempt to clone a Runtime, refactor them to clone the AppState or
otherwise adjust ownership instead of cloning the whole Runtime.

Comment on lines 564 to 599
fn handle_add_request(&mut self) -> Task<Message> {
if let Some(RequestId::HttpFile { path, .. }) = self.view.selection.clone() {
let new_draft = RequestDraft {
title: "New request".to_string(),
..Default::default()
};
if let Some(file) = self.view.http_files.get_mut(&path) {
let persist_draft = new_draft.clone();
file.requests.push(new_draft);
let new_idx = file.requests.len() - 1;
let new_id = RequestId::HttpFile {
path: path.clone(),
index: new_idx,
};
self.view.selection = Some(new_id.clone());
if let Some(loaded) = self.view.resolve_request(&new_id) {
let task = self.apply_model_action(Action::LoadDraft(loaded), false, Some("Ready"));
self.view.update_response_viewer();
return task;
}
self.view.update_status_with_model("Saving new request...", &self.model);
let effect = Effect::PersistRequest {
root: self.view.http_root.clone(),
selection: None,
draft: persist_draft,
explicit_path: Some(path),
};
return self.apply_model_action(Action::Emit(effect), false, None);
}
return Task::none();
}

self.view
.update_status_with_model("Select a file to add a request", &self.model);
Task::none()
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the handle_saved implementation and understand how file.requests is updated
sed -n '659,700p' src/app/update.rs

Repository: sharno/zagel

Length of output: 1600


🏁 Script executed:

# Check if there are any other places where file.requests is modified in response to persist completion
rg -A 10 "fn handle_saved" src/app/update.rs

Repository: sharno/zagel

Length of output: 578


🏁 Script executed:

# Look for file rescan logic to understand reconciliation
rg -B 3 -A 10 "rescan\|rescans" src/app/update.rs

Repository: sharno/zagel

Length of output: 38


🏁 Script executed:

# Check the full context around handle_add_request and handle_saved to understand the flow
sed -n '564,680p' src/app/update.rs

Repository: sharno/zagel

Length of output: 4748


Optimistic state update precedes persistence confirmation.

Line 570 pushes the new request to file.requests before the persist effect completes. While handle_saved calls self.rescan_files() on successful persistence to reconcile state, this only mitigates the issue on success. If persistence fails, the in-memory state remains diverged until the next rescan, creating a temporary inconsistency.

Updating file.requests only in handle_saved after confirming successful persistence (the result's Ok case) would eliminate this window entirely and improve state consistency.

🤖 Prompt for AI Agents
In src/app/update.rs around lines 564-599, the code currently pushes the new
RequestDraft into file.requests and updates selection before the PersistRequest
effect completes; instead, do not mutate in-memory file.requests or change
selection optimistically—create the new draft (persist_draft) and emit
Effect::PersistRequest immediately, and only on a successful save (handle_saved
Ok branch) append the persisted draft to the appropriate file.requests, compute
and set the new RequestId selection there, call rescan_files()/update views as
needed; on failure, leave in-memory state untouched and surface the error
status. Ensure any UI feedback (status/message) is shown before/while persisting
but defer the actual state mutation until persistence confirmation.

Copy link

@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

♻️ Duplicate comments (1)
src/app/update.rs (1)

579-619: Optimistic state update precedes persistence confirmation.

The new request is pushed to file.requests (line 587) and selection is updated (line 593) before the PersistRequest effect completes. If persistence fails, in-memory state remains inconsistent until the next rescan_files() call in handle_saved.

This concern was raised in a previous review and remains applicable.

🧹 Nitpick comments (1)
src/app/update.rs (1)

412-447: Consider using debug_assert! with a condition instead of debug_assert!(false, ...).

The pattern debug_assert!(false, "message") unconditionally triggers the assertion in debug builds when reached. While this works, it's clearer to express the invariant directly:

-                let Some(index) = HeaderIndex::new(idx, len) else {
-                    debug_assert!(
-                        false,
-                        "Invalid header index {idx} for header_rows len {len}"
-                    );
-                    return Task::none();
-                };
+                let Some(index) = HeaderIndex::new(idx, len) else {
+                    debug_assert!(
+                        idx < len,
+                        "Invalid header index {idx} for header_rows len {len}"
+                    );
+                    return Task::none();
+                };

Alternatively, use unreachable!() if this branch truly should never be reached, or simply log and return silently if it's a recoverable edge case in production.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3217f76 and 7549350.

📒 Files selected for processing (1)
  • src/app/update.rs
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Run cargo clippy --all-targets --all-features -- -D warnings (or cargo clippy-strict) before committing to enforce the strict lint baseline
Make invalid state unrepresentable (embrace ADTs - Algebraic Data Types)
Parse, don't validate: parse input at the edges so downstream state stays valid and doesn't need repeated validation
Push ifs up, push fors (loops) down
Prefer immutability and functional programming style when you can without sacrificing code cleanliness

Files:

  • src/app/update.rs
🧬 Code graph analysis (1)
src/app/update.rs (4)
src/app/options.rs (2)
  • apply_auth_headers (79-105)
  • build_graphql_body (69-77)
src/app/reducer.rs (2)
  • reduce (61-135)
  • new (14-20)
src/app/history.rs (3)
  • new (63-74)
  • record (76-92)
  • from_selection (16-18)
src/app/state.rs (4)
  • new (22-25)
  • new (36-42)
  • new (172-203)
  • default (85-101)
⏰ 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). (2)
  • GitHub Check: Build and test (windows-latest)
  • GitHub Check: Build and test (windows-latest)
🔇 Additional comments (11)
src/app/update.rs (11)

1-19: LGTM!

Imports are well-organized and the FILE_SCAN_DEBOUNCE constant is appropriately defined for debouncing file system events.


21-83: LGTM!

Helper functions for edit selection management are well-structured. The index swapping logic correctly handles the bidirectional swap case (a↔b).


501-527: LGTM!

The apply_model_action method cleanly orchestrates the reducer pattern: it captures focus for history, applies the reduction, records the action, updates status, and runs effects. The use of std::mem::take is appropriate here.


529-561: LGTM!

The run_effects method properly batches async tasks for all effect types. Early return on empty effects is efficient.


563-567: LGTM!

Simple state persistence helper that properly synchronizes view state to runtime storage.


569-577: LGTM!

Selection handling correctly updates view state without recording to history, as selection is view-only state per the PR design.


621-650: LGTM!

Request preparation correctly handles GraphQL mode by forcing POST, building the JSON body, and conditionally adding the Content-Type header. Auth headers are applied at the right stage.


652-677: LGTM!

Save handling properly distinguishes between updating existing requests (HttpFile selection) and creating new files (with explicit path), with appropriate validation for the new file case.


679-697: LGTM!

The handle_saved handler correctly updates selection on success and triggers a rescan to reconcile state. Error cases properly surface the failure to the user.


699-725: LGTM!

Undo/redo handlers correctly restore model state from history, update the view selection from the recorded focus, and refresh the response viewer. The closure pattern for focus restoration is clean.


728-742: LGTM!

Both helper functions are clean and correct. The headers_contain_name function properly handles HTTP header case-insensitivity per RFC 7230.

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.

support ctrl+z

1 participant