wat/src/app.rs

1641 lines
63 KiB
Rust

use crate::config::Config;
use crate::state::{AppState, TimeItem};
use chrono::Utc;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
use std::process::Command;
#[derive(Debug, Deserialize, Clone)]
pub(crate) struct WatsonFrame {
pub id: String,
pub project: String,
pub start: String,
pub stop: String,
pub tags: Vec<String>,
}
pub enum Screen {
Main,
Help,
ConfigHelp,
NewEntry,
ReassignProject,
LogView,
LogViewHelp,
}
pub enum LogViewPeriod {
Day,
Week,
Month,
}
pub enum LogViewDayOrder {
Chronological, // Oldest first
ReverseChronological, // Newest first (default)
}
pub enum LogViewGrouping {
ByDate,
ByProject,
}
pub enum LogViewSelection {
Entry, // Individual entry selected
Project, // Whole project selected (ByProject mode only)
Day, // Whole day selected
All, // Entire view/period selected
}
pub struct App {
pub state: AppState,
pub config: Config,
pub current_screen: Screen,
pub needs_clear: bool,
pub new_entry_buffer: String,
pub new_entry_project: String,
pub new_entry_cursor: usize,
pub new_entry_mode: NewEntryMode, // Task or Project
pub reassign_project_buffer: String,
pub reassign_project_cursor: usize,
pub log_view_period: LogViewPeriod,
pub log_view_grouping: LogViewGrouping,
pub log_view_day_order: LogViewDayOrder,
pub log_view_selection_level: LogViewSelection,
pub log_view_frames: Vec<WatsonFrame>,
pub log_view_content: Vec<String>,
pub log_view_frame_indices: Vec<usize>, // Maps display order to frame index
pub log_view_scroll: usize,
pub log_view_selected: usize,
pub help_scroll: usize,
pub clipboard: Option<arboard::Clipboard>,
pub status_message: Option<(String, std::time::Instant)>,
}
pub enum NewEntryMode {
Task,
Project,
}
impl App {
// Helper to determine if a line is an entry line based on grouping mode
fn is_entry_line(&self, line: &str) -> bool {
// Exclude separator lines (time gap markers)
if line.trim() == "---" {
return false;
}
match self.log_view_grouping {
LogViewGrouping::ByProject => line.starts_with("\t\t"),
LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"),
}
}
pub fn new() -> anyhow::Result<Self> {
let config = Config::load()?;
let mut state = AppState::load()?;
// Initialize current_pane to first enabled section
let enabled = [
config.show_permanent,
config.show_recurring,
config.show_recent,
];
// Find first enabled pane
if let Some(first_enabled) = enabled.iter().position(|&x| x) {
state.current_pane = first_enabled;
}
Ok(Self {
state,
config,
current_screen: Screen::Main,
needs_clear: false,
new_entry_buffer: String::new(),
new_entry_project: String::new(),
new_entry_cursor: 0,
new_entry_mode: NewEntryMode::Task,
reassign_project_buffer: String::new(),
reassign_project_cursor: 0,
log_view_period: LogViewPeriod::Day,
log_view_grouping: LogViewGrouping::ByDate,
log_view_day_order: LogViewDayOrder::ReverseChronological,
log_view_selection_level: LogViewSelection::Entry,
log_view_frames: Vec::new(),
log_view_content: Vec::new(),
log_view_frame_indices: Vec::new(),
log_view_scroll: 0,
log_view_selected: 0,
help_scroll: 0,
clipboard: arboard::Clipboard::new().ok(),
status_message: None,
})
}
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
// Update status message
self.update_status_message();
let previous_screen = std::mem::discriminant(&self.current_screen);
let result = match self.current_screen {
Screen::Main => self.handle_main_event(event),
Screen::Help => self.handle_help_event(event),
Screen::ConfigHelp => self.handle_config_help_event(event),
Screen::NewEntry => self.handle_new_entry_event(event),
Screen::ReassignProject => self.handle_reassign_project_event(event),
Screen::LogView => self.handle_log_view_event(event),
Screen::LogViewHelp => self.handle_log_view_help_event(event),
};
// If we switched screens, signal that we need to clear
let current_screen = std::mem::discriminant(&self.current_screen);
if previous_screen != current_screen {
self.needs_clear = true;
}
result
}
fn handle_main_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent {
code, modifiers, ..
}) => match (code, modifiers) {
(KeyCode::Char('q'), _) => return Ok(true),
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1),
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.move_column(-1),
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.move_column(1),
(KeyCode::Char('n'), KeyModifiers::CONTROL) => self.change_pane(1),
(KeyCode::Char('p'), KeyModifiers::CONTROL) => self.change_pane(-1),
(KeyCode::Enter, _) => self.toggle_current_item()?,
(KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?,
(KeyCode::Char('?'), _) => {
self.help_scroll = 0;
self.current_screen = Screen::Help;
}
(KeyCode::Char('c'), _) => self.edit_app_config()?,
(KeyCode::Char('n'), _) => self.start_new_entry(),
(KeyCode::Char('p'), _) => self.start_reassign_project(),
(KeyCode::Char('v'), _) => self.start_log_view()?,
(KeyCode::Char('x'), _) => self.delete_current_item()?,
_ => {}
},
_ => {}
}
Ok(false)
}
fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent {
code, modifiers, ..
}) => {
match (code, modifiers) {
(KeyCode::Esc, _) => {
self.current_screen = Screen::Main;
self.new_entry_buffer.clear();
self.new_entry_project.clear();
self.new_entry_mode = NewEntryMode::Task;
self.new_entry_cursor = 0;
}
(KeyCode::Enter, _) => {
match self.new_entry_mode {
NewEntryMode::Task => {
// Move from Tag to Project
self.new_entry_mode = NewEntryMode::Project;
self.new_entry_cursor = self.new_entry_buffer.len();
}
NewEntryMode::Project => {
// Project is required, tag is optional
if !self.new_entry_buffer.is_empty() {
// Create new time item
let item = TimeItem {
name: self.new_entry_buffer.clone(),
tags: if self.new_entry_project.is_empty() {
vec![]
} else {
vec![self.new_entry_project.clone()]
},
last_used: Some(Utc::now()),
};
// Add to current pane (or recurring if in recent)
match self.state.current_pane {
0 => self.state.permanent_items.push(item), // Permanent Items
1 => self.state.recurring_items.push(item), // Recurring Items
2 => self.state.recent_items.push(item), // Ad-Hoc Items
_ => unreachable!(),
}
self.state.save()?;
// Clear and return to main screen
self.current_screen = Screen::Main;
self.new_entry_buffer.clear();
self.new_entry_project.clear();
self.new_entry_mode = NewEntryMode::Task;
}
}
}
}
(KeyCode::Backspace, _) => match self.new_entry_mode {
NewEntryMode::Task => {
if self.new_entry_cursor > 0 {
self.new_entry_project.remove(self.new_entry_cursor - 1);
self.new_entry_cursor -= 1;
}
}
NewEntryMode::Project => {
if self.new_entry_cursor > 0 {
let idx = self.new_entry_cursor - 1;
if idx < self.new_entry_buffer.len() {
self.new_entry_buffer.remove(idx);
self.new_entry_cursor -= 1;
}
}
}
},
(KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode {
NewEntryMode::Task => {
self.new_entry_project.insert(self.new_entry_cursor, c);
self.new_entry_cursor += 1;
}
NewEntryMode::Project => {
if self.new_entry_cursor <= self.new_entry_buffer.len() {
self.new_entry_buffer.insert(self.new_entry_cursor, c);
self.new_entry_cursor += 1;
}
}
},
_ => {}
}
}
_ => {}
}
Ok(false)
}
fn handle_help_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent { code, .. }) => match code {
KeyCode::Char('c') => {
self.help_scroll = 0;
self.current_screen = Screen::ConfigHelp;
}
KeyCode::Char('j') | KeyCode::Down => {
self.help_scroll = self.help_scroll.saturating_add(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.help_scroll = self.help_scroll.saturating_sub(1);
}
KeyCode::PageDown => {
self.help_scroll = self.help_scroll.saturating_add(10);
}
KeyCode::PageUp => {
self.help_scroll = self.help_scroll.saturating_sub(10);
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
self.help_scroll = 0;
self.current_screen = Screen::Main;
}
_ => {}
},
_ => {}
}
Ok(false)
}
fn handle_config_help_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent { code, .. }) => match code {
KeyCode::Char('j') | KeyCode::Down => {
self.help_scroll = self.help_scroll.saturating_add(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.help_scroll = self.help_scroll.saturating_sub(1);
}
KeyCode::PageDown => {
self.help_scroll = self.help_scroll.saturating_add(10);
}
KeyCode::PageUp => {
self.help_scroll = self.help_scroll.saturating_sub(10);
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
self.help_scroll = 0;
self.current_screen = Screen::Help;
}
_ => {}
},
_ => {}
}
Ok(false)
}
fn handle_log_view_help_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent { code, .. }) => match code {
KeyCode::Char('j') | KeyCode::Down => {
self.help_scroll = self.help_scroll.saturating_add(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.help_scroll = self.help_scroll.saturating_sub(1);
}
KeyCode::PageDown => {
self.help_scroll = self.help_scroll.saturating_add(10);
}
KeyCode::PageUp => {
self.help_scroll = self.help_scroll.saturating_sub(10);
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
self.help_scroll = 0;
self.current_screen = Screen::LogView;
}
_ => {}
},
_ => {}
}
Ok(false)
}
fn handle_reassign_project_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent {
code, modifiers, ..
}) => {
match (code, modifiers) {
(KeyCode::Esc, _) => {
self.current_screen = Screen::Main;
self.reassign_project_buffer.clear();
self.reassign_project_cursor = 0;
}
(KeyCode::Enter, _) => {
if self.reassign_project_buffer.is_empty()
|| self.config.is_valid_project(&self.reassign_project_buffer)
{
self.reassign_project_for_current_item()?;
self.current_screen = Screen::Main;
self.reassign_project_buffer.clear();
self.reassign_project_cursor = 0;
}
}
(KeyCode::Backspace, _) => {
if self.reassign_project_cursor > 0 {
let idx = self.reassign_project_cursor - 1;
if idx < self.reassign_project_buffer.len() {
self.reassign_project_buffer.remove(idx);
self.reassign_project_cursor -= 1;
}
}
}
(KeyCode::Char(c), m) if m.is_empty() => {
if self.reassign_project_cursor <= self.reassign_project_buffer.len() {
self.reassign_project_buffer.insert(self.reassign_project_cursor, c);
self.reassign_project_cursor += 1;
}
}
_ => {}
}
}
_ => {}
}
Ok(false)
}
fn handle_log_view_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent { code, .. }) => match code {
KeyCode::Esc | KeyCode::Char('q') => {
self.current_screen = Screen::Main;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.needs_clear = true;
}
KeyCode::Char('?') => {
self.help_scroll = 0;
self.current_screen = Screen::LogViewHelp;
}
KeyCode::Char('d') => {
self.log_view_period = LogViewPeriod::Day;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.load_log_content()?;
self.needs_clear = true;
}
KeyCode::Char('w') => {
self.log_view_period = LogViewPeriod::Week;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.load_log_content()?;
self.needs_clear = true;
}
KeyCode::Char('m') => {
self.log_view_period = LogViewPeriod::Month;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.load_log_content()?;
self.needs_clear = true;
}
KeyCode::Char('j') | KeyCode::Down => {
match self.log_view_selection_level {
LogViewSelection::Entry => {
// Move to next entry
if self.log_view_selected < self.log_view_frame_indices.len().saturating_sub(1) {
self.log_view_selected += 1;
}
}
LogViewSelection::Project => {
// Jump to next project group
self.jump_to_next_project();
}
LogViewSelection::Day => {
// Jump to next day
self.jump_to_next_day();
}
LogViewSelection::All => {
// No navigation at All level - already selecting everything
}
}
}
KeyCode::Char('k') | KeyCode::Up => {
match self.log_view_selection_level {
LogViewSelection::Entry => {
// Move to previous entry
if self.log_view_selected > 0 {
self.log_view_selected -= 1;
}
}
LogViewSelection::Project => {
// Jump to previous project group
self.jump_to_previous_project();
}
LogViewSelection::Day => {
// Jump to previous day
self.jump_to_previous_day();
}
LogViewSelection::All => {
// No navigation at All level - already selecting everything
}
}
}
KeyCode::Char('e') => {
// Only allow edit when selecting individual entry
if matches!(self.log_view_selection_level, LogViewSelection::Entry) {
self.edit_selected_frame()?;
}
}
KeyCode::Char('x') => {
// Only allow delete when selecting individual entry
if matches!(self.log_view_selection_level, LogViewSelection::Entry) {
self.delete_selected_frame()?;
}
}
KeyCode::Char('b') => {
// Backfill - adjust start time to match previous entry's end time
// Only works in ByDate mode at Entry level
if !matches!(self.log_view_selection_level, LogViewSelection::Entry) {
self.set_status_message("Backfill only works at Entry level (use h/l)");
} else if !matches!(self.log_view_grouping, LogViewGrouping::ByDate) {
self.set_status_message("Backfill only works in By Date view (press g)");
} else {
self.fix_entry_gap()?;
}
}
KeyCode::Char('h') | KeyCode::Left => {
// Zoom out selection level
self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) {
(LogViewSelection::All, _) => LogViewSelection::All, // Already at highest
(LogViewSelection::Day, _) => LogViewSelection::All,
(LogViewSelection::Project, _) => LogViewSelection::Day,
(LogViewSelection::Entry, LogViewGrouping::ByProject) => LogViewSelection::Project,
(LogViewSelection::Entry, LogViewGrouping::ByDate) => LogViewSelection::Day,
};
}
KeyCode::Char('l') | KeyCode::Right => {
// Zoom in selection level
self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) {
(LogViewSelection::Entry, _) => LogViewSelection::Entry, // Already at lowest
(LogViewSelection::Project, _) => LogViewSelection::Entry,
(LogViewSelection::Day, LogViewGrouping::ByProject) => LogViewSelection::Project,
(LogViewSelection::Day, LogViewGrouping::ByDate) => LogViewSelection::Entry,
(LogViewSelection::All, LogViewGrouping::ByProject) => LogViewSelection::Day,
(LogViewSelection::All, LogViewGrouping::ByDate) => LogViewSelection::Day,
};
}
KeyCode::Char('c') => {
self.copy_log_to_clipboard()?;
}
KeyCode::Char('g') => {
// Toggle grouping mode
self.log_view_grouping = match self.log_view_grouping {
LogViewGrouping::ByDate => LogViewGrouping::ByProject,
LogViewGrouping::ByProject => LogViewGrouping::ByDate,
};
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.format_log_entries();
self.needs_clear = true;
}
KeyCode::Char('r') => {
// Toggle day order (chronological vs reverse chronological)
self.log_view_day_order = match self.log_view_day_order {
LogViewDayOrder::Chronological => LogViewDayOrder::ReverseChronological,
LogViewDayOrder::ReverseChronological => LogViewDayOrder::Chronological,
};
self.format_log_entries();
self.needs_clear = true;
}
KeyCode::PageDown => {
self.log_view_selected = (self.log_view_selected + 10)
.min(self.log_view_frame_indices.len().saturating_sub(1));
}
KeyCode::PageUp => {
self.log_view_selected = self.log_view_selected.saturating_sub(10);
}
_ => {}
},
_ => {}
}
Ok(false)
}
fn format_log_entries(&mut self) {
if self.log_view_frames.is_empty() {
self.log_view_content = vec!["No log entries for this period.".to_string()];
return;
}
match self.log_view_grouping {
LogViewGrouping::ByDate => self.format_by_date(),
LogViewGrouping::ByProject => self.format_by_project(),
}
}
fn format_by_date(&mut self) {
use chrono::{DateTime, Local, Timelike};
use std::collections::BTreeMap;
// Group frames by date, tracking their original indices
// Use YYYY-MM-DD as the key for proper sorting
let mut by_date: BTreeMap<String, (String, Vec<(usize, &WatsonFrame)>)> = BTreeMap::new();
// Choose date format based on view period
let date_format = match self.log_view_period {
LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday)
_ => "%A %d %B %Y", // "Saturday 23 November 2025"
};
for (idx, frame) in self.log_view_frames.iter().enumerate() {
if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) {
let local_dt: DateTime<Local> = start_dt.into();
let sort_key = local_dt.format("%Y-%m-%d").to_string(); // For sorting
let display_date = local_dt.format(date_format).to_string(); // For display
by_date
.entry(sort_key)
.or_insert_with(|| (display_date.clone(), Vec::new()))
.1
.push((idx, frame));
}
}
let mut lines = Vec::new();
let mut frame_indices = Vec::new();
// Sort each day's frames chronologically
for (_, frames) in by_date.values_mut() {
frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start));
}
// Collect dates in the desired order
let dates: Vec<_> = match self.log_view_day_order {
LogViewDayOrder::ReverseChronological => {
by_date.iter().rev().collect()
}
LogViewDayOrder::Chronological => {
by_date.iter().collect()
}
};
for (_sort_key, (display_date, frames)) in dates {
lines.push(display_date.clone());
let mut prev_stop: Option<DateTime<Local>> = None;
for (frame_idx, (idx, frame)) in frames.iter().enumerate() {
if let (Ok(start_dt), Ok(stop_dt)) = (
DateTime::parse_from_rfc3339(&frame.start),
DateTime::parse_from_rfc3339(&frame.stop),
) {
let start_local: DateTime<Local> = start_dt.into();
let stop_local: DateTime<Local> = stop_dt.into();
// Check for time gap and add separator line if enabled
if self.config.show_time_gaps && frame_idx > 0 {
if let Some(prev) = prev_stop {
let gap = start_local.signed_duration_since(prev);
if gap.num_minutes() >= 5 {
lines.push("\t ---".to_string());
// Note: don't add to frame_indices - separator is not an entry
}
}
}
let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
let display_text = if frame.tags.is_empty() {
frame.project.clone()
} else {
format!("{} [{}]", frame.tags.join(", "), frame.project)
};
let line_text = format!(
"\t{} to {} {}",
start_time, stop_time, display_text
);
lines.push(line_text);
frame_indices.push(*idx); // Only add actual entries to frame_indices
prev_stop = Some(stop_local);
}
}
lines.push(String::new()); // Empty line between dates
}
self.log_view_content = lines;
self.log_view_frame_indices = frame_indices;
}
fn format_by_project(&mut self) {
use chrono::{DateTime, Local, Timelike};
use std::collections::BTreeMap;
// Group frames by date, then by project within each date, tracking indices
// Use YYYY-MM-DD as the key for proper sorting
let mut by_date: BTreeMap<String, (String, BTreeMap<String, Vec<(usize, &WatsonFrame)>>)> = BTreeMap::new();
// Choose date format based on view period
let date_format = match self.log_view_period {
LogViewPeriod::Month => "%d %B %Y", // "23 November 2025" (no weekday)
_ => "%A %d %B %Y", // "Saturday 23 November 2025"
};
for (idx, frame) in self.log_view_frames.iter().enumerate() {
if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) {
let local_dt: DateTime<Local> = start_dt.into();
let sort_key = local_dt.format("%Y-%m-%d").to_string(); // For sorting
let display_date = local_dt.format(date_format).to_string(); // For display
by_date
.entry(sort_key)
.or_insert_with(|| (display_date.clone(), BTreeMap::new()))
.1
.entry(frame.project.clone())
.or_insert_with(Vec::new)
.push((idx, frame));
}
}
let mut lines = Vec::new();
let mut frame_indices = Vec::new();
// Sort frames within each project chronologically
for (_, projects) in by_date.values_mut() {
for frames in projects.values_mut() {
frames.sort_by(|(_, a), (_, b)| a.start.cmp(&b.start));
}
}
// Collect dates in the desired order
let dates: Vec<_> = match self.log_view_day_order {
LogViewDayOrder::ReverseChronological => {
by_date.iter().rev().collect()
}
LogViewDayOrder::Chronological => {
by_date.iter().collect()
}
};
for (_sort_key, (display_date, projects)) in dates {
lines.push(display_date.clone());
for (project, frames) in projects.iter() {
lines.push(format!(" {}", project)); // Project header with indent
for (idx, frame) in frames {
if let (Ok(start_dt), Ok(stop_dt)) = (
DateTime::parse_from_rfc3339(&frame.start),
DateTime::parse_from_rfc3339(&frame.stop),
) {
let start_local: DateTime<Local> = start_dt.into();
let stop_local: DateTime<Local> = stop_dt.into();
let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
let tags_str = if frame.tags.is_empty() {
String::new()
} else {
format!(" [{}]", frame.tags.join(", "))
};
lines.push(format!(
"\t\t{} to {}{}",
start_time, stop_time, tags_str
));
frame_indices.push(*idx);
}
}
}
lines.push(String::new()); // Empty line between dates
}
self.log_view_content = lines;
self.log_view_frame_indices = frame_indices;
}
fn edit_selected_frame(&mut self) -> anyhow::Result<()> {
// Check if selection is valid
if self.log_view_selected >= self.log_view_frame_indices.len() {
return Ok(());
}
// Get the actual frame index from the display index
let frame_idx = self.log_view_frame_indices[self.log_view_selected];
if frame_idx >= self.log_view_frames.len() {
return Ok(());
}
let frame_id = &self.log_view_frames[frame_idx].id;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::stdout;
// Leave TUI mode
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
// Run watson edit
let status = Command::new("watson").arg("edit").arg(frame_id).status()?;
// Return to TUI mode
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
if status.success() {
// Reload log content
self.load_log_content()?;
}
self.needs_clear = true;
Ok(())
}
fn delete_selected_frame(&mut self) -> anyhow::Result<()> {
// Check if selection is valid
if self.log_view_selected >= self.log_view_frame_indices.len() {
return Ok(());
}
// Get the actual frame index from the display index
let frame_idx = self.log_view_frame_indices[self.log_view_selected];
if frame_idx >= self.log_view_frames.len() {
return Ok(());
}
let frame_id = self.log_view_frames[frame_idx].id.clone();
// Run watson remove with --force flag (no confirmation)
let output = Command::new("watson")
.arg("remove")
.arg("--force")
.arg(&frame_id)
.output()?;
if output.status.success() {
// Reload log content
self.load_log_content()?;
// Adjust selection if we deleted the last item
if self.log_view_selected >= self.log_view_frame_indices.len() && !self.log_view_frame_indices.is_empty() {
self.log_view_selected = self.log_view_frame_indices.len() - 1;
}
}
self.needs_clear = true;
Ok(())
}
fn fix_entry_gap(&mut self) -> anyhow::Result<()> {
use chrono::DateTime;
use serde_json::Value;
self.set_status_message("Backfill function called");
// Check if selection is valid
if self.log_view_selected >= self.log_view_frame_indices.len() {
self.set_status_message("Invalid selection");
return Ok(());
}
// Can't fix the first entry
if self.log_view_selected == 0 {
self.set_status_message("No previous entry!");
return Ok(());
}
// Check if previous entry is on the same day
let current_frame_idx = self.log_view_frame_indices[self.log_view_selected];
let prev_frame_idx = self.log_view_frame_indices[self.log_view_selected - 1];
if let (Some(current), Some(prev)) = (
self.log_view_frames.get(current_frame_idx),
self.log_view_frames.get(prev_frame_idx),
) {
// Parse timestamps
if let (Ok(curr_start), Ok(prev_stop)) = (
DateTime::parse_from_rfc3339(&current.start),
DateTime::parse_from_rfc3339(&prev.stop),
) {
// Check if they're on the same day
let curr_date = curr_start.format("%Y-%m-%d").to_string();
let prev_date = prev_stop.format("%Y-%m-%d").to_string();
if curr_date != prev_date {
self.set_status_message("No previous entry on same day!");
return Ok(());
}
// Read watson frames file
let frames_path = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?
.join("watson")
.join("frames");
let frames_content = std::fs::read_to_string(&frames_path)?;
let mut frames: Value = serde_json::from_str(&frames_content)?;
// Find and update the frame with matching id
if let Some(frames_array) = frames.as_array_mut() {
for frame in frames_array {
if let Some(frame_array) = frame.as_array_mut() {
// Frame format: [start, stop, project, id, tags, ...]
if frame_array.len() > 3 {
if let Some(id) = frame_array[3].as_str() {
if id == current.id {
// Update start timestamp (index 0)
let new_start_timestamp = prev_stop.timestamp();
frame_array[0] = Value::Number(new_start_timestamp.into());
break;
}
}
}
}
}
}
// Write back to frames file
let updated_content = serde_json::to_string_pretty(&frames)?;
std::fs::write(&frames_path, updated_content)?;
// Reload log content
self.load_log_content()?;
self.set_status_message("Entry start time adjusted");
}
}
self.needs_clear = true;
Ok(())
}
fn copy_log_to_clipboard(&mut self) -> anyhow::Result<()> {
if self.log_view_content.is_empty() {
return Ok(());
}
// Determine what to copy based on selection level
let text = match self.log_view_selection_level {
LogViewSelection::Entry => {
// Copy just the selected entry
self.get_selected_entry_text()
}
LogViewSelection::Project => {
// Copy the selected project group
self.get_selected_project_text()
}
LogViewSelection::Day => {
// Copy the entire day
self.get_selected_day_text()
}
LogViewSelection::All => {
// Copy the entire view/period
self.log_view_content.join("\n")
}
};
// Copy to clipboard using the persistent clipboard instance
if let Some(ref mut clipboard) = self.clipboard {
if let Err(e) = clipboard.set_text(&text) {
self.set_status_message(format!("Failed to copy: {}", e));
}
} else {
// Try to create a new clipboard if we don't have one
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&text) {
self.set_status_message(format!("Failed to copy: {}", e));
}
self.clipboard = Some(clipboard);
}
Err(e) => {
self.set_status_message(format!("Failed to access clipboard: {}", e));
}
}
}
Ok(())
}
fn get_selected_entry_text(&self) -> String {
// Find the entry line corresponding to the selected frame
let entry_lines: Vec<&String> = self
.log_view_content
.iter()
.filter(|l| self.is_entry_line(l))
.collect();
if self.log_view_selected < entry_lines.len() {
entry_lines[self.log_view_selected].to_string()
} else {
String::new()
}
}
fn get_selected_project_text(&self) -> String {
// Find which project group the selected entry belongs to
let mut current_project_lines = Vec::new();
let mut frame_count = 0;
let mut found = false;
for line in &self.log_view_content {
if line.starts_with(" ") && !line.starts_with("\t") {
// This is a project header
if found {
break; // We've collected the target project
}
current_project_lines.clear();
current_project_lines.push(line.clone());
} else if self.is_entry_line(line) {
// Entry within a project
current_project_lines.push(line.clone());
if frame_count == self.log_view_selected {
found = true;
}
frame_count += 1;
}
}
current_project_lines.join("\n")
}
fn get_selected_day_text(&self) -> String {
// Find which day the selected entry belongs to
let mut current_day_lines = Vec::new();
let mut frame_count = 0;
let mut found = false;
for line in &self.log_view_content {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
// This is a date header
if found {
break; // We've collected the target day
}
current_day_lines.clear();
current_day_lines.push(line.clone());
} else if line.starts_with('\t') || line.starts_with(" ") {
// Add all lines within the day (project headers and entries)
current_day_lines.push(line.clone());
// Count only actual entries
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
found = true;
}
frame_count += 1;
}
}
}
current_day_lines.join("\n")
}
fn jump_to_next_project(&mut self) {
// Find the current project, then jump to first entry of next project
let mut current_date = String::new();
let mut current_project = String::new();
let mut selected_project = String::new();
let mut selected_date = String::new();
let mut frame_count = 0;
let mut found_current = false;
// Find which project we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with("\t") {
current_project = line.clone();
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
selected_project = current_project.clone();
selected_date = current_date.clone();
found_current = true;
break;
}
frame_count += 1;
}
}
if !found_current {
return;
}
// Now find the next project within the same day first, then next day
current_project = String::new();
current_date = String::new();
let mut past_selected = false;
frame_count = 0;
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with("\t") {
current_project = line.clone();
if past_selected && current_project != selected_project {
// This is the next project, select its first entry
self.log_view_selected = frame_count;
return;
}
}
if self.is_entry_line(line) {
if current_project == selected_project && current_date == selected_date {
past_selected = true;
}
frame_count += 1;
}
}
}
fn jump_to_previous_project(&mut self) {
// Find the previous project and jump to its first entry
let mut current_project = String::new();
let mut _selected_project = String::new();
let mut frame_count = 0;
let mut previous_project_first_entry = None;
let mut last_different_project_entry = None;
// Find which project we're currently in
for line in &self.log_view_content.clone() {
if line.starts_with(" ") && !line.starts_with("\t") {
if !current_project.is_empty() && current_project != _selected_project {
last_different_project_entry = previous_project_first_entry;
}
current_project = line.clone();
previous_project_first_entry = Some(frame_count);
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
_selected_project = current_project.clone();
// Jump to the last different project we saw
if let Some(entry) = last_different_project_entry {
self.log_view_selected = entry;
}
return;
}
frame_count += 1;
}
}
}
fn jump_to_next_day(&mut self) {
// Find the current day, then jump to first entry of next day
let mut current_date = String::new();
let mut selected_date = String::new();
let mut frame_count = 0;
let mut found_current = false;
// Find which day we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
selected_date = current_date.clone();
found_current = true;
break;
}
frame_count += 1;
}
}
if !found_current {
return;
}
// Now find the next day and jump to its first entry
current_date = String::new();
let mut past_selected = false;
frame_count = 0;
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
if past_selected && line != &selected_date {
// This is the next day, continue to find its first entry
current_date = line.clone();
} else if line == &selected_date {
past_selected = true;
}
}
if self.is_entry_line(line) {
if past_selected && !current_date.is_empty() && current_date != selected_date {
// First entry of next day
self.log_view_selected = frame_count;
return;
}
frame_count += 1;
}
}
}
fn jump_to_previous_day(&mut self) {
// Find the previous day and jump to its first entry
let mut current_date = String::new();
let mut _selected_date = String::new();
let mut frame_count = 0;
let mut last_different_day_entry = None;
let mut previous_day_first_entry = None;
// Find which day we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
if !current_date.is_empty() && current_date != _selected_date {
last_different_day_entry = previous_day_first_entry;
}
current_date = line.clone();
previous_day_first_entry = Some(frame_count);
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
_selected_date = current_date.clone();
// Jump to the last different day we saw
if let Some(entry) = last_different_day_entry {
self.log_view_selected = entry;
}
return;
}
frame_count += 1;
}
}
}
fn move_selection(&mut self, delta: i32) {
let items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return,
};
if items.is_empty() {
return;
}
let current = self.state.selected_indices[self.state.current_pane] as i32;
let items_len = items.len() as i32;
// Check if we're using two-column mode
let use_two_columns = self.config.multi_column && items.len() > 6;
if use_two_columns {
let mid_point = (items.len() + 1) / 2;
let (col_start, col_end) = if self.state.current_column == 0 {
(0, mid_point as i32 - 1)
} else {
(mid_point as i32, items_len - 1)
};
let new_index = current + delta;
if new_index < col_start && delta < 0 {
// Moving up beyond column - go to previous pane
self.change_pane(-1);
let new_items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return,
};
if !new_items.is_empty() {
self.state.selected_indices[self.state.current_pane] = new_items.len() - 1;
// Stay in right column if new pane has enough items
if self.config.multi_column && new_items.len() > 6 {
self.state.current_column = 1;
} else {
self.state.current_column = 0;
}
}
} else if new_index > col_end && delta > 0 {
// Moving down beyond column - go to next pane
self.change_pane(1);
self.state.selected_indices[self.state.current_pane] = 0;
self.state.current_column = 0;
} else {
// Normal movement within column
self.state.selected_indices[self.state.current_pane] =
new_index.clamp(col_start, col_end) as usize;
}
} else {
// Single column mode
let new_index = current + delta;
if new_index < 0 && delta < 0 {
// At top, move to previous pane
self.change_pane(-1);
let new_items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return,
};
if !new_items.is_empty() {
self.state.selected_indices[self.state.current_pane] = new_items.len() - 1;
}
} else if new_index >= items_len && delta > 0 {
// At bottom, move to next pane
self.change_pane(1);
self.state.selected_indices[self.state.current_pane] = 0;
} else {
// Normal movement within pane
self.state.selected_indices[self.state.current_pane] =
new_index.clamp(0, items_len - 1) as usize;
}
}
}
fn move_column(&mut self, delta: i32) {
if !self.config.multi_column {
return;
}
let items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return,
};
// Only switch columns if we have enough items for two columns
if items.len() <= 6 {
return;
}
let mid_point = (items.len() + 1) / 2;
let new_column = if delta > 0 { 1 } else { 0 };
if new_column != self.state.current_column {
let current_selected = self.state.selected_indices[self.state.current_pane];
// Calculate which row we're on in the current column
let current_row = if self.state.current_column == 0 {
current_selected // In left column
} else {
current_selected.saturating_sub(mid_point) // In right column
};
// Switch column and jump to the same row in the new column
self.state.current_column = new_column;
if new_column == 0 {
// Moving to left column - jump to same row
self.state.selected_indices[self.state.current_pane] = current_row.min(mid_point - 1);
} else {
// Moving to right column - jump to same row
let right_items_len = items.len() - mid_point;
self.state.selected_indices[self.state.current_pane] = (mid_point + current_row).min(items.len() - 1).min(mid_point + right_items_len - 1);
}
}
}
fn change_pane(&mut self, delta: i32) {
// Find next enabled pane
let mut next_pane = self.state.current_pane;
let enabled = [
self.config.show_permanent,
self.config.show_recurring,
self.config.show_recent,
];
// Count enabled panes
let enabled_count = enabled.iter().filter(|&&x| x).count();
if enabled_count == 0 {
return; // No panes to switch to
}
// Find next enabled pane
for _ in 0..3 {
next_pane = ((next_pane as i32 + delta).rem_euclid(3)) as usize;
if enabled[next_pane] {
break;
}
}
self.state.current_pane = next_pane;
self.state.current_column = 0;
}
fn get_current_item(&self) -> Option<TimeItem> {
let items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return None,
};
let index = self.state.selected_indices[self.state.current_pane];
items.get(index).cloned()
}
fn toggle_current_item(&mut self) -> anyhow::Result<()> {
if let Some(item) = self.get_current_item() {
if self
.state
.active_timer
.as_ref()
.map(|(active, _)| active.name == item.name)
.unwrap_or(false)
{
self.state.stop_timer()?;
} else {
self.state.start_timer(item)?;
}
self.state.save()?;
}
Ok(())
}
fn delete_current_item(&mut self) -> anyhow::Result<()> {
// Check if this is the active timer
let should_stop = {
let items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return Ok(()),
};
let index = self.state.selected_indices[self.state.current_pane];
if !items.is_empty() && index < items.len() {
if let Some((ref active, _)) = self.state.active_timer {
items[index].name == active.name
} else {
false
}
} else {
return Ok(());
}
};
// Stop timer if needed
if should_stop {
self.state.stop_timer()?;
}
// Now delete the item
let items = match self.state.current_pane {
0 => &mut self.state.permanent_items,
1 => &mut self.state.recurring_items,
2 => &mut self.state.recent_items,
_ => return Ok(()),
};
let index = self.state.selected_indices[self.state.current_pane];
if !items.is_empty() && index < items.len() {
// Remove the item
items.remove(index);
// Adjust index if we're at the end
if !items.is_empty() && index == items.len() {
self.state.selected_indices[self.state.current_pane] = items.len() - 1;
}
// Save changes
self.state.save()?;
}
Ok(())
}
fn set_status_message<S: Into<String>>(&mut self, message: S) {
self.status_message = Some((message.into(), std::time::Instant::now()));
}
fn update_status_message(&mut self) {
// Clear status message after 3 seconds
if let Some((_, instant)) = self.status_message {
if instant.elapsed().as_secs() >= 3 {
self.status_message = None;
}
}
}
fn start_new_entry(&mut self) {
self.current_screen = Screen::NewEntry;
self.new_entry_buffer.clear();
self.new_entry_project.clear();
self.new_entry_cursor = 0;
self.new_entry_mode = NewEntryMode::Task;
}
fn start_reassign_project(&mut self) {
if let Some(item) = self.get_current_item() {
self.current_screen = Screen::ReassignProject;
// Pre-fill with current project (the name field)
self.reassign_project_buffer = item.name.clone();
self.reassign_project_cursor = self.reassign_project_buffer.len();
}
}
fn start_log_view(&mut self) -> anyhow::Result<()> {
self.current_screen = Screen::LogView;
self.log_view_period = LogViewPeriod::Day;
self.log_view_scroll = 0;
self.load_log_content()?;
Ok(())
}
fn load_log_content(&mut self) -> anyhow::Result<()> {
let flag = match self.log_view_period {
LogViewPeriod::Day => "--day",
LogViewPeriod::Week => "--week",
LogViewPeriod::Month => "--month",
};
let output = Command::new("watson")
.arg("log")
.arg(flag)
.arg("--json")
.output()?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
// Parse JSON frames
match serde_json::from_str::<Vec<WatsonFrame>>(&json_str) {
Ok(frames) => {
self.log_view_frames = frames;
self.format_log_entries();
}
Err(e) => {
self.log_view_frames.clear();
self.log_view_content = vec![
"Failed to parse Watson log JSON:".to_string(),
e.to_string(),
];
}
}
} else {
let error = String::from_utf8_lossy(&output.stderr);
self.log_view_frames.clear();
self.log_view_content = vec![
"Failed to load Watson log:".to_string(),
error.to_string(),
];
}
Ok(())
}
fn reassign_project_for_current_item(&mut self) -> anyhow::Result<()> {
let items = match self.state.current_pane {
0 => &mut self.state.permanent_items,
1 => &mut self.state.recurring_items,
2 => &mut self.state.recent_items,
_ => return Ok(()),
};
let index = self.state.selected_indices[self.state.current_pane];
let needs_restart = if let Some(item) = items.get_mut(index) {
// Update the project name (item.name field)
let old_name = item.name.clone();
item.name = self.reassign_project_buffer.clone();
// Check if this is the active timer
self.state
.active_timer
.as_ref()
.map(|(active, _)| active.name == old_name)
.unwrap_or(false)
} else {
false
};
// If this was the active timer, restart it with new project name
if needs_restart {
let item = items[index].clone();
self.state.stop_timer()?;
self.state.start_timer(item)?;
}
self.state.save()?;
Ok(())
}
fn edit_app_config(&mut self) -> anyhow::Result<()> {
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::stdout;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let config_path = Config::config_path()?;
// Leave TUI mode
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
// Run editor
let status = Command::new(editor).arg(&config_path).status()?;
if status.success() {
// Reload entire application state
self.config = Config::load()?;
self.current_screen = Screen::Main;
}
// Return to TUI mode
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
self.needs_clear = true;
Ok(())
}
fn edit_config(&mut self) -> anyhow::Result<()> {
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::stdout;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let config_path = AppState::config_file()?;
if !config_path.exists() {
std::fs::write(&config_path, "# WAT Configuration\n# Add your permanent items here\n\npermanent_items:\n - name: Daily Standup\n tags: [daily, meeting]\n")?;
}
// Leave TUI mode
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
// Run editor
let status = Command::new(editor).arg(&config_path).status()?;
if status.success() {
// Reload entire application state
self.state = AppState::load()?;
self.current_screen = Screen::Main;
}
// Return to TUI mode
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
self.needs_clear = true;
Ok(())
}
}