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