kimun_notes/components/
indexing.rs1use std::time::Duration;
2
3use ratatui::Frame;
4use ratatui::layout::Alignment;
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::Style;
7use ratatui::text::Text;
8use ratatui::widgets::{Block, Borders, Clear, 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 fn fixed_centered_rect(width: u16, height: u16, r: Rect) -> Rect {
46 let x = r.x + (r.width.saturating_sub(width)) / 2;
47 let y = r.y + (r.height.saturating_sub(height)) / 2;
48 Rect {
49 x,
50 y,
51 width: width.min(r.width),
52 height: height.min(r.height),
53 }
54}
55
56pub fn render_indexing_overlay(
62 f: &mut Frame,
63 state: &IndexingProgressState,
64 throbber_state: &mut ThrobberState,
65 theme: &Theme,
66 running_label: &str,
67) {
68 let area = fixed_centered_rect(44, 5, f.area());
69 f.render_widget(Clear, area);
70 let block = Block::default()
71 .title("Indexing")
72 .borders(Borders::ALL)
73 .border_style(Style::default().fg(theme.accent.to_ratatui()))
74 .style(theme.base_style());
75 let inner = block.inner(area);
76 f.render_widget(block, area);
77
78 match state {
79 IndexingProgressState::Running { .. } => {
80 throbber_state.calc_next();
81 let content_width = (running_label.chars().count() as u16).saturating_add(2);
83 let vert = Layout::default()
84 .direction(Direction::Vertical)
85 .constraints([
86 Constraint::Min(0),
87 Constraint::Length(1),
88 Constraint::Min(0),
89 ])
90 .split(inner);
91 let horiz = Layout::default()
92 .direction(Direction::Horizontal)
93 .constraints([
94 Constraint::Min(0),
95 Constraint::Length(content_width),
96 Constraint::Min(0),
97 ])
98 .split(vert[1]);
99 let throbber = Throbber::default().label(running_label).style(
100 Style::default()
101 .fg(theme.fg.to_ratatui())
102 .bg(theme.bg.to_ratatui()),
103 );
104 f.render_stateful_widget(throbber, horiz[1], throbber_state);
105 }
106 IndexingProgressState::Done(dur) => {
107 f.render_widget(
108 Paragraph::new(Text::raw(format!(
109 "✓ Done in {}s\n\n[ OK ]",
110 dur.as_secs()
111 )))
112 .alignment(Alignment::Center)
113 .style(theme.base_style()),
114 inner,
115 );
116 }
117 IndexingProgressState::Failed(msg) => {
118 f.render_widget(
119 Paragraph::new(Text::raw(format!("✗ {}\n\n[ OK ]", msg)))
120 .alignment(Alignment::Center)
121 .style(theme.base_style()),
122 inner,
123 );
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::sync::{
132 Arc,
133 atomic::{AtomicBool, Ordering},
134 };
135
136 #[tokio::test]
137 async fn drop_aborts_running_tasks() {
138 let completed = Arc::new(AtomicBool::new(false));
139 let completed2 = completed.clone();
140
141 let work = tokio::spawn(async move {
142 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
143 completed2.store(true, Ordering::SeqCst);
144 });
145 let ticker = tokio::spawn(async {
146 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
147 });
148
149 let state = IndexingProgressState::Running { work, ticker };
150 drop(state);
151
152 for _ in 0..10 {
155 tokio::task::yield_now().await;
156 }
157
158 assert!(
159 !completed.load(Ordering::SeqCst),
160 "work task should be aborted, not completed"
161 );
162 }
163}