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
448 .draw(|frame| draw(frame, app))
449 .map_err(|error| AppError::Message(error.to_string()))?;
450
451 if event::poll(Duration::from_millis(100))? {
452 if let Event::Key(key) = event::read()? {
453 let moved = match key.code {
454 KeyCode::Char('q') => return Ok(()),
455 KeyCode::Tab => {
456 app.toggle_focus();
457 false
458 }
459 KeyCode::Char('j') | KeyCode::Down => match app.focus {
460 PaneFocus::Documents => app.select_next(),
461 PaneFocus::Inspector => app.scroll_inspector_down(),
462 },
463 KeyCode::Char('k') | KeyCode::Up => match app.focus {
464 PaneFocus::Documents => app.select_previous(),
465 PaneFocus::Inspector => app.scroll_inspector_up(),
466 },
467 KeyCode::PageDown => match app.focus {
468 PaneFocus::Documents => app.select_page_down(),
469 PaneFocus::Inspector => app.scroll_inspector_page_down(),
470 },
471 KeyCode::PageUp => match app.focus {
472 PaneFocus::Documents => app.select_page_up(),
473 PaneFocus::Inspector => app.scroll_inspector_page_up(),
474 },
475 KeyCode::Char('g') => match app.focus {
476 PaneFocus::Documents => app.select_first(),
477 PaneFocus::Inspector => app.scroll_inspector_top(),
478 },
479 KeyCode::Char('G') => match app.focus {
480 PaneFocus::Documents => app.select_last(),
481 PaneFocus::Inspector => app.scroll_inspector_bottom(),
482 },
483 KeyCode::Char('r') => {
484 app.request_documents_reload();
485 spawn_documents_loader(client.clone(), event_sender.clone());
486 spawn_latest_task_loader(client.clone(), event_sender.clone());
487 false
488 }
489 _ => false,
490 };
491
492 if moved {
493 if let Some(document_id) = app.maybe_request_inspector() {
494 spawn_inspector_loader(client.clone(), event_sender.clone(), document_id);
495 }
496 }
497 }
498 }
499 }
500}
501
502fn spawn_documents_loader<T: Transport + Clone + Send + 'static>(
503 client: ApiClient<T>,
504 event_sender: Sender<TuiEvent>,
505) {
506 std::thread::spawn(move || {
507 let mut query = DocumentQuery::new();
508 query.page_size = 25;
509 query.page = 1;
510
511 loop {
512 match list_documents(&client, &query) {
513 Ok(response) => {
514 let documents = document_summaries_from_response(&response);
515 let total = response
516 .get("count")
517 .and_then(serde_json::Value::as_u64)
518 .map(|value| value as usize);
519 let done = next_page_url_from_response(&response).is_none();
520
521 if event_sender
522 .send(TuiEvent::DocumentsPage {
523 documents,
524 total,
525 done,
526 })
527 .is_err()
528 {
529 break;
530 }
531
532 if done {
533 break;
534 }
535
536 query.page += 1;
537 }
538 Err(error) => {
539 let _ = event_sender.send(TuiEvent::DocumentsError(error.to_string()));
540 break;
541 }
542 }
543 }
544 });
545}
546
547fn spawn_inspector_loader<T: Transport + Clone + Send + 'static>(
548 client: ApiClient<T>,
549 event_sender: Sender<TuiEvent>,
550 document_id: u64,
551) {
552 std::thread::spawn(move || {
553 let result =
554 get_document_inspector(&client, document_id).map_err(|error| error.to_string());
555 let _ = event_sender.send(TuiEvent::Inspector {
556 document_id,
557 result,
558 });
559 });
560}
561
562fn spawn_latest_task_loader<T: Transport + Clone + Send + 'static>(
563 client: ApiClient<T>,
564 event_sender: Sender<TuiEvent>,
565) {
566 std::thread::spawn(move || {
567 let result = list_tasks(&client)
568 .map(|tasks| latest_task_summary(&tasks))
569 .map_err(|error| error.to_string());
570 let _ = event_sender.send(TuiEvent::LatestTask(result));
571 });
572}
573
574pub fn draw(frame: &mut Frame<'_>, app: &mut TuiApp) {
575 let rows = Layout::default()
576 .direction(Direction::Vertical)
577 .constraints([
578 Constraint::Length(3),
579 Constraint::Min(12),
580 Constraint::Length(6),
581 ])
582 .split(frame.area());
583 let body = Layout::default()
584 .direction(Direction::Horizontal)
585 .constraints([Constraint::Percentage(36), Constraint::Percentage(64)])
586 .split(rows[1]);
587
588 let title = Paragraph::new(Line::from(vec![
589 Span::styled(
590 "paperless-cli",
591 Style::default()
592 .fg(Color::Cyan)
593 .add_modifier(Modifier::BOLD),
594 ),
595 Span::raw(" "),
596 Span::styled("Document browser", Style::default().fg(Color::Magenta)),
597 Span::raw(" "),
598 Span::styled(
599 match app.document_total {
600 Some(total) if app.loading_documents => {
601 format!("{}/{} docs", app.loaded_document_count, total)
602 }
603 Some(total) => format!("{} docs", total),
604 None if app.loading_documents => {
605 format!("{} docs loading", app.loaded_document_count)
606 }
607 None => format!("{} docs", app.loaded_document_count),
608 },
609 Style::default().fg(Color::DarkGray),
610 ),
611 ]))
612 .block(Block::default().borders(Borders::ALL).title("Overview"));
613 frame.render_widget(title, rows[0]);
614
615 let document_items = if app.loading_documents && app.documents.is_empty() {
616 vec![ListItem::new("Loading first page…")]
617 } else if app.documents.is_empty() {
618 vec![ListItem::new("No documents loaded.")]
619 } else {
620 let mut items = app
621 .documents
622 .iter()
623 .map(|document| {
624 ListItem::new(format!(
625 "{:>5} {} {}",
626 document.id, document.created, document.title
627 ))
628 })
629 .collect::<Vec<_>>();
630 if app.loading_documents {
631 items.push(ListItem::new("… loading more"));
632 }
633 items
634 };
635 let documents_block = Block::default().borders(Borders::ALL).title("Documents");
636 let documents_block = if app.focus == PaneFocus::Documents {
637 documents_block.border_style(Style::default().fg(Color::Cyan))
638 } else {
639 documents_block
640 };
641 let documents_inner = documents_block.inner(body[0]);
642 frame.render_widget(documents_block, body[0]);
643
644 if documents_inner.width > 0 && documents_inner.height > 0 {
645 app.documents_viewport_height = documents_inner.height as usize;
646 let document_columns = if documents_inner.width > 2 {
647 Layout::default()
648 .direction(Direction::Horizontal)
649 .constraints([Constraint::Min(1), Constraint::Length(1)])
650 .split(documents_inner)
651 } else {
652 Layout::default()
653 .direction(Direction::Horizontal)
654 .constraints([Constraint::Min(1)])
655 .split(documents_inner)
656 };
657
658 let documents = List::new(document_items)
659 .highlight_style(
660 Style::default()
661 .fg(Color::Black)
662 .bg(Color::Cyan)
663 .add_modifier(Modifier::BOLD),
664 )
665 .highlight_symbol("▶ ");
666 frame.render_stateful_widget(documents, document_columns[0], &mut app.documents_state);
667
668 if document_columns.len() > 1 {
669 let content_length = app.documents.len() + usize::from(app.loading_documents);
670 let viewport_height = app.documents_viewport_height;
671 let mut scrollbar_state = ScrollbarState::new(content_length.max(1))
672 .position(app.documents_state.offset())
673 .viewport_content_length(viewport_height.max(1));
674 frame.render_stateful_widget(
675 Scrollbar::new(ScrollbarOrientation::VerticalRight)
676 .begin_symbol(None)
677 .end_symbol(None)
678 .track_symbol(Some("│"))
679 .thumb_symbol("█")
680 .track_style(Style::default().fg(Color::DarkGray))
681 .thumb_style(Style::default().fg(Color::Cyan)),
682 document_columns[1].inner(Margin {
683 vertical: 0,
684 horizontal: 0,
685 }),
686 &mut scrollbar_state,
687 );
688 }
689 } else {
690 app.documents_viewport_height = 1;
691 }
692
693 let inspector_title = if app.loading_inspector {
694 "Inspector · loading"
695 } else {
696 "Inspector"
697 };
698 let inspector_body = if let Some(inspector) = app.current_inspector() {
699 inspector.text.clone()
700 } else if app.loading_documents {
701 "Loading document list…".to_string()
702 } else if app.documents.is_empty() {
703 "No document selected.".to_string()
704 } else {
705 "Loading document text…".to_string()
706 };
707 app.inspector_viewport_height = body[1].height.saturating_sub(2) as usize;
708 let detail = Paragraph::new(inspector_body)
709 .scroll((app.inspector_scroll as u16, 0))
710 .wrap(Wrap { trim: false })
711 .block(
712 Block::default()
713 .borders(Borders::ALL)
714 .border_style(if app.focus == PaneFocus::Inspector {
715 Style::default().fg(Color::Cyan)
716 } else {
717 Style::default()
718 })
719 .title(inspector_title),
720 );
721 frame.render_widget(detail, body[1]);
722
723 let metadata = Paragraph::new(
724 app.selected_metadata()
725 .into_iter()
726 .map(Line::from)
727 .collect::<Vec<_>>(),
728 )
729 .wrap(Wrap { trim: true })
730 .block(Block::default().borders(Borders::ALL).title("Status"));
731 frame.render_widget(metadata, rows[2]);
732}
733
734fn severity_label(severity: &Severity) -> &'static str {
735 match severity {
736 Severity::Critical => "critical",
737 Severity::High => "high",
738 Severity::Medium => "medium",
739 Severity::Low => "low",
740 }
741}