tempo_cli/ui/
interactive.rs

1use anyhow::Result;
2use crossterm::event::{self, Event, KeyCode};
3use ratatui::{
4    backend::Backend,
5    layout::{Constraint, Direction, Layout, Rect},
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
9    Frame, Terminal,
10};
11use std::time::Duration;
12
13use crate::{
14    models::{Project, Session},
15    ui::formatter::Formatter,
16};
17
18pub struct InteractiveViewer {
19    projects: Vec<Project>,
20    sessions: Vec<Session>,
21    selected_project: Option<usize>,
22    project_list_state: ListState,
23}
24
25impl InteractiveViewer {
26    pub fn new() -> Result<Self> {
27        let mut viewer = Self {
28            projects: Vec::new(),
29            sessions: Vec::new(),
30            selected_project: None,
31            project_list_state: ListState::default(),
32        };
33
34        viewer.load_data()?;
35        Ok(viewer)
36    }
37
38    pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
39        loop {
40            terminal.draw(|f| {
41                self.render(f);
42            })?;
43
44            // Handle input
45            if event::poll(Duration::from_millis(100))? {
46                if let Event::Key(key) = event::read()? {
47                    match key.code {
48                        KeyCode::Char('q') | KeyCode::Esc => break,
49                        KeyCode::Up => self.previous_project(),
50                        KeyCode::Down => self.next_project(),
51                        KeyCode::Enter => self.select_project(),
52                        KeyCode::Char('r') => self.load_data()?,
53                        _ => {}
54                    }
55                }
56            }
57        }
58
59        Ok(())
60    }
61
62    fn render(&mut self, f: &mut Frame) {
63        let chunks = Layout::default()
64            .direction(Direction::Horizontal)
65            .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
66            .split(f.size());
67
68        self.render_project_list(f, chunks[0]);
69        self.render_session_details(f, chunks[1]);
70    }
71
72    fn render_project_list(&mut self, f: &mut Frame, area: Rect) {
73        let items: Vec<ListItem> = self
74            .projects
75            .iter()
76            .enumerate()
77            .map(|(i, project)| {
78                let style = if Some(i) == self.selected_project {
79                    Style::default()
80                        .fg(Color::Yellow)
81                        .add_modifier(Modifier::BOLD)
82                } else {
83                    Style::default().fg(Color::White)
84                };
85
86                ListItem::new(Line::from(Span::styled(project.name.clone(), style)))
87            })
88            .collect();
89
90        let list = List::new(items)
91            .block(
92                Block::default()
93                    .title("Projects")
94                    .borders(Borders::ALL)
95                    .style(Style::default().fg(Color::Cyan)),
96            )
97            .highlight_style(Style::default().bg(Color::DarkGray));
98
99        f.render_stateful_widget(list, area, &mut self.project_list_state);
100    }
101
102    fn render_session_details(&self, f: &mut Frame, area: Rect) {
103        let chunks = Layout::default()
104            .direction(Direction::Vertical)
105            .constraints([Constraint::Length(8), Constraint::Min(0)])
106            .split(area);
107
108        // Project info
109        if let Some(selected_idx) = self.selected_project {
110            if let Some(project) = self.projects.get(selected_idx) {
111                let project_info = Formatter::format_project_info(project);
112                let paragraph = Paragraph::new(project_info)
113                    .block(Formatter::create_header_block("Project Details"));
114                f.render_widget(paragraph, chunks[0]);
115
116                // Sessions for this project
117                let project_sessions: Vec<&Session> = self
118                    .sessions
119                    .iter()
120                    .filter(|s| s.project_id == project.id.unwrap_or(-1))
121                    .collect();
122
123                if !project_sessions.is_empty() {
124                    let sessions_summary = Formatter::format_sessions_summary(
125                        &project_sessions.into_iter().cloned().collect::<Vec<_>>(),
126                    );
127                    let sessions_widget =
128                        Paragraph::new(sessions_summary).block(Formatter::create_info_block());
129                    f.render_widget(sessions_widget, chunks[1]);
130                } else {
131                    let no_sessions = Paragraph::new("No sessions found for this project")
132                        .style(Style::default().fg(Color::Gray))
133                        .block(Formatter::create_info_block());
134                    f.render_widget(no_sessions, chunks[1]);
135                }
136            }
137        } else {
138            let help_text = vec![
139                Line::from("Select a project to view details"),
140                Line::from(""),
141                Line::from("Controls:"),
142                Line::from("  Up/Down  Navigate projects"),
143                Line::from("  Enter  Select project"),
144                Line::from("  r      Refresh data"),
145                Line::from("  q/Esc  Quit"),
146            ];
147
148            let paragraph = Paragraph::new(help_text).block(
149                Block::default()
150                    .title("Help")
151                    .borders(Borders::ALL)
152                    .style(Style::default().fg(Color::Cyan)),
153            );
154            f.render_widget(paragraph, area);
155        }
156    }
157
158    fn previous_project(&mut self) {
159        if self.projects.is_empty() {
160            return;
161        }
162
163        let i = match self.project_list_state.selected() {
164            Some(i) => {
165                if i == 0 {
166                    self.projects.len() - 1
167                } else {
168                    i - 1
169                }
170            }
171            None => 0,
172        };
173        self.project_list_state.select(Some(i));
174        self.selected_project = Some(i);
175    }
176
177    fn next_project(&mut self) {
178        if self.projects.is_empty() {
179            return;
180        }
181
182        let i = match self.project_list_state.selected() {
183            Some(i) => {
184                if i >= self.projects.len() - 1 {
185                    0
186                } else {
187                    i + 1
188                }
189            }
190            None => 0,
191        };
192        self.project_list_state.select(Some(i));
193        self.selected_project = Some(i);
194    }
195
196    fn select_project(&mut self) {
197        if let Some(i) = self.project_list_state.selected() {
198            self.selected_project = Some(i);
199        }
200    }
201
202    fn load_data(&mut self) -> Result<()> {
203        // This would use IPC to load actual data
204        // For now, create placeholder data
205        use chrono::Utc;
206        use std::path::PathBuf;
207
208        self.projects = vec![Project {
209            id: Some(1),
210            name: "Sample Project".to_string(),
211            path: PathBuf::from("/Users/example/sample"),
212            git_hash: Some("abc123".to_string()),
213            created_at: chrono::Local::now().with_timezone(&Utc),
214            updated_at: chrono::Local::now().with_timezone(&Utc),
215            is_archived: false,
216            description: Some("A sample project for demo".to_string()),
217        }];
218
219        use crate::models::session::SessionContext;
220
221        self.sessions = vec![Session {
222            id: Some(1),
223            project_id: 1,
224            start_time: (chrono::Local::now() - chrono::Duration::hours(2)).with_timezone(&Utc),
225            end_time: Some((chrono::Local::now() - chrono::Duration::hours(1)).with_timezone(&Utc)),
226            context: SessionContext::Terminal,
227            paused_duration: chrono::Duration::minutes(5),
228            notes: Some("Working on initial setup".to_string()),
229            created_at: chrono::Local::now().with_timezone(&Utc),
230        }];
231
232        Ok(())
233    }
234}