1use std::collections::HashMap;
2use std::sync::mpsc::{self, Receiver, Sender};
3use std::time::Duration;
4
5use crossterm::event::{self, Event, KeyCode};
6use crossterm::execute;
7use crossterm::terminal::{
8 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
9};
10use ratatui::backend::Backend;
11use ratatui::layout::{Constraint, Direction, Layout, Margin};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{
15 Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
16 ScrollbarState, Wrap,
17};
18use ratatui::{Frame, Terminal};
19
20use crate::api::{ApiClient, Transport};
21use crate::error::AppError;
22use crate::security::{SecurityFinding, Severity};
23use crate::services::{
24 document_summaries_from_response, get_document_inspector, latest_task_summary, list_documents,
25 list_tasks, next_page_url_from_response, DashboardSnapshot, DocumentInspector, DocumentQuery,
26 DocumentSummary, TaskSummary,
27};
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum PaneFocus {
31 Documents,
32 Inspector,
33}
34
35#[derive(Clone, Debug)]
36pub struct TuiApp {
37 pub title: String,
38 pub project_summary: String,
39 pub documents: Vec<DocumentSummary>,
40 pub selected_document: usize,
41 pub documents_state: ListState,
42 pub inspector_cache: HashMap<u64, DocumentInspector>,
43 pub loading_documents: bool,
44 pub loading_inspector: bool,
45 pub latest_task: Option<TaskSummary>,
46 pub security: Vec<SecurityFinding>,
47 pub status_line: String,
48 pub loaded_document_count: usize,
49 pub document_total: Option<usize>,
50 pub documents_viewport_height: usize,
51 pub inspector_scroll: usize,
52 pub inspector_viewport_height: usize,
53 pub focus: PaneFocus,
54 pending_inspector_id: Option<u64>,
55}
56
57#[derive(Debug)]
58enum TuiEvent {
59 DocumentsPage {
60 documents: Vec<DocumentSummary>,
61 total: Option<usize>,
62 done: bool,
63 },
64 DocumentsError(String),
65 Inspector {
66 document_id: u64,
67 result: Result<DocumentInspector, String>,
68 },
69 LatestTask(Result<Option<TaskSummary>, String>),
70}
71
72impl TuiApp {
73 pub fn from_snapshot(snapshot: DashboardSnapshot) -> Self {
74 let has_documents = !snapshot.documents.is_empty();
75 let loaded_document_count = snapshot.documents.len();
76 let project_summary = snapshot
77 .project
78 .get("url")
79 .or_else(|| {
80 snapshot
81 .project
82 .get("response")
83 .and_then(|value| value.get("version"))
84 })
85 .and_then(|value| value.as_str())
86 .map(|value| format!("connected {value}"))
87 .unwrap_or_else(|| "connected".to_string());
88
89 Self {
90 title: "paperless-cli".to_string(),
91 project_summary,
92 documents: snapshot.documents,
93 selected_document: 0,
94 documents_state: ListState::default().with_selected(has_documents.then_some(0)),
95 inspector_cache: HashMap::new(),
96 loading_documents: true,
97 loading_inspector: false,
98 latest_task: snapshot.latest_task,
99 security: snapshot.security,
100 status_line:
101 "[Tab] focus [j/k] move [PgUp/PgDn] page [g/G] top/end [r] reload [q] quit"
102 .to_string(),
103 loaded_document_count,
104 document_total: None,
105 documents_viewport_height: 1,
106 inspector_scroll: 0,
107 inspector_viewport_height: 1,
108 focus: PaneFocus::Documents,
109 pending_inspector_id: None,
110 }
111 }
112
113 pub fn on_security_update(&mut self, findings: Vec<SecurityFinding>) {
114 self.security = findings;
115 }
116
117 pub fn current_document_id(&self) -> Option<u64> {
118 self.documents
119 .get(self.selected_document)
120 .map(|document| document.id)
121 }
122
123 pub fn current_inspector(&self) -> Option<&DocumentInspector> {
124 self.current_document_id()
125 .and_then(|document_id| self.inspector_cache.get(&document_id))
126 }
127
128 pub fn selected_metadata(&self) -> Vec<String> {
129 let mut sections = Vec::new();
130
131 if let Some(document) = self.documents.get(self.selected_document) {
132 sections.push(format!("doc {} {}", document.id, document.created));
133 }
134
135 if let Some(inspector) = self.current_inspector() {
136 if !inspector.metadata.is_empty() {
137 sections.push(inspector.metadata.join(" | "));
138 }
139 }
140
141 sections.push(self.project_summary.clone());
142
143 if let Some(task) = &self.latest_task {
144 let detail = if task.note.is_empty() {
145 format!("latest task {}", task.status)
146 } else {
147 format!("latest task {} {}", task.status, task.note)
148 };
149 sections.push(detail);
150 }
151
152 if let Some(finding) = self.security.first() {
153 sections.push(format!(
154 "security {} {}",
155 severity_label(&finding.severity),
156 finding.title
157 ));
158 } else {
159 sections.push("security clear".to_string());
160 }
161
162 sections.push(self.status_line.clone());
163
164 sections
165 }
166
167 pub fn on_documents_page(
168 &mut self,
169 documents: Vec<DocumentSummary>,
170 total: Option<usize>,
171 done: bool,
172 ) -> Option<u64> {
173 let was_empty = self.documents.is_empty();
174 if was_empty {
175 self.pending_inspector_id = None;
176 }
177 self.documents.extend(documents);
178 self.loaded_document_count = self.documents.len();
179 if let Some(total) = total {
180 self.document_total = Some(total);
181 }
182 self.loading_documents = !done;
183 if was_empty && !self.documents.is_empty() {
184 self.set_selected_document(0);
185 self.current_document_id()
186 } else {
187 None
188 }
189 }
190
191 pub fn on_inspector_loaded(&mut self, document_id: u64, inspector: DocumentInspector) {
192 self.loading_inspector = false;
193 self.pending_inspector_id = None;
194 self.inspector_scroll = 0;
195 self.inspector_cache.insert(document_id, inspector);
196 }
197
198 pub fn on_loader_error(&mut self, message: String) {
199 self.loading_documents = false;
200 self.loading_inspector = false;
201 self.pending_inspector_id = None;
202 self.project_summary = format!("error {message}");
203 }
204
205 pub fn request_documents_reload(&mut self) {
206 self.loading_documents = true;
207 self.documents.clear();
208 self.loaded_document_count = 0;
209 self.document_total = None;
210 self.selected_document = 0;
211 self.documents_state.select(None);
212 self.inspector_scroll = 0;
213 self.pending_inspector_id = None;
214 }
215
216 pub fn maybe_request_inspector(&mut self) -> Option<u64> {
217 let document_id = self.current_document_id()?;
218 if self.inspector_cache.contains_key(&document_id)
219 || self.pending_inspector_id == Some(document_id)
220 {
221 return None;
222 }
223
224 self.loading_inspector = true;
225 self.pending_inspector_id = Some(document_id);
226 self.inspector_scroll = 0;
227 Some(document_id)
228 }
229
230 pub fn select_next(&mut self) -> bool {
231 if self.documents.is_empty() {
232 return false;
233 }
234 let next = (self.selected_document + 1) % self.documents.len();
235 let changed = next != self.selected_document;
236 self.set_selected_document(next);
237 changed
238 }
239
240 pub fn select_previous(&mut self) -> bool {
241 if self.documents.is_empty() {
242 return false;
243 }
244 let next = if self.selected_document == 0 {
245 self.documents.len() - 1
246 } else {
247 self.selected_document - 1
248 };
249 let changed = next != self.selected_document;
250 self.set_selected_document(next);
251 changed
252 }
253
254 pub fn select_first(&mut self) -> bool {
255 if self.documents.is_empty() || self.selected_document == 0 {
256 return false;
257 }
258 self.set_selected_document(0);
259 true
260 }
261
262 pub fn select_last(&mut self) -> bool {
263 if self.documents.is_empty() {
264 return false;
265 }
266 let last = self.documents.len() - 1;
267 if self.selected_document == last {
268 return false;
269 }
270 self.set_selected_document(last);
271 true
272 }
273
274 pub fn select_page_down(&mut self) -> bool {
275 if self.documents.is_empty() {
276 return false;
277 }
278 let page = self.documents_viewport_height.max(1);
279 let last = self.documents.len() - 1;
280 let next = (self.selected_document + page).min(last);
281 let changed = next != self.selected_document;
282 self.set_selected_document(next);
283 changed
284 }
285
286 pub fn select_page_up(&mut self) -> bool {
287 if self.documents.is_empty() {
288 return false;
289 }
290 let page = self.documents_viewport_height.max(1);
291 let next = self.selected_document.saturating_sub(page);
292 let changed = next != self.selected_document;
293 self.set_selected_document(next);
294 changed
295 }
296
297 fn set_selected_document(&mut self, index: usize) {
298 self.selected_document = index;
299 self.inspector_scroll = 0;
300 let selected = if self.documents.is_empty() {
301 None
302 } else {
303 Some(index.min(self.documents.len() - 1))
304 };
305 self.documents_state.select(selected);
306 }
307
308 pub fn toggle_focus(&mut self) {
309 self.focus = match self.focus {
310 PaneFocus::Documents => PaneFocus::Inspector,
311 PaneFocus::Inspector => PaneFocus::Documents,
312 };
313 }
314
315 pub fn scroll_inspector_down(&mut self) -> bool {
316 let max_scroll = self.max_inspector_scroll();
317 if self.inspector_scroll >= max_scroll {
318 return false;
319 }
320 self.inspector_scroll = (self.inspector_scroll + 1).min(max_scroll);
321 true
322 }
323
324 pub fn scroll_inspector_up(&mut self) -> bool {
325 if self.inspector_scroll == 0 {
326 return false;
327 }
328 self.inspector_scroll = self.inspector_scroll.saturating_sub(1);
329 true
330 }
331
332 pub fn scroll_inspector_page_down(&mut self) -> bool {
333 let max_scroll = self.max_inspector_scroll();
334 if self.inspector_scroll >= max_scroll {
335 return false;
336 }
337 let page = self.inspector_viewport_height.max(1);
338 self.inspector_scroll = (self.inspector_scroll + page).min(max_scroll);
339 true
340 }
341
342 pub fn scroll_inspector_page_up(&mut self) -> bool {
343 if self.inspector_scroll == 0 {
344 return false;
345 }
346 let page = self.inspector_viewport_height.max(1);
347 self.inspector_scroll = self.inspector_scroll.saturating_sub(page);
348 true
349 }
350
351 pub fn scroll_inspector_top(&mut self) -> bool {
352 if self.inspector_scroll == 0 {
353 return false;
354 }
355 self.inspector_scroll = 0;
356 true
357 }
358
359 pub fn scroll_inspector_bottom(&mut self) -> bool {
360 let max_scroll = self.max_inspector_scroll();
361 if self.inspector_scroll == max_scroll {
362 return false;
363 }
364 self.inspector_scroll = max_scroll;
365 true
366 }
367
368 fn max_inspector_scroll(&self) -> usize {
369 let line_count = self
370 .current_inspector()
371 .map(|inspector| inspector.text.lines().count())
372 .unwrap_or(1);
373 line_count.saturating_sub(self.inspector_viewport_height.max(1))
374 }
375}
376
377pub fn run_tui<T: Transport + Clone + Send + 'static>(
378 client: ApiClient<T>,
379 snapshot: DashboardSnapshot,
380 security_receiver: Receiver<Vec<SecurityFinding>>,
381) -> Result<(), AppError> {
382 enable_raw_mode()?;
383 let mut stdout = std::io::stdout();
384 execute!(stdout, EnterAlternateScreen)?;
385 let backend = ratatui::backend::CrosstermBackend::new(stdout);
386 let mut terminal = Terminal::new(backend)?;
387 let mut app = TuiApp::from_snapshot(snapshot);
388 let (event_sender, event_receiver) = mpsc::channel();
389
390 spawn_documents_loader(client.clone(), event_sender.clone());
391 spawn_latest_task_loader(client.clone(), event_sender.clone());
392
393 let result = run_event_loop(
394 &mut terminal,
395 &mut app,
396 client,
397 security_receiver,
398 event_sender,
399 event_receiver,
400 );
401
402 disable_raw_mode()?;
403 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
404 terminal.show_cursor()?;
405 result
406}
407
408fn run_event_loop<B: Backend, T: Transport + Clone + Send + 'static>(
409 terminal: &mut Terminal<B>,
410 app: &mut TuiApp,
411 client: ApiClient<T>,
412 security_receiver: Receiver<Vec<SecurityFinding>>,
413 event_sender: Sender<TuiEvent>,
414 event_receiver: Receiver<TuiEvent>,
415) -> Result<(), AppError> {
416 loop {
417 while let Ok(findings) = security_receiver.try_recv() {
418 app.on_security_update(findings);
419 }
420
421 while let Ok(event) = event_receiver.try_recv() {
422 match event {
423 TuiEvent::DocumentsPage {
424 documents,
425 total,
426 done,
427 } => {
428 if let Some(document_id) = app.on_documents_page(documents, total, done) {
429 spawn_inspector_loader(client.clone(), event_sender.clone(), document_id);
430 }
431 }
432 TuiEvent::DocumentsError(error) => app.on_loader_error(error),
433 TuiEvent::Inspector {
434 document_id,
435 result,
436 } => match result {
437 Ok(inspector) => app.on_inspector_loaded(document_id, inspector),
438 Err(error) => app.on_loader_error(error),
439 },
440 TuiEvent::LatestTask(result) => match result {
441 Ok(task) => app.latest_task = task,
442 Err(error) => app.on_loader_error(error),
443 },
444 }
445 }
446
447 terminal.draw(|frame| draw(frame, app))?;
448
449 if event::poll(Duration::from_millis(100))? {
450 if let Event::Key(key) = event::read()? {
451 let moved = match key.code {
452 KeyCode::Char('q') => return Ok(()),
453 KeyCode::Tab => {
454 app.toggle_focus();
455 false
456 }
457 KeyCode::Char('j') | KeyCode::Down => match app.focus {
458 PaneFocus::Documents => app.select_next(),
459 PaneFocus::Inspector => app.scroll_inspector_down(),
460 },
461 KeyCode::Char('k') | KeyCode::Up => match app.focus {
462 PaneFocus::Documents => app.select_previous(),
463 PaneFocus::Inspector => app.scroll_inspector_up(),
464 },
465 KeyCode::PageDown => match app.focus {
466 PaneFocus::Documents => app.select_page_down(),
467 PaneFocus::Inspector => app.scroll_inspector_page_down(),
468 },
469 KeyCode::PageUp => match app.focus {
470 PaneFocus::Documents => app.select_page_up(),
471 PaneFocus::Inspector => app.scroll_inspector_page_up(),
472 },
473 KeyCode::Char('g') => match app.focus {
474 PaneFocus::Documents => app.select_first(),
475 PaneFocus::Inspector => app.scroll_inspector_top(),
476 },
477 KeyCode::Char('G') => match app.focus {
478 PaneFocus::Documents => app.select_last(),
479 PaneFocus::Inspector => app.scroll_inspector_bottom(),
480 },
481 KeyCode::Char('r') => {
482 app.request_documents_reload();
483 spawn_documents_loader(client.clone(), event_sender.clone());
484 spawn_latest_task_loader(client.clone(), event_sender.clone());
485 false
486 }
487 _ => false,
488 };
489
490 if moved {
491 if let Some(document_id) = app.maybe_request_inspector() {
492 spawn_inspector_loader(client.clone(), event_sender.clone(), document_id);
493 }
494 }
495 }
496 }
497 }
498}
499
500fn spawn_documents_loader<T: Transport + Clone + Send + 'static>(
501 client: ApiClient<T>,
502 event_sender: Sender<TuiEvent>,
503) {
504 std::thread::spawn(move || {
505 let mut query = DocumentQuery::new();
506 query.page_size = 25;
507 query.page = 1;
508
509 loop {
510 match list_documents(&client, &query) {
511 Ok(response) => {
512 let documents = document_summaries_from_response(&response);
513 let total = response
514 .get("count")
515 .and_then(serde_json::Value::as_u64)
516 .map(|value| value as usize);
517 let done = next_page_url_from_response(&response).is_none();
518
519 if event_sender
520 .send(TuiEvent::DocumentsPage {
521 documents,
522 total,
523 done,
524 })
525 .is_err()
526 {
527 break;
528 }
529
530 if done {
531 break;
532 }
533
534 query.page += 1;
535 }
536 Err(error) => {
537 let _ = event_sender.send(TuiEvent::DocumentsError(error.to_string()));
538 break;
539 }
540 }
541 }
542 });
543}
544
545fn spawn_inspector_loader<T: Transport + Clone + Send + 'static>(
546 client: ApiClient<T>,
547 event_sender: Sender<TuiEvent>,
548 document_id: u64,
549) {
550 std::thread::spawn(move || {
551 let result =
552 get_document_inspector(&client, document_id).map_err(|error| error.to_string());
553 let _ = event_sender.send(TuiEvent::Inspector {
554 document_id,
555 result,
556 });
557 });
558}
559
560fn spawn_latest_task_loader<T: Transport + Clone + Send + 'static>(
561 client: ApiClient<T>,
562 event_sender: Sender<TuiEvent>,
563) {
564 std::thread::spawn(move || {
565 let result = list_tasks(&client)
566 .map(|tasks| latest_task_summary(&tasks))
567 .map_err(|error| error.to_string());
568 let _ = event_sender.send(TuiEvent::LatestTask(result));
569 });
570}
571
572pub fn draw(frame: &mut Frame<'_>, app: &mut TuiApp) {
573 let rows = Layout::default()
574 .direction(Direction::Vertical)
575 .constraints([
576 Constraint::Length(3),
577 Constraint::Min(12),
578 Constraint::Length(6),
579 ])
580 .split(frame.area());
581 let body = Layout::default()
582 .direction(Direction::Horizontal)
583 .constraints([Constraint::Percentage(36), Constraint::Percentage(64)])
584 .split(rows[1]);
585
586 let title = Paragraph::new(Line::from(vec![
587 Span::styled(
588 "paperless-cli",
589 Style::default()
590 .fg(Color::Cyan)
591 .add_modifier(Modifier::BOLD),
592 ),
593 Span::raw(" "),
594 Span::styled("Document browser", Style::default().fg(Color::Magenta)),
595 Span::raw(" "),
596 Span::styled(
597 match app.document_total {
598 Some(total) if app.loading_documents => {
599 format!("{}/{} docs", app.loaded_document_count, total)
600 }
601 Some(total) => format!("{} docs", total),
602 None if app.loading_documents => {
603 format!("{} docs loading", app.loaded_document_count)
604 }
605 None => format!("{} docs", app.loaded_document_count),
606 },
607 Style::default().fg(Color::DarkGray),
608 ),
609 ]))
610 .block(Block::default().borders(Borders::ALL).title("Overview"));
611 frame.render_widget(title, rows[0]);
612
613 let document_items = if app.loading_documents && app.documents.is_empty() {
614 vec![ListItem::new("Loading first page…")]
615 } else if app.documents.is_empty() {
616 vec![ListItem::new("No documents loaded.")]
617 } else {
618 let mut items = app
619 .documents
620 .iter()
621 .map(|document| {
622 ListItem::new(format!(
623 "{:>5} {} {}",
624 document.id, document.created, document.title
625 ))
626 })
627 .collect::<Vec<_>>();
628 if app.loading_documents {
629 items.push(ListItem::new("… loading more"));
630 }
631 items
632 };
633 let documents_block = Block::default().borders(Borders::ALL).title("Documents");
634 let documents_block = if app.focus == PaneFocus::Documents {
635 documents_block.border_style(Style::default().fg(Color::Cyan))
636 } else {
637 documents_block
638 };
639 let documents_inner = documents_block.inner(body[0]);
640 frame.render_widget(documents_block, body[0]);
641
642 if documents_inner.width > 0 && documents_inner.height > 0 {
643 app.documents_viewport_height = documents_inner.height as usize;
644 let document_columns = if documents_inner.width > 2 {
645 Layout::default()
646 .direction(Direction::Horizontal)
647 .constraints([Constraint::Min(1), Constraint::Length(1)])
648 .split(documents_inner)
649 } else {
650 Layout::default()
651 .direction(Direction::Horizontal)
652 .constraints([Constraint::Min(1)])
653 .split(documents_inner)
654 };
655
656 let documents = List::new(document_items)
657 .highlight_style(
658 Style::default()
659 .fg(Color::Black)
660 .bg(Color::Cyan)
661 .add_modifier(Modifier::BOLD),
662 )
663 .highlight_symbol("▶ ");
664 frame.render_stateful_widget(documents, document_columns[0], &mut app.documents_state);
665
666 if document_columns.len() > 1 {
667 let content_length = app.documents.len() + usize::from(app.loading_documents);
668 let viewport_height = app.documents_viewport_height;
669 let mut scrollbar_state = ScrollbarState::new(content_length.max(1))
670 .position(app.documents_state.offset())
671 .viewport_content_length(viewport_height.max(1));
672 frame.render_stateful_widget(
673 Scrollbar::new(ScrollbarOrientation::VerticalRight)
674 .begin_symbol(None)
675 .end_symbol(None)
676 .track_symbol(Some("│"))
677 .thumb_symbol("█")
678 .track_style(Style::default().fg(Color::DarkGray))
679 .thumb_style(Style::default().fg(Color::Cyan)),
680 document_columns[1].inner(Margin {
681 vertical: 0,
682 horizontal: 0,
683 }),
684 &mut scrollbar_state,
685 );
686 }
687 } else {
688 app.documents_viewport_height = 1;
689 }
690
691 let inspector_title = if app.loading_inspector {
692 "Inspector · loading"
693 } else {
694 "Inspector"
695 };
696 let inspector_body = if let Some(inspector) = app.current_inspector() {
697 inspector.text.clone()
698 } else if app.loading_documents {
699 "Loading document list…".to_string()
700 } else if app.documents.is_empty() {
701 "No document selected.".to_string()
702 } else {
703 "Loading document text…".to_string()
704 };
705 app.inspector_viewport_height = body[1].height.saturating_sub(2) as usize;
706 let detail = Paragraph::new(inspector_body)
707 .scroll((app.inspector_scroll as u16, 0))
708 .wrap(Wrap { trim: false })
709 .block(
710 Block::default()
711 .borders(Borders::ALL)
712 .border_style(if app.focus == PaneFocus::Inspector {
713 Style::default().fg(Color::Cyan)
714 } else {
715 Style::default()
716 })
717 .title(inspector_title),
718 );
719 frame.render_widget(detail, body[1]);
720
721 let metadata = Paragraph::new(
722 app.selected_metadata()
723 .into_iter()
724 .map(Line::from)
725 .collect::<Vec<_>>(),
726 )
727 .wrap(Wrap { trim: true })
728 .block(Block::default().borders(Borders::ALL).title("Status"));
729 frame.render_widget(metadata, rows[2]);
730}
731
732fn severity_label(severity: &Severity) -> &'static str {
733 match severity {
734 Severity::Critical => "critical",
735 Severity::High => "high",
736 Severity::Medium => "medium",
737 Severity::Low => "low",
738 }
739}