1#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
5
6use crate::storage::history::{Record, Session};
14use crate::tools::tool::Action;
15
16#[derive(Clone, PartialEq, Eq, Debug)]
18pub enum Row {
19 Session { ts: u64 },
22 Entry {
24 seq: u64,
25 ts: u64,
26 command: Option<String>,
27 changed: Vec<String>,
28 },
29}
30
31pub fn rows(records: &[Record], sessions: &[Session]) -> Vec<Row> {
37 let mut rows = Vec::with_capacity(records.len() + sessions.len());
38 let mut sessions = sessions.iter().peekable();
39 for record in records {
40 while let Some(session) = sessions.peek() {
41 if session.seq > record.seq {
42 break;
43 }
44 rows.push(Row::Session { ts: session.ts });
45 sessions.next();
46 }
47 let meta = record.meta.clone().unwrap_or_default();
48 rows.push(Row::Entry {
49 seq: record.seq,
50 ts: meta.ts,
51 command: meta.command,
52 changed: meta.changed,
53 });
54 }
55 rows.extend(sessions.map(|s| Row::Session { ts: s.ts }));
57 rows
58}
59
60pub fn summary(command: Option<&str>, changed: &[String]) -> String {
64 let what = match changed.len() {
65 0 => "no visible change".to_string(),
66 1..=3 => changed.join(", "),
67 n => format!("{}, and {} more", changed[..2].join(", "), n - 2),
68 };
69 match command {
70 Some(command) => format!("{command} — {what}"),
71 None => what,
72 }
73}
74
75pub fn ago(ts: u64, now: u64) -> String {
81 let seconds = now.saturating_sub(ts) / 1000;
82 match seconds {
83 0..=4 => "just now".to_string(),
84 5..=59 => format!("{seconds}s ago"),
85 60..=3599 => format!("{}m ago", seconds / 60),
86 3600..=86_399 => format!("{}h ago", seconds / 3600),
87 _ => format!("{}d ago", seconds / 86_400),
88 }
89}
90
91pub fn timeline(ctx: &egui::Context, open: &mut bool, rows: &[Row], now: u64) -> Option<Action> {
94 let mut action = None;
95 let mut showing = true;
96 egui::Window::new("Timeline")
97 .id(egui::Id::new("timeline"))
98 .open(&mut showing)
99 .resizable(true)
100 .default_width(360.0)
101 .show(ctx, |ui| {
102 if rows.is_empty() {
103 ui.label("No history yet — edits are recorded as you make them.");
104 return;
105 }
106 egui::ScrollArea::vertical().show(ui, |ui| {
107 for row in rows.iter().rev() {
109 match row {
110 Row::Session { ts } => {
111 ui.add_space(4.0);
112 ui.label(
113 egui::RichText::new(format!("opened {}", ago(*ts, now))).weak(),
114 );
115 ui.separator();
116 }
117 Row::Entry {
118 seq,
119 ts,
120 command,
121 changed,
122 } => {
123 ui.horizontal(|ui| {
124 ui.label(egui::RichText::new(ago(*ts, now)).weak());
125 if ui
126 .button(summary(command.as_deref(), changed))
127 .on_hover_text("Restore this state as a new edit")
128 .clicked()
129 {
130 action = Some(Action::RestoreHistory(*seq));
131 }
132 });
133 }
134 }
135 }
136 });
137 });
138 if !showing {
139 *open = false;
140 }
141 action
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::storage::history::Entry;
148
149 fn record(seq: u64, changed: &[&str]) -> Record {
150 Record {
151 seq,
152 meta: Some(Entry {
153 ts: 1000 * seq,
154 command: None,
155 changed: changed.iter().map(|s| (*s).to_string()).collect(),
156 }),
157 }
158 }
159
160 #[test]
161 fn a_session_marker_precedes_the_work_it_started() {
162 let records = vec![record(0, &["b1"]), record(1, &["b2"]), record(2, &["b3"])];
163 let sessions = vec![Session { ts: 1, seq: 0 }, Session { ts: 2, seq: 2 }];
164
165 assert_eq!(
166 rows(&records, &sessions),
167 vec![
168 Row::Session { ts: 1 },
169 Row::Entry {
170 seq: 0,
171 ts: 0,
172 command: None,
173 changed: vec!["b1".to_string()],
174 },
175 Row::Entry {
176 seq: 1,
177 ts: 1000,
178 command: None,
179 changed: vec!["b2".to_string()],
180 },
181 Row::Session { ts: 2 },
182 Row::Entry {
183 seq: 2,
184 ts: 2000,
185 command: None,
186 changed: vec!["b3".to_string()],
187 },
188 ]
189 );
190 }
191
192 #[test]
195 fn a_session_that_made_no_edits_still_appears() {
196 let rows = rows(
197 &[record(0, &["b1"])],
198 &[Session { ts: 1, seq: 0 }, Session { ts: 2, seq: 1 }],
199 );
200 assert_eq!(rows.last(), Some(&Row::Session { ts: 2 }));
201 assert_eq!(rows.len(), 3);
202 }
203
204 #[test]
207 fn an_unlabelled_entry_still_gets_a_row() {
208 let rows = rows(&[Record { seq: 4, meta: None }], &[]);
209 assert_eq!(
210 rows,
211 vec![Row::Entry {
212 seq: 4,
213 ts: 0,
214 command: None,
215 changed: Vec::new(),
216 }]
217 );
218 }
219
220 #[test]
221 fn a_summary_names_the_command_and_what_it_touched() {
222 assert_eq!(summary(Some("delete"), &["b3".to_string()]), "delete — b3");
223 assert_eq!(summary(None, &["b3".to_string()]), "b3");
224 assert_eq!(summary(None, &[]), "no visible change");
225 }
226
227 #[test]
229 fn a_long_change_list_is_abbreviated() {
230 let many: Vec<String> = (0..9).map(|n| format!("b{n}")).collect();
231 assert_eq!(summary(None, &many), "b0, b1, and 7 more");
232 }
233
234 #[test]
235 fn elapsed_time_reads_in_the_largest_useful_unit() {
236 let now = 100 * 86_400 * 1000;
237 assert_eq!(ago(now, now), "just now");
238 assert_eq!(ago(now - 30_000, now), "30s ago");
239 assert_eq!(ago(now - 5 * 60_000, now), "5m ago");
240 assert_eq!(ago(now - 3 * 3_600_000, now), "3h ago");
241 assert_eq!(ago(now - 2 * 86_400_000, now), "2d ago");
242 assert_eq!(ago(now + 5000, now), "just now");
244 }
245}