Skip to main content

datui_lib/widgets/text_input/
history.rs

1//! Recall of previously submitted values for [`super::TextInput`].
2//!
3//! Each history has an id, which is also the name of the cache file it is
4//! persisted to, so the query, SQL and fuzzy inputs each keep their own list.
5//! Loading is lazy: nothing touches the disk until the user actually walks back
6//! through the history or submits a value.
7//!
8//! Reading and writing go through [`CacheManager`], which holds a lock across
9//! the whole read-modify-write. Two datui instances submitting a query at the
10//! same moment then merge instead of one overwriting the other.
11
12use color_eyre::Result;
13
14use crate::cache::CacheManager;
15
16/// Append an entry, skipping it when it repeats the previous one.
17///
18/// Only consecutive duplicates are dropped: a value the user returns to after
19/// trying something else is worth its own slot in the list.
20pub fn push_entry(entries: &mut Vec<String>, entry: String) {
21    if entries.last() == Some(&entry) {
22        return;
23    }
24    entries.push(entry);
25}
26
27/// Stands for a line break on disk. The file holds one entry per line, and a
28/// statement can span several; U+2028 is Unicode's own line separator, and
29/// nothing typed into a query plausibly holds one.
30const LINE_BREAK_ON_DISK: char = '\u{2028}';
31
32fn to_disk(entry: &str) -> String {
33    entry.replace('\n', &LINE_BREAK_ON_DISK.to_string())
34}
35
36fn from_disk(entry: String) -> String {
37    if entry.contains(LINE_BREAK_ON_DISK) {
38        entry.replace(LINE_BREAK_ON_DISK, "\n")
39    } else {
40        entry
41    }
42}
43
44/// Drop the oldest entries until at most `limit` remain.
45fn trim(entries: &mut Vec<String>, limit: usize) {
46    let excess = entries.len().saturating_sub(limit);
47    entries.drain(..excess);
48}
49
50/// One input's recall state: the entries, where the user is in them, and the
51/// value that was being edited before they started walking back.
52#[derive(Debug, Clone, Default)]
53pub(super) struct InputHistory {
54    pub id: Option<String>,
55    entries: Vec<String>,
56    /// Index into `entries` while walking; `None` means editing a fresh value.
57    index: Option<usize>,
58    /// The in-progress value stashed when the walk began.
59    stash: Option<String>,
60    pub limit: usize,
61    loaded: bool,
62}
63
64impl InputHistory {
65    pub fn new(limit: usize) -> Self {
66        Self {
67            limit,
68            ..Self::default()
69        }
70    }
71
72    pub fn is_enabled(&self) -> bool {
73        self.id.is_some()
74    }
75
76    pub fn entries(&self) -> &[String] {
77        &self.entries
78    }
79
80    /// Load entries from the cache the first time they are needed.
81    pub fn ensure_loaded(&mut self, cache: &CacheManager) -> Result<()> {
82        if self.loaded {
83            return Ok(());
84        }
85        if let Some(id) = &self.id {
86            self.entries = cache
87                .load_history_file(id)?
88                .into_iter()
89                .map(from_disk)
90                .collect();
91            self.loaded = true;
92        }
93        Ok(())
94    }
95
96    /// Record `value` as the newest entry and persist the list.
97    ///
98    /// The persisted copy is re-derived from whatever is on disk, so an entry
99    /// added by another running instance survives this write.
100    pub fn remember(&mut self, value: &str, cache: &CacheManager) -> Result<()> {
101        let Some(id) = self.id.clone() else {
102            return Ok(());
103        };
104        if value.is_empty() {
105            return Ok(());
106        }
107        push_entry(&mut self.entries, value.to_string());
108        trim(&mut self.entries, self.limit);
109
110        let entry = to_disk(value);
111        let limit = self.limit;
112        cache.update_history_file(&id, move |entries| {
113            push_entry(entries, entry);
114            trim(entries, limit);
115        })?;
116        Ok(())
117    }
118
119    /// Add an entry without touching the cache. Test support.
120    #[cfg(test)]
121    pub fn seed(&mut self, entry: String) {
122        self.loaded = true;
123        push_entry(&mut self.entries, entry);
124    }
125
126    /// Stop walking the history without changing the current value.
127    pub fn reset_position(&mut self) {
128        self.index = None;
129        self.stash = None;
130    }
131
132    /// Step to an older entry, returning the value to show.
133    ///
134    /// `current` is stashed on the first step so that walking back down returns
135    /// to it.
136    pub fn older(&mut self, current: &str, cache: Option<&CacheManager>) -> Option<String> {
137        self.id.as_ref()?;
138        if !self.loaded {
139            let cache = cache?;
140            self.ensure_loaded(cache)
141                .inspect_err(|e| log::warn!(target: "datui", "read input history: {e:#}"))
142                .ok()?;
143        }
144        if self.entries.is_empty() {
145            return None;
146        }
147        if self.index.is_none() {
148            self.stash = Some(current.to_string());
149        }
150        let index = match self.index {
151            Some(current) => current.saturating_sub(1),
152            None => self.entries.len() - 1,
153        };
154        self.index = Some(index);
155        self.entries.get(index).cloned()
156    }
157
158    /// Step to a newer entry, returning the value to show. Stepping past the
159    /// newest entry restores the stashed in-progress value.
160    pub fn newer(&mut self) -> Option<String> {
161        self.id.as_ref()?;
162        let index = self.index?;
163        if index + 1 >= self.entries.len() {
164            let stashed = self.stash.take();
165            self.index = None;
166            return stashed;
167        }
168        self.index = Some(index + 1);
169        self.entries.get(index + 1).cloned()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn history_with(entries: &[&str]) -> InputHistory {
178        let mut history = InputHistory::new(100);
179        history.id = Some("test".to_string());
180        history.entries = entries.iter().map(|s| s.to_string()).collect();
181        history.loaded = true;
182        history
183    }
184
185    #[test]
186    fn push_entry_skips_consecutive_duplicates_only() {
187        let mut entries = Vec::new();
188        push_entry(&mut entries, "query1".to_string());
189        push_entry(&mut entries, "query2".to_string());
190        push_entry(&mut entries, "query2".to_string());
191        push_entry(&mut entries, "query1".to_string());
192        assert_eq!(entries, vec!["query1", "query2", "query1"]);
193    }
194
195    #[test]
196    fn walking_back_and_forward_returns_to_the_draft() {
197        let mut history = history_with(&["one", "two", "three"]);
198        assert_eq!(history.older("draft", None).as_deref(), Some("three"));
199        assert_eq!(history.older("draft", None).as_deref(), Some("two"));
200        assert_eq!(history.older("draft", None).as_deref(), Some("one"));
201        // Already at the oldest entry.
202        assert_eq!(history.older("draft", None).as_deref(), Some("one"));
203        assert_eq!(history.newer().as_deref(), Some("two"));
204        assert_eq!(history.newer().as_deref(), Some("three"));
205        assert_eq!(history.newer().as_deref(), Some("draft"));
206        assert_eq!(history.newer(), None);
207    }
208
209    #[test]
210    fn a_statement_over_several_lines_is_one_entry_on_disk() {
211        let sql = "SELECT *\nFROM df";
212        assert!(!to_disk(sql).contains('\n'));
213        assert_eq!(from_disk(to_disk(sql)), sql);
214        assert_eq!(from_disk("select a".to_string()), "select a");
215    }
216
217    #[test]
218    fn walking_forward_without_walking_back_does_nothing() {
219        let mut history = history_with(&["one"]);
220        assert_eq!(history.newer(), None);
221    }
222
223    #[test]
224    fn history_is_inert_without_an_id() {
225        let mut history = InputHistory::new(100);
226        assert!(!history.is_enabled());
227        assert_eq!(history.older("draft", None), None);
228        assert_eq!(history.newer(), None);
229    }
230
231    #[test]
232    fn an_empty_history_has_nothing_to_walk() {
233        let mut history = history_with(&[]);
234        assert_eq!(history.older("draft", None), None);
235    }
236
237    #[test]
238    fn unloaded_history_needs_a_cache_to_walk() {
239        let mut history = InputHistory::new(100);
240        history.id = Some("test".to_string());
241        assert_eq!(history.older("draft", None), None);
242    }
243
244    fn temp_cache() -> (tempfile::TempDir, CacheManager) {
245        let dir = tempfile::tempdir().expect("temp dir");
246        let cache = CacheManager::with_dir(dir.path().to_path_buf());
247        (dir, cache)
248    }
249
250    #[test]
251    fn remembered_entries_survive_a_reload() {
252        let (_dir, cache) = temp_cache();
253        let mut history = InputHistory::new(100);
254        history.id = Some("query".to_string());
255        history.remember("select one", &cache).expect("remember");
256        history.remember("select two", &cache).expect("remember");
257
258        let mut reloaded = InputHistory::new(100);
259        reloaded.id = Some("query".to_string());
260        reloaded.ensure_loaded(&cache).expect("load");
261        assert_eq!(reloaded.entries(), ["select one", "select two"]);
262    }
263
264    #[test]
265    fn empty_values_are_not_remembered() {
266        let (_dir, cache) = temp_cache();
267        let mut history = InputHistory::new(100);
268        history.id = Some("query".to_string());
269        history.remember("", &cache).expect("remember");
270        assert!(history.entries().is_empty());
271    }
272
273    #[test]
274    fn the_limit_drops_the_oldest_entries() {
275        let (_dir, cache) = temp_cache();
276        let mut history = InputHistory::new(2);
277        history.id = Some("query".to_string());
278        for entry in ["a", "b", "c"] {
279            history.remember(entry, &cache).expect("remember");
280        }
281        assert_eq!(history.entries(), ["b", "c"]);
282
283        let mut reloaded = InputHistory::new(2);
284        reloaded.id = Some("query".to_string());
285        reloaded.ensure_loaded(&cache).expect("load");
286        assert_eq!(reloaded.entries(), ["b", "c"]);
287    }
288
289    #[test]
290    fn an_entry_written_by_another_instance_is_kept() {
291        let (_dir, cache) = temp_cache();
292        cache
293            .save_history_file("query", &["from elsewhere".to_string()])
294            .expect("seed file");
295
296        let mut history = InputHistory::new(100);
297        history.id = Some("query".to_string());
298        history.remember("mine", &cache).expect("remember");
299
300        let stored = cache.load_history_file("query").expect("load");
301        assert_eq!(stored, ["from elsewhere", "mine"]);
302    }
303
304    #[test]
305    fn a_history_without_an_id_is_never_persisted() {
306        let (_dir, cache) = temp_cache();
307        let mut history = InputHistory::new(100);
308        history.remember("ignored", &cache).expect("remember");
309        assert!(history.entries().is_empty());
310    }
311
312    #[test]
313    fn resetting_the_position_drops_the_stash() {
314        let mut history = history_with(&["one"]);
315        history.older("draft", None);
316        history.reset_position();
317        assert_eq!(history.newer(), None);
318    }
319}