use anyhow::Result;
use chrono::{Local, Utc};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
Frame, Terminal,
};
use std::time::Duration;
use crate::{
db::queries::SessionQueries,
db::{get_database_path, Database},
models::Session,
ui::{formatter::Formatter, widgets::ColorScheme},
};
pub struct SessionHistoryBrowser {
sessions: Vec<Session>,
table_state: TableState,
show_filters: bool,
user_host_string: String,
}
impl SessionHistoryBrowser {
pub async fn new() -> Result<Self> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let sessions =
SessionQueries::list_with_filter(&db.connection, None, None, None, Some(100))?;
let mut table_state = TableState::default();
if !sessions.is_empty() {
table_state.select(Some(0));
}
let user = std::process::Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.and_then(|output| {
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
})
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "user".to_string());
let host = std::process::Command::new("hostname")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
})
.or_else(|| std::env::var("HOSTNAME").ok())
.unwrap_or_else(|| "machine".to_string());
let user_host_string = format!("{}@{}", user, host);
Ok(Self {
sessions,
table_state,
show_filters: false,
user_host_string,
})
}
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
loop {
terminal.draw(|f| self.render(f))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Up => {
let i = match self.table_state.selected() {
Some(i) => {
if i == 0 {
self.sessions.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.table_state.select(Some(i));
}
KeyCode::Down => {
let i = match self.table_state.selected() {
Some(i) => {
if i >= self.sessions.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.table_state.select(Some(i));
}
KeyCode::Char('f') => {
self.show_filters = !self.show_filters;
}
KeyCode::Enter => {
}
_ => {}
}
}
_ => {}
}
}
}
Ok(())
}
fn render(&mut self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
.split(f.size());
self.render_header(f, chunks[0]);
self.render_filter_bar(f, chunks[1]);
self.render_main_content(f, chunks[2]);
self.render_footer(f, chunks[3]);
}
fn render_header(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), Constraint::Percentage(50), ])
.margin(1)
.split(area);
f.render_widget(
Paragraph::new("SESSION HISTORY").style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
),
chunks[0],
);
f.render_widget(
Paragraph::new(self.user_host_string.as_str())
.alignment(Alignment::Right)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
chunks[1],
);
}
fn render_filter_bar(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK))
.style(Style::default().bg(ColorScheme::BG_DARK));
f.render_widget(block, area);
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(20), Constraint::Length(20), Constraint::Length(20), Constraint::Length(20), Constraint::Min(10), ])
.margin(1)
.split(area);
let filters = [
"Start Date: [All]",
"End Date: [All]",
"Project: [All]",
"Duration: [Any]",
"Search: [None]",
];
for (i, filter) in filters.iter().enumerate() {
if i < layout.len() {
f.render_widget(
Paragraph::new(*filter).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
layout[i],
);
}
}
}
fn render_main_content(&mut self, f: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(70), Constraint::Percentage(30), ])
.split(area);
self.render_session_table(f, chunks[0]);
self.render_details_panel(f, chunks[1]);
}
fn render_session_table(&mut self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK));
f.render_widget(block.clone(), area);
let inner_area = block.inner(area);
let header_cells = ["DATE", "PROJECT", "DURATION", "START", "END", "STATUS"]
.iter()
.map(|h| {
Cell::from(*h).style(
Style::default()
.fg(ColorScheme::TEXT_SECONDARY)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1).bottom_margin(1);
let rows = self.sessions.iter().map(|session| {
let duration = if let Some(end) = session.end_time {
(end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
} else {
(Utc::now() - session.start_time).num_seconds()
- session.paused_duration.num_seconds()
};
let start_time = session.start_time.with_timezone(&Local);
let date_str = start_time.format("%Y-%m-%d").to_string();
let start_str = start_time.format("%H:%M").to_string();
let end_str = if let Some(end) = session.end_time {
end.with_timezone(&Local).format("%H:%M").to_string()
} else {
"-".to_string()
};
let duration_str = Formatter::format_duration(duration);
let status = if session.end_time.is_none() {
"RUNNING"
} else {
"COMPLETED"
};
let project_str = format!("Project {}", session.project_id);
let cells = vec![
Cell::from(date_str).style(Style::default().fg(ColorScheme::TEXT_MAIN)),
Cell::from(project_str).style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
),
Cell::from(duration_str).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from(start_str).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from(end_str).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
Cell::from(status).style(Style::default().fg(if session.end_time.is_none() {
ColorScheme::SUCCESS
} else {
ColorScheme::CLEAN_GREEN
})),
];
Row::new(cells).height(1)
});
let table = Table::new(rows)
.widths(&[
Constraint::Percentage(15),
Constraint::Percentage(25),
Constraint::Percentage(15),
Constraint::Percentage(15),
Constraint::Percentage(15),
Constraint::Percentage(15),
])
.header(header)
.highlight_style(
Style::default()
.bg(ColorScheme::PANEL_DARK)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(table, inner_area, &mut self.table_state);
}
fn render_details_panel(&self, f: &mut Frame, area: Rect) {
let block = Block::default()
.borders(Borders::NONE)
.style(Style::default().bg(ColorScheme::BG_DARK));
f.render_widget(block.clone(), area);
let inner_area = block.inner(area);
if let Some(selected_index) = self.table_state.selected() {
if let Some(session) = self.sessions.get(selected_index) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(1), ])
.split(inner_area);
f.render_widget(
Paragraph::new("SESSION DETAILS")
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
),
layout[0],
);
let details = vec![
Line::from(Span::styled(
"NOTES",
Style::default()
.fg(ColorScheme::TEXT_SECONDARY)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
session.notes.clone().unwrap_or("No notes".to_string()),
Style::default().fg(ColorScheme::TEXT_MAIN),
)),
Line::from(""),
Line::from(Span::styled(
"TAGS",
Style::default()
.fg(ColorScheme::TEXT_SECONDARY)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"coding, rust, ui", Style::default().fg(ColorScheme::TEXT_MAIN),
)),
Line::from(""),
Line::from(Span::styled(
"CONTEXT",
Style::default()
.fg(ColorScheme::TEXT_SECONDARY)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
session.context.to_string(),
Style::default().fg(ColorScheme::TEXT_MAIN),
)),
];
f.render_widget(
Paragraph::new(details)
.wrap(ratatui::widgets::Wrap { trim: true })
.block(
Block::default().padding(ratatui::widgets::Padding::new(1, 1, 1, 1)),
),
layout[1],
);
}
} else {
f.render_widget(
Paragraph::new("Select a session to view details")
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
inner_area,
);
}
}
fn render_footer(&self, f: &mut Frame, area: Rect) {
let hints = vec![
Span::styled(
"[↑/↓]",
Style::default()
.fg(ColorScheme::PRIMARY_DASHBOARD)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Navigate "),
Span::styled(
"[F]",
Style::default()
.fg(ColorScheme::PRIMARY_DASHBOARD)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Filter "),
Span::styled(
"[Enter]",
Style::default()
.fg(ColorScheme::PRIMARY_DASHBOARD)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Details "),
Span::styled(
"[Q]",
Style::default()
.fg(ColorScheme::ERROR)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Quit"),
];
f.render_widget(
Paragraph::new(Line::from(hints))
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
area,
);
}
}