Skip to main content

paperless_cli/
tui.rs

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}