1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
//! Live-review-mode state bundle, extracted from `App` in v1.6 and
//! encapsulated in v1.7.3.
//!
//! Five fields that co-move on every live-mode lifecycle event (start,
//! rescan, reanchor, orphan selection): `active`, `last_refresh_at`,
//! `pending_rescan`, `cached_file_contents`, `last_selected_orphan`.
//! Grouping them into one struct makes the live-mode path legible
//! without surfing past ~50 unrelated App fields.
//!
//! Encapsulation pass (v1.7.3): mutation helpers that only touch
//! these five fields (`activate`, `deactivate`, `mark_refreshed`,
//! `request_rescan`, `drain_rescan`, `remember_orphan`, `take_orphan`)
//! live on the struct. App-level methods that also need `&App`
//! (watcher wiring, diff reload) continue to live in `main.rs` and
//! `app/mod.rs` — those call the struct helpers for the data-only
//! bits.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// See module docs for the cluster rationale.
#[derive(Debug, Default)]
pub struct LiveModeState {
/// True when live review mode is active (file watcher running).
/// Toggled by the `--live` CLI flag or the `:live` / `:live!`
/// commands. The actual `notify` handle lives in the main event
/// loop — this field only exposes the "is live" bit to the rest
/// of the app (status bar, command dispatch) so handlers don't
/// need to know about watcher plumbing.
pub active: bool,
/// Timestamp of the last live-mode-driven rescan, for the
/// status-bar "LIVE · HH:MM:SS" indicator. `None` until the first
/// rescan fires.
pub last_refresh_at: Option<chrono::DateTime<chrono::Local>>,
/// L3 rescan deferral: when a `LiveEvent::Rescan` arrives while
/// the user is in a non-Normal input mode (Comment, Command,
/// Search, etc.), we *don't* reload the diff under their cursor.
/// Instead we set this bit; the next transition back to
/// `InputMode::Normal` drains it by calling `reload_diff_files()`
/// once. Multiple rescans while deferred collapse to a single
/// pending flag — we always want to re-read the latest state,
/// never replay.
pub pending_rescan: bool,
/// L2 rescan-race fix: cached last-known "new side" file contents
/// keyed by display path. Seeded at App construction and refreshed
/// at the end of every `reload_diff_files()` after re-anchoring
/// has consumed the previous snapshot. Feeds
/// `reanchor_comments_against_new_content` as `old_new_content`
/// — reading disk at rescan start would only see the post-change
/// bytes (the watcher fires *after* the write), which would turn
/// the `AnchorMap` into an identity map and silently skip
/// re-anchoring.
pub cached_file_contents: HashMap<PathBuf, String>,
/// L3 orphan-selection memory: when the cursor lands on an
/// `AnnotatedLine::OrphanedComment`, we stash `(path, orphan_idx)`
/// here. The next `A` / `:reanchor` press on a diff line consumes
/// it so the user gets to pick *which* orphan gets re-anchored
/// when a file has more than one. Cleared after consumption;
/// untouched by cursor moves that land on non-orphan rows, so the
/// selection survives the navigation from the Orphaned section
/// down to the target diff line.
pub last_selected_orphan: Option<(PathBuf, usize)>,
}
impl LiveModeState {
// --- lifecycle ---
/// Mark live mode active. Caller is responsible for spinning up
/// the watcher thread.
pub fn activate(&mut self) {
self.active = true;
}
/// Mark live mode inactive and clear the refresh timestamp so the
/// status-bar indicator disappears until the next `:live` starts
/// a fresh session.
pub fn deactivate(&mut self) {
self.active = false;
self.last_refresh_at = None;
}
/// Stamp the last-refresh timestamp to "now" in the local timezone.
/// Called after a successful rescan so the status bar shows when
/// the diff was last reloaded.
pub fn mark_refreshed(&mut self) {
self.last_refresh_at = Some(chrono::Local::now());
}
// --- rescan deferral ---
/// Defer a rescan — the event loop arrived during a non-Normal
/// input mode, so we don't want to reload the diff under the
/// user's cursor. Multiple rescans collapse to a single pending
/// bit (we always re-read the latest state, never replay).
pub fn request_rescan(&mut self) {
self.pending_rescan = true;
}
/// Take and clear the pending-rescan bit. Returns `true` iff a
/// rescan was pending, so the caller can conditionally invoke
/// `reload_diff_files()` + `mark_refreshed()`.
pub fn drain_rescan(&mut self) -> bool {
std::mem::replace(&mut self.pending_rescan, false)
}
// --- orphan selection ---
/// Remember which orphan the cursor is on so the next
/// `A` / `:reanchor` press can target it. Overwrites any prior
/// selection (the cursor only lands on one orphan at a time).
pub fn remember_orphan(&mut self, path: PathBuf, orphan_idx: usize) {
self.last_selected_orphan = Some((path, orphan_idx));
}
/// Consume the orphan selection, if any. Each selection is used
/// exactly once per `:reanchor`.
pub fn take_orphan(&mut self) -> Option<(PathBuf, usize)> {
self.last_selected_orphan.take()
}
/// Clear the orphan selection without consuming it (e.g. after a
/// failed reanchor that doesn't want to retry against the same
/// target).
pub fn clear_orphan(&mut self) {
self.last_selected_orphan = None;
}
// --- cached file contents ---
/// Look up the previous "new side" contents for a display path,
/// used by re-anchoring before the rescan reads the post-change
/// bytes from disk.
pub fn cached_contents(&self, path: &Path) -> Option<&String> {
self.cached_file_contents.get(path)
}
/// Replace the entire cached-contents map. Called at the end of
/// each `reload_diff_files()` with the freshly-read snapshot, so
/// the *next* rescan sees the pre-change bytes as `old_new`.
pub fn replace_cached_contents(&mut self, snapshot: HashMap<PathBuf, String>) {
self.cached_file_contents = snapshot;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activate_and_deactivate_flip_and_clear_timestamp() {
let mut s = LiveModeState::default();
s.activate();
s.mark_refreshed();
assert!(s.active);
assert!(s.last_refresh_at.is_some());
s.deactivate();
assert!(!s.active);
assert!(
s.last_refresh_at.is_none(),
"deactivate must clear the refresh timestamp so the status bar indicator disappears"
);
}
#[test]
fn drain_rescan_is_one_shot_and_collapses_multiple_requests() {
let mut s = LiveModeState::default();
assert!(!s.drain_rescan(), "empty drain returns false");
s.request_rescan();
s.request_rescan();
s.request_rescan();
assert!(s.drain_rescan(), "first drain returns true");
assert!(
!s.drain_rescan(),
"second drain returns false — requests don't replay"
);
}
#[test]
fn orphan_selection_round_trips_and_is_one_shot() {
let mut s = LiveModeState::default();
assert!(s.take_orphan().is_none());
s.remember_orphan(PathBuf::from("a.rs"), 3);
assert_eq!(s.take_orphan(), Some((PathBuf::from("a.rs"), 3)));
assert!(
s.take_orphan().is_none(),
"selection must be consumed exactly once"
);
}
#[test]
fn remember_orphan_overwrites_prior_selection() {
let mut s = LiveModeState::default();
s.remember_orphan(PathBuf::from("a.rs"), 0);
s.remember_orphan(PathBuf::from("b.rs"), 2);
assert_eq!(s.take_orphan(), Some((PathBuf::from("b.rs"), 2)));
}
#[test]
fn clear_orphan_drops_without_returning() {
let mut s = LiveModeState::default();
s.remember_orphan(PathBuf::from("a.rs"), 0);
s.clear_orphan();
assert!(s.take_orphan().is_none());
}
#[test]
fn cached_contents_replace_and_lookup() {
let mut s = LiveModeState::default();
let mut snap = HashMap::new();
snap.insert(PathBuf::from("a.rs"), "hello".into());
snap.insert(PathBuf::from("b.rs"), "world".into());
s.replace_cached_contents(snap);
assert_eq!(
s.cached_contents(&PathBuf::from("a.rs"))
.map(String::as_str),
Some("hello")
);
assert!(s.cached_contents(&PathBuf::from("missing.rs")).is_none());
}
}