1use std::sync::Arc;
8use std::sync::mpsc::{Receiver, TryRecvError};
9
10use kimun_core::note::property_search_key;
11use kimun_core::{NoteVault, OrderBy, OrderField, SearchTerms, with_order_directive};
12
13use crate::components::events::{AppEvent, AppTx};
14use crate::components::file_list::{PropertyValues, SortField, SortOrder};
15
16pub fn sort_chip_label(field: &SortField, order: SortOrder) -> String {
23 let name = match field {
24 SortField::Name => "Name",
25 SortField::Title => "Title",
26 SortField::Property(key) => key.as_str(),
27 };
28 format!(
29 " {} {} ",
30 truncate_cells(name, SORT_CHIP_MAX_NAME),
31 order.label()
32 )
33}
34
35pub const SORT_CHIP_MAX_NAME: usize = 12;
37
38fn truncate_cells(s: &str, max: usize) -> std::borrow::Cow<'_, str> {
40 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
41 if s.width() <= max {
42 return std::borrow::Cow::Borrowed(s);
43 }
44 let mut out = String::new();
45 let mut w = 0;
46 for c in s.chars() {
47 let cw = c.width().unwrap_or(0);
48 if w + cw > max.saturating_sub(1) {
49 break;
50 }
51 out.push(c);
52 w += cw;
53 }
54 out.push('…');
55 std::borrow::Cow::Owned(out)
56}
57
58pub fn sort_chip_line(
61 field: &SortField,
62 order: SortOrder,
63 theme: &crate::settings::themes::Theme,
64) -> ratatui::text::Line<'static> {
65 ratatui::text::Line::from(ratatui::text::Span::styled(
66 sort_chip_label(field, order),
67 theme.action(),
68 ))
69}
70
71#[derive(Debug, Clone, PartialEq)]
73pub struct SortState {
74 pub field: SortField,
75 pub order: SortOrder,
76 pub group_dirs: Option<bool>,
78}
79
80pub trait SortableList {
82 fn sort_state(&self) -> SortState;
83 fn apply_sort(&mut self, state: &SortState, tx: &AppTx);
86 fn allows_property(&self) -> bool;
89 fn is_unsorted(&self) -> bool {
94 false
95 }
96}
97
98struct FetchResult {
101 key: String,
102 values: PropertyValues,
103 failed: bool,
104}
105
106#[derive(Default)]
113pub struct PropertySort {
114 key: Option<String>,
116 values: Option<PropertyValues>,
117 rx: Option<Receiver<FetchResult>>,
118 failed: bool,
121 #[cfg(test)]
123 pub(crate) fetches: usize,
124}
125
126impl PropertySort {
127 pub fn values_for(&self, key: &str) -> Option<PropertyValues> {
130 let key = property_search_key(key)?;
131 (self.key.as_deref() == Some(key.as_str()))
132 .then(|| self.values.clone())
133 .flatten()
134 }
135
136 pub fn values_for_field(&self, field: &SortField) -> Option<PropertyValues> {
139 property_key(field).and_then(|k| self.values_for(k))
140 }
141
142 pub fn is_awaiting(&self, field: &SortField) -> bool {
145 property_key(field).is_some_and(|k| self.values_for(k).is_none())
146 }
147
148 pub fn fetch(&mut self, vault: &Arc<NoteVault>, key: &str, tx: &AppTx) {
152 let Some(key) = property_search_key(key) else {
153 return;
154 };
155 if self.key.as_deref() != Some(key.as_str()) {
156 self.values = None;
157 self.key = Some(key.clone());
158 }
159 self.failed = false;
160 #[cfg(test)]
161 {
162 self.fetches += 1;
163 }
164 let (result_tx, result_rx) = std::sync::mpsc::channel();
165 self.rx = Some(result_rx);
166 let vault = Arc::clone(vault);
167 let tx = tx.clone();
168 tokio::spawn(async move {
169 let (values, failed) = match vault.property_sort_values(&key).await {
170 Ok(values) => (values, false),
171 Err(e) => {
172 report_fetch_error(&key, &e, &tx);
173 (Default::default(), true)
174 }
175 };
176 result_tx
177 .send(FetchResult {
178 key,
179 values: Arc::new(values),
180 failed,
181 })
182 .ok();
183 tx.send(AppEvent::Redraw).ok();
184 });
185 }
186
187 pub fn ensure(&mut self, vault: &Arc<NoteVault>, key: &str, tx: &AppTx) {
192 let Some(folded) = property_search_key(key) else {
193 return;
194 };
195 let same_key = self.key.as_deref() == Some(folded.as_str());
196 if same_key && (self.rx.is_some() || (self.values.is_some() && !self.failed)) {
197 return;
198 }
199 self.fetch(vault, key, tx);
200 }
201
202 pub fn sync(&mut self, vault: &Arc<NoteVault>, field: &SortField, tx: &AppTx) {
206 match property_key(field) {
207 Some(key) => self.ensure(vault, key, tx),
208 None => self.clear(),
209 }
210 }
211
212 pub fn clear(&mut self) {
214 self.key = None;
215 self.values = None;
216 self.rx = None;
217 self.failed = false;
218 }
219
220 pub fn poll(&mut self) -> Option<(String, PropertyValues)> {
223 let rx = self.rx.as_ref()?;
224 match rx.try_recv() {
225 Ok(FetchResult {
226 key,
227 values,
228 failed,
229 }) => {
230 self.rx = None;
231 self.failed = failed;
232 Some((key, values))
233 }
234 Err(TryRecvError::Disconnected) => {
235 self.rx = None;
236 None
237 }
238 Err(TryRecvError::Empty) => None,
239 }
240 }
241
242 pub fn receive(&mut self, key: &str, values: PropertyValues) -> bool {
244 let key = property_search_key(key);
245 if key.is_none() || self.key != key {
246 return false;
247 }
248 self.values = Some(values);
249 true
250 }
251
252 #[cfg(test)]
254 pub fn is_pending(&self) -> bool {
255 self.rx.is_some()
256 }
257}
258
259fn report_fetch_error(key: &str, e: &dyn std::fmt::Display, tx: &AppTx) {
262 tracing::warn!("property sort values for {key}: {e}");
263 tx.send(AppEvent::FlashMessage(format!(
264 "couldn't load property values: {e}"
265 )))
266 .ok();
267}
268
269pub fn property_key(field: &SortField) -> Option<&str> {
271 match field {
272 SortField::Property(key) => Some(key.as_str()),
273 _ => None,
274 }
275}
276
277pub fn is_blank_property(field: &SortField) -> bool {
279 matches!(field, SortField::Property(key) if key.trim().is_empty())
280}
281
282pub fn order_of_query(query: &str) -> (SortField, SortOrder) {
286 directive_of_query(query).unwrap_or((SortField::Name, SortOrder::Ascending))
287}
288
289pub fn directive_of_query(query: &str) -> Option<(SortField, SortOrder)> {
292 let st = SearchTerms::from_query_string(query);
293 let (field, asc) = match st.order_by.first()? {
294 OrderBy::Title { asc } => (SortField::Title, *asc),
295 OrderBy::FileName { asc } => (SortField::Name, *asc),
296 OrderBy::Property { key, asc } => (SortField::Property(key.clone()), *asc),
297 };
298 let order = if asc {
299 SortOrder::Ascending
300 } else {
301 SortOrder::Descending
302 };
303 Some((field, order))
304}
305
306pub fn query_with_sort(query: &str, field: &SortField, order: SortOrder) -> Option<String> {
310 if is_blank_property(field) {
311 return None;
312 }
313 let order_field = match field {
314 SortField::Name => OrderField::FileName,
315 SortField::Title => OrderField::Title,
316 SortField::Property(key) => OrderField::Property(key.clone()),
317 };
318 let asc = matches!(order, SortOrder::Ascending);
319 Some(with_order_directive(query, order_field, asc))
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn sort_chip_names_the_field_and_cuts_long_keys() {
328 assert_eq!(
329 sort_chip_label(&SortField::Name, SortOrder::Ascending),
330 " Name ↑ "
331 );
332 assert_eq!(
333 sort_chip_label(&SortField::Property("due".into()), SortOrder::Descending),
334 " due ↓ "
335 );
336 let long = sort_chip_label(
337 &SortField::Property("a-very-long-property-name".into()),
338 SortOrder::Ascending,
339 );
340 assert_eq!(long, " a-very-long… ↑ ");
341 let name = long.trim().trim_end_matches(" ↑");
342 assert_eq!(
343 unicode_width::UnicodeWidthStr::width(name),
344 SORT_CHIP_MAX_NAME
345 );
346 }
347
348 #[test]
349 fn order_of_query_reads_the_directive() {
350 assert_eq!(
351 order_of_query("widget -or:title"),
352 (SortField::Title, SortOrder::Descending)
353 );
354 assert_eq!(
355 order_of_query("#work or:prop:due"),
356 (SortField::Property("due".into()), SortOrder::Ascending)
357 );
358 assert_eq!(
359 order_of_query("widget"),
360 (SortField::Name, SortOrder::Ascending)
361 );
362 }
363
364 #[test]
365 fn query_with_sort_round_trips_and_skips_empty_keys() {
366 let q = query_with_sort("x", &SortField::Name, SortOrder::Descending).unwrap();
367 assert_eq!(order_of_query(&q), (SortField::Name, SortOrder::Descending));
368 assert_eq!(
369 query_with_sort("x", &SortField::Property(" ".into()), SortOrder::Ascending),
370 None
371 );
372 }
373
374 #[test]
375 fn property_sort_cache_folds_the_key_like_core() {
376 use kimun_core::PropertySortValue::Number;
377 let mut sort = PropertySort {
378 key: Some("rank".into()),
379 ..Default::default()
380 };
381 let values: PropertyValues = Arc::new(std::collections::HashMap::from([(
382 kimun_core::nfs::VaultPath::note_path_from("/a"),
383 Number(1.0),
384 )]));
385 assert!(sort.receive(" Rank ", values));
386 assert!(sort.values_for("RANK").is_some());
387 assert!(sort.values_for("rank").is_some());
388 assert!(sort.values_for("other").is_none());
389 assert!(sort.values_for(" ").is_none());
390 assert!(!sort.receive("other", Arc::default()));
391 }
392
393 #[tokio::test(flavor = "multi_thread")]
394 async fn fetch_reuses_the_cache_for_another_casing() {
395 use kimun_core::PropertySortValue::Number;
396 let vault = crate::test_support::temp_vault("prop-sort-casing").await;
397 vault.validate_and_init().await.unwrap();
398 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
399 let mut sort = PropertySort::default();
400 sort.fetch(&vault, "Rank", &tx);
401 let values: PropertyValues = Arc::new(std::collections::HashMap::from([(
402 kimun_core::nfs::VaultPath::note_path_from("/a"),
403 Number(1.0),
404 )]));
405 assert!(sort.receive("rank", values));
406 sort.fetch(&vault, "RANK", &tx);
407 assert!(
408 sort.values_for("rank").is_some(),
409 "a refetch under another casing keeps the cached values"
410 );
411 }
412
413 #[tokio::test(flavor = "multi_thread")]
416 async fn ensure_fetches_only_when_the_key_changes() {
417 let vault = crate::test_support::temp_vault("prop-sort-ensure").await;
418 vault.validate_and_init().await.unwrap();
419 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
420 let mut sort = PropertySort::default();
421 sort.ensure(&vault, "rank", &tx);
422 assert_eq!(sort.fetches, 1);
423 sort.ensure(&vault, "Rank", &tx);
424 assert_eq!(sort.fetches, 1, "same key while in flight: no refetch");
425 assert!(sort.receive("rank", Arc::default()));
426 sort.rx = None;
427 sort.ensure(&vault, " RANK ", &tx);
428 assert_eq!(sort.fetches, 1, "same key with cached values: no refetch");
429 sort.ensure(&vault, "due", &tx);
430 assert_eq!(sort.fetches, 2, "another key fetches");
431 sort.fetch(&vault, "due", &tx);
432 assert_eq!(sort.fetches, 3, "fetch always refetches (list reloads)");
433 }
434
435 #[tokio::test(flavor = "multi_thread")]
438 async fn a_failed_fetch_is_retried_on_the_same_key() {
439 let vault = crate::test_support::temp_vault("prop-sort-retry").await;
440 vault.validate_and_init().await.unwrap();
441 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
442 let mut sort = PropertySort::default();
443 sort.ensure(&vault, "rank", &tx);
444 let (result_tx, result_rx) = std::sync::mpsc::channel();
446 sort.rx = Some(result_rx);
447 result_tx
448 .send(FetchResult {
449 key: "rank".into(),
450 values: Arc::default(),
451 failed: true,
452 })
453 .unwrap();
454 let (key, values) = sort.poll().expect("a result");
455 assert!(sort.receive(&key, values));
456 assert!(sort.values_for("rank").is_some(), "rows still order");
457 sort.ensure(&vault, "rank", &tx);
458 assert_eq!(sort.fetches, 2, "a failed fetch is retried");
459 }
460
461 #[tokio::test(flavor = "multi_thread")]
463 async fn sync_clears_the_cache_for_a_non_property_sort() {
464 let vault = crate::test_support::temp_vault("prop-sort-sync").await;
465 vault.validate_and_init().await.unwrap();
466 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
467 let mut sort = PropertySort::default();
468 let rank = SortField::Property("rank".into());
469 sort.sync(&vault, &rank, &tx);
470 assert!(sort.receive("rank", Arc::default()));
471 sort.rx = None;
472 sort.sync(&vault, &rank, &tx);
473 assert_eq!(sort.fetches, 1, "same key: reused");
474 sort.sync(&vault, &SortField::Name, &tx);
475 assert!(sort.values_for("rank").is_none());
476 sort.sync(&vault, &rank, &tx);
477 assert_eq!(sort.fetches, 2, "back from Name: refetched");
478 }
479
480 #[test]
481 fn a_fetch_error_is_flashed() {
482 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
483 report_fetch_error("rank", &"db gone", &tx);
484 match rx.try_recv() {
485 Ok(AppEvent::FlashMessage(msg)) => {
486 assert_eq!(msg, "couldn't load property values: db gone")
487 }
488 other => panic!("expected a flash, got {other:?}"),
489 }
490 }
491}