use std::sync::Arc;
use std::sync::mpsc::{Receiver, TryRecvError};
use kimun_core::note::property_search_key;
use kimun_core::{NoteVault, OrderBy, OrderField, SearchTerms, with_order_directive};
use crate::components::events::{AppEvent, AppTx};
use crate::components::file_list::{PropertyValues, SortField, SortOrder};
pub fn sort_chip_label(field: &SortField, order: SortOrder) -> String {
let name = match field {
SortField::Name => "Name",
SortField::Title => "Title",
SortField::Property(key) => key.as_str(),
};
format!(
" {} {} ",
truncate_cells(name, SORT_CHIP_MAX_NAME),
order.label()
)
}
pub const SORT_CHIP_MAX_NAME: usize = 12;
fn truncate_cells(s: &str, max: usize) -> std::borrow::Cow<'_, str> {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if s.width() <= max {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::new();
let mut w = 0;
for c in s.chars() {
let cw = c.width().unwrap_or(0);
if w + cw > max.saturating_sub(1) {
break;
}
out.push(c);
w += cw;
}
out.push('…');
std::borrow::Cow::Owned(out)
}
pub fn sort_chip_line(
field: &SortField,
order: SortOrder,
theme: &crate::settings::themes::Theme,
) -> ratatui::text::Line<'static> {
ratatui::text::Line::from(ratatui::text::Span::styled(
sort_chip_label(field, order),
theme.action(),
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct SortState {
pub field: SortField,
pub order: SortOrder,
pub group_dirs: Option<bool>,
}
pub trait SortableList {
fn sort_state(&self) -> SortState;
fn apply_sort(&mut self, state: &SortState, tx: &AppTx);
fn allows_property(&self) -> bool;
fn is_unsorted(&self) -> bool {
false
}
}
struct FetchResult {
key: String,
values: PropertyValues,
failed: bool,
}
#[derive(Default)]
pub struct PropertySort {
key: Option<String>,
values: Option<PropertyValues>,
rx: Option<Receiver<FetchResult>>,
failed: bool,
#[cfg(test)]
pub(crate) fetches: usize,
}
impl PropertySort {
pub fn values_for(&self, key: &str) -> Option<PropertyValues> {
let key = property_search_key(key)?;
(self.key.as_deref() == Some(key.as_str()))
.then(|| self.values.clone())
.flatten()
}
pub fn values_for_field(&self, field: &SortField) -> Option<PropertyValues> {
property_key(field).and_then(|k| self.values_for(k))
}
pub fn is_awaiting(&self, field: &SortField) -> bool {
property_key(field).is_some_and(|k| self.values_for(k).is_none())
}
pub fn fetch(&mut self, vault: &Arc<NoteVault>, key: &str, tx: &AppTx) {
let Some(key) = property_search_key(key) else {
return;
};
if self.key.as_deref() != Some(key.as_str()) {
self.values = None;
self.key = Some(key.clone());
}
self.failed = false;
#[cfg(test)]
{
self.fetches += 1;
}
let (result_tx, result_rx) = std::sync::mpsc::channel();
self.rx = Some(result_rx);
let vault = Arc::clone(vault);
let tx = tx.clone();
tokio::spawn(async move {
let (values, failed) = match vault.property_sort_values(&key).await {
Ok(values) => (values, false),
Err(e) => {
report_fetch_error(&key, &e, &tx);
(Default::default(), true)
}
};
result_tx
.send(FetchResult {
key,
values: Arc::new(values),
failed,
})
.ok();
tx.send(AppEvent::Redraw).ok();
});
}
pub fn ensure(&mut self, vault: &Arc<NoteVault>, key: &str, tx: &AppTx) {
let Some(folded) = property_search_key(key) else {
return;
};
let same_key = self.key.as_deref() == Some(folded.as_str());
if same_key && (self.rx.is_some() || (self.values.is_some() && !self.failed)) {
return;
}
self.fetch(vault, key, tx);
}
pub fn sync(&mut self, vault: &Arc<NoteVault>, field: &SortField, tx: &AppTx) {
match property_key(field) {
Some(key) => self.ensure(vault, key, tx),
None => self.clear(),
}
}
pub fn clear(&mut self) {
self.key = None;
self.values = None;
self.rx = None;
self.failed = false;
}
pub fn poll(&mut self) -> Option<(String, PropertyValues)> {
let rx = self.rx.as_ref()?;
match rx.try_recv() {
Ok(FetchResult {
key,
values,
failed,
}) => {
self.rx = None;
self.failed = failed;
Some((key, values))
}
Err(TryRecvError::Disconnected) => {
self.rx = None;
None
}
Err(TryRecvError::Empty) => None,
}
}
pub fn receive(&mut self, key: &str, values: PropertyValues) -> bool {
let key = property_search_key(key);
if key.is_none() || self.key != key {
return false;
}
self.values = Some(values);
true
}
#[cfg(test)]
pub fn is_pending(&self) -> bool {
self.rx.is_some()
}
}
fn report_fetch_error(key: &str, e: &dyn std::fmt::Display, tx: &AppTx) {
tracing::warn!("property sort values for {key}: {e}");
tx.send(AppEvent::FlashMessage(format!(
"couldn't load property values: {e}"
)))
.ok();
}
pub fn property_key(field: &SortField) -> Option<&str> {
match field {
SortField::Property(key) => Some(key.as_str()),
_ => None,
}
}
pub fn is_blank_property(field: &SortField) -> bool {
matches!(field, SortField::Property(key) if key.trim().is_empty())
}
pub fn order_of_query(query: &str) -> (SortField, SortOrder) {
directive_of_query(query).unwrap_or((SortField::Name, SortOrder::Ascending))
}
pub fn directive_of_query(query: &str) -> Option<(SortField, SortOrder)> {
let st = SearchTerms::from_query_string(query);
let (field, asc) = match st.order_by.first()? {
OrderBy::Title { asc } => (SortField::Title, *asc),
OrderBy::FileName { asc } => (SortField::Name, *asc),
OrderBy::Property { key, asc } => (SortField::Property(key.clone()), *asc),
};
let order = if asc {
SortOrder::Ascending
} else {
SortOrder::Descending
};
Some((field, order))
}
pub fn query_with_sort(query: &str, field: &SortField, order: SortOrder) -> Option<String> {
if is_blank_property(field) {
return None;
}
let order_field = match field {
SortField::Name => OrderField::FileName,
SortField::Title => OrderField::Title,
SortField::Property(key) => OrderField::Property(key.clone()),
};
let asc = matches!(order, SortOrder::Ascending);
Some(with_order_directive(query, order_field, asc))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sort_chip_names_the_field_and_cuts_long_keys() {
assert_eq!(
sort_chip_label(&SortField::Name, SortOrder::Ascending),
" Name ↑ "
);
assert_eq!(
sort_chip_label(&SortField::Property("due".into()), SortOrder::Descending),
" due ↓ "
);
let long = sort_chip_label(
&SortField::Property("a-very-long-property-name".into()),
SortOrder::Ascending,
);
assert_eq!(long, " a-very-long… ↑ ");
let name = long.trim().trim_end_matches(" ↑");
assert_eq!(
unicode_width::UnicodeWidthStr::width(name),
SORT_CHIP_MAX_NAME
);
}
#[test]
fn order_of_query_reads_the_directive() {
assert_eq!(
order_of_query("widget -or:title"),
(SortField::Title, SortOrder::Descending)
);
assert_eq!(
order_of_query("#work or:prop:due"),
(SortField::Property("due".into()), SortOrder::Ascending)
);
assert_eq!(
order_of_query("widget"),
(SortField::Name, SortOrder::Ascending)
);
}
#[test]
fn query_with_sort_round_trips_and_skips_empty_keys() {
let q = query_with_sort("x", &SortField::Name, SortOrder::Descending).unwrap();
assert_eq!(order_of_query(&q), (SortField::Name, SortOrder::Descending));
assert_eq!(
query_with_sort("x", &SortField::Property(" ".into()), SortOrder::Ascending),
None
);
}
#[test]
fn property_sort_cache_folds_the_key_like_core() {
use kimun_core::PropertySortValue::Number;
let mut sort = PropertySort {
key: Some("rank".into()),
..Default::default()
};
let values: PropertyValues = Arc::new(std::collections::HashMap::from([(
kimun_core::nfs::VaultPath::note_path_from("/a"),
Number(1.0),
)]));
assert!(sort.receive(" Rank ", values));
assert!(sort.values_for("RANK").is_some());
assert!(sort.values_for("rank").is_some());
assert!(sort.values_for("other").is_none());
assert!(sort.values_for(" ").is_none());
assert!(!sort.receive("other", Arc::default()));
}
#[tokio::test(flavor = "multi_thread")]
async fn fetch_reuses_the_cache_for_another_casing() {
use kimun_core::PropertySortValue::Number;
let vault = crate::test_support::temp_vault("prop-sort-casing").await;
vault.validate_and_init().await.unwrap();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut sort = PropertySort::default();
sort.fetch(&vault, "Rank", &tx);
let values: PropertyValues = Arc::new(std::collections::HashMap::from([(
kimun_core::nfs::VaultPath::note_path_from("/a"),
Number(1.0),
)]));
assert!(sort.receive("rank", values));
sort.fetch(&vault, "RANK", &tx);
assert!(
sort.values_for("rank").is_some(),
"a refetch under another casing keeps the cached values"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn ensure_fetches_only_when_the_key_changes() {
let vault = crate::test_support::temp_vault("prop-sort-ensure").await;
vault.validate_and_init().await.unwrap();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut sort = PropertySort::default();
sort.ensure(&vault, "rank", &tx);
assert_eq!(sort.fetches, 1);
sort.ensure(&vault, "Rank", &tx);
assert_eq!(sort.fetches, 1, "same key while in flight: no refetch");
assert!(sort.receive("rank", Arc::default()));
sort.rx = None;
sort.ensure(&vault, " RANK ", &tx);
assert_eq!(sort.fetches, 1, "same key with cached values: no refetch");
sort.ensure(&vault, "due", &tx);
assert_eq!(sort.fetches, 2, "another key fetches");
sort.fetch(&vault, "due", &tx);
assert_eq!(sort.fetches, 3, "fetch always refetches (list reloads)");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_fetch_is_retried_on_the_same_key() {
let vault = crate::test_support::temp_vault("prop-sort-retry").await;
vault.validate_and_init().await.unwrap();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut sort = PropertySort::default();
sort.ensure(&vault, "rank", &tx);
let (result_tx, result_rx) = std::sync::mpsc::channel();
sort.rx = Some(result_rx);
result_tx
.send(FetchResult {
key: "rank".into(),
values: Arc::default(),
failed: true,
})
.unwrap();
let (key, values) = sort.poll().expect("a result");
assert!(sort.receive(&key, values));
assert!(sort.values_for("rank").is_some(), "rows still order");
sort.ensure(&vault, "rank", &tx);
assert_eq!(sort.fetches, 2, "a failed fetch is retried");
}
#[tokio::test(flavor = "multi_thread")]
async fn sync_clears_the_cache_for_a_non_property_sort() {
let vault = crate::test_support::temp_vault("prop-sort-sync").await;
vault.validate_and_init().await.unwrap();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut sort = PropertySort::default();
let rank = SortField::Property("rank".into());
sort.sync(&vault, &rank, &tx);
assert!(sort.receive("rank", Arc::default()));
sort.rx = None;
sort.sync(&vault, &rank, &tx);
assert_eq!(sort.fetches, 1, "same key: reused");
sort.sync(&vault, &SortField::Name, &tx);
assert!(sort.values_for("rank").is_none());
sort.sync(&vault, &rank, &tx);
assert_eq!(sort.fetches, 2, "back from Name: refetched");
}
#[test]
fn a_fetch_error_is_flashed() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
report_fetch_error("rank", &"db gone", &tx);
match rx.try_recv() {
Ok(AppEvent::FlashMessage(msg)) => {
assert_eq!(msg, "couldn't load property values: db gone")
}
other => panic!("expected a flash, got {other:?}"),
}
}
}