Skip to main content

kimun_notes/components/
indexing.rs

1use std::time::Duration;
2
3use ratatui::Frame;
4use ratatui::layout::Alignment;
5use ratatui::layout::{Constraint, Direction, Layout};
6use ratatui::style::Style;
7use ratatui::text::Text;
8use ratatui::widgets::Paragraph;
9use throbber_widgets_tui::{Throbber, ThrobberState};
10
11use crate::components::events::{AppEvent, AppTx};
12use crate::settings::themes::Theme;
13
14pub enum IndexingProgressState {
15    Running {
16        work: tokio::task::JoinHandle<()>,
17        ticker: tokio::task::JoinHandle<()>,
18    },
19    Done(Duration),
20    Failed(String),
21}
22
23impl Drop for IndexingProgressState {
24    fn drop(&mut self) {
25        if let Self::Running { work, ticker } = self {
26            work.abort();
27            ticker.abort();
28        }
29    }
30}
31
32pub fn spawn_running(work: tokio::task::JoinHandle<()>, tx: &AppTx) -> IndexingProgressState {
33    let tx2 = tx.clone();
34    let ticker = tokio::spawn(async move {
35        loop {
36            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
37            if tx2.send(AppEvent::Redraw).is_err() {
38                break;
39            }
40        }
41    });
42    IndexingProgressState::Running { work, ticker }
43}
44
45pub use crate::components::fixed_centered_rect;
46
47/// Render a centered indexing progress dialog over the current frame.
48///
49/// - `running_label`: text shown next to the throbber spinner while running.
50///   Both the throbber and the label are centered in the dialog box.
51/// - Done/Failed states render centered status text and a `[ OK ]` hint.
52pub fn render_indexing_overlay(
53    f: &mut Frame,
54    state: &IndexingProgressState,
55    throbber_state: &mut ThrobberState,
56    theme: &Theme,
57    running_label: &str,
58) {
59    let area = fixed_centered_rect(44, 5, f.area());
60    let inner = crate::components::panel::modal_chrome(
61        f,
62        area,
63        theme,
64        crate::components::panel::ModalSpec {
65            title: Some("Indexing"),
66            border: Some(Style::default().fg(theme.accent.to_ratatui())),
67            bg: crate::components::panel::ModalBg::Base,
68        },
69    );
70
71    match state {
72        IndexingProgressState::Running { .. } => {
73            throbber_state.calc_next();
74            // +2 for the spinner char and the space throbber_widgets_tui inserts before the label
75            let content_width = (running_label.chars().count() as u16).saturating_add(2);
76            let vert = Layout::default()
77                .direction(Direction::Vertical)
78                .constraints([
79                    Constraint::Min(0),
80                    Constraint::Length(1),
81                    Constraint::Min(0),
82                ])
83                .split(inner);
84            let horiz = Layout::default()
85                .direction(Direction::Horizontal)
86                .constraints([
87                    Constraint::Min(0),
88                    Constraint::Length(content_width),
89                    Constraint::Min(0),
90                ])
91                .split(vert[1]);
92            let throbber = Throbber::default().label(running_label).style(
93                Style::default()
94                    .fg(theme.fg.to_ratatui())
95                    .bg(theme.bg.to_ratatui()),
96            );
97            f.render_stateful_widget(throbber, horiz[1], throbber_state);
98        }
99        IndexingProgressState::Done(dur) => {
100            f.render_widget(
101                Paragraph::new(Text::raw(format!(
102                    "✓  Done in {}s\n\n[ OK ]",
103                    dur.as_secs()
104                )))
105                .alignment(Alignment::Center)
106                .style(theme.base_style()),
107                inner,
108            );
109        }
110        IndexingProgressState::Failed(msg) => {
111            f.render_widget(
112                Paragraph::new(Text::raw(format!("✗  {}\n\n[ OK ]", msg)))
113                    .alignment(Alignment::Center)
114                    .style(theme.base_style()),
115                inner,
116            );
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::sync::{
125        Arc,
126        atomic::{AtomicBool, Ordering},
127    };
128
129    #[tokio::test]
130    async fn drop_aborts_running_tasks() {
131        let completed = Arc::new(AtomicBool::new(false));
132        let completed2 = completed.clone();
133
134        let work = tokio::spawn(async move {
135            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
136            completed2.store(true, Ordering::SeqCst);
137        });
138        let ticker = tokio::spawn(async {
139            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
140        });
141
142        let state = IndexingProgressState::Running { work, ticker };
143        drop(state);
144
145        // Yield several times: abort() is cooperative, the task needs at least one
146        // poll after cancellation is posted before it is marked finished.
147        for _ in 0..10 {
148            tokio::task::yield_now().await;
149        }
150
151        assert!(
152            !completed.load(Ordering::SeqCst),
153            "work task should be aborted, not completed"
154        );
155    }
156}