tempo_cli/ui/
interactive.rs1use 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 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 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 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 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}