Fix projects, remove output in interface, log output to file
This commit is contained in:
parent
401ee37b32
commit
ae045167bf
5 changed files with 274 additions and 162 deletions
119
src/app.rs
119
src/app.rs
|
|
@ -1,8 +1,8 @@
|
||||||
use std::process::Command;
|
use crate::config::Config;
|
||||||
|
use crate::state::{AppState, TimeItem};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
use crate::state::{AppState, TimeItem};
|
use std::process::Command;
|
||||||
use crate::config::Config;
|
|
||||||
|
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
Main,
|
Main,
|
||||||
|
|
@ -19,7 +19,7 @@ pub struct App {
|
||||||
pub new_entry_buffer: String,
|
pub new_entry_buffer: String,
|
||||||
pub new_entry_project: String,
|
pub new_entry_project: String,
|
||||||
pub new_entry_cursor: usize,
|
pub new_entry_cursor: usize,
|
||||||
pub new_entry_mode: NewEntryMode, // Task or Project
|
pub new_entry_mode: NewEntryMode, // Task or Project
|
||||||
pub status_message: Option<(String, std::time::Instant)>,
|
pub status_message: Option<(String, std::time::Instant)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ impl App {
|
||||||
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
||||||
// Update status message
|
// Update status message
|
||||||
self.update_status_message();
|
self.update_status_message();
|
||||||
|
|
||||||
match self.current_screen {
|
match self.current_screen {
|
||||||
Screen::Main => self.handle_main_event(event),
|
Screen::Main => self.handle_main_event(event),
|
||||||
Screen::Help => self.handle_help_event(event),
|
Screen::Help => self.handle_help_event(event),
|
||||||
|
|
@ -58,9 +58,7 @@ impl App {
|
||||||
fn handle_main_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
fn handle_main_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
||||||
match event {
|
match event {
|
||||||
Event::Key(KeyEvent {
|
Event::Key(KeyEvent {
|
||||||
code,
|
code, modifiers, ..
|
||||||
modifiers,
|
|
||||||
..
|
|
||||||
}) => match (code, modifiers) {
|
}) => match (code, modifiers) {
|
||||||
(KeyCode::Char('q'), _) => return Ok(true),
|
(KeyCode::Char('q'), _) => return Ok(true),
|
||||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
|
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
|
||||||
|
|
@ -85,9 +83,7 @@ impl App {
|
||||||
fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
||||||
match event {
|
match event {
|
||||||
Event::Key(KeyEvent {
|
Event::Key(KeyEvent {
|
||||||
code,
|
code, modifiers, ..
|
||||||
modifiers,
|
|
||||||
..
|
|
||||||
}) => {
|
}) => {
|
||||||
match (code, modifiers) {
|
match (code, modifiers) {
|
||||||
(KeyCode::Esc, _) => {
|
(KeyCode::Esc, _) => {
|
||||||
|
|
@ -95,16 +91,20 @@ impl App {
|
||||||
self.new_entry_buffer.clear();
|
self.new_entry_buffer.clear();
|
||||||
self.new_entry_project.clear();
|
self.new_entry_project.clear();
|
||||||
self.new_entry_mode = NewEntryMode::Task;
|
self.new_entry_mode = NewEntryMode::Task;
|
||||||
|
self.new_entry_cursor = 0;
|
||||||
}
|
}
|
||||||
(KeyCode::Enter, _) => {
|
(KeyCode::Enter, _) => {
|
||||||
match self.new_entry_mode {
|
match self.new_entry_mode {
|
||||||
NewEntryMode::Task => {
|
NewEntryMode::Task => {
|
||||||
if !self.new_entry_buffer.is_empty() {
|
if !self.new_entry_buffer.is_empty() {
|
||||||
self.new_entry_mode = NewEntryMode::Project;
|
self.new_entry_mode = NewEntryMode::Project;
|
||||||
|
self.new_entry_cursor = self.new_entry_project.len();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NewEntryMode::Project => {
|
NewEntryMode::Project => {
|
||||||
if self.new_entry_project.is_empty() || self.config.is_valid_project(&self.new_entry_project) {
|
if self.new_entry_project.is_empty()
|
||||||
|
|| self.config.is_valid_project(&self.new_entry_project)
|
||||||
|
{
|
||||||
// Create new time item
|
// Create new time item
|
||||||
let item = TimeItem {
|
let item = TimeItem {
|
||||||
name: self.new_entry_buffer.clone(),
|
name: self.new_entry_buffer.clone(),
|
||||||
|
|
@ -117,13 +117,13 @@ impl App {
|
||||||
};
|
};
|
||||||
// Add to current pane (or recurring if in recent)
|
// Add to current pane (or recurring if in recent)
|
||||||
match self.state.current_pane {
|
match self.state.current_pane {
|
||||||
0 => self.state.permanent_items.push(item), // Permanent Items
|
0 => self.state.permanent_items.push(item), // Permanent Items
|
||||||
1 => self.state.recurring_items.push(item), // Recurring Items
|
1 => self.state.recurring_items.push(item), // Recurring Items
|
||||||
2 => self.state.recent_items.push(item), // Ad-Hoc Items
|
2 => self.state.recent_items.push(item), // Ad-Hoc Items
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
self.state.save()?;
|
self.state.save()?;
|
||||||
|
|
||||||
// Clear and return to main screen
|
// Clear and return to main screen
|
||||||
self.current_screen = Screen::Main;
|
self.current_screen = Screen::Main;
|
||||||
self.new_entry_buffer.clear();
|
self.new_entry_buffer.clear();
|
||||||
|
|
@ -133,34 +133,35 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(KeyCode::Backspace, _) => {
|
(KeyCode::Backspace, _) => match self.new_entry_mode {
|
||||||
match self.new_entry_mode {
|
NewEntryMode::Task => {
|
||||||
NewEntryMode::Task => {
|
if self.new_entry_cursor > 0 {
|
||||||
if self.new_entry_cursor > 0 {
|
self.new_entry_buffer.remove(self.new_entry_cursor - 1);
|
||||||
self.new_entry_buffer.remove(self.new_entry_cursor - 1);
|
self.new_entry_cursor -= 1;
|
||||||
self.new_entry_cursor -= 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
NewEntryMode::Project => {
|
}
|
||||||
if self.new_entry_cursor > 0 {
|
NewEntryMode::Project => {
|
||||||
self.new_entry_project.remove(self.new_entry_cursor - 1);
|
if self.new_entry_cursor > 0 {
|
||||||
|
let idx = self.new_entry_cursor - 1;
|
||||||
|
if idx < self.new_entry_project.len() {
|
||||||
|
self.new_entry_project.remove(idx);
|
||||||
self.new_entry_cursor -= 1;
|
self.new_entry_cursor -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
(KeyCode::Char(c), m) if m.is_empty() => {
|
(KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode {
|
||||||
match self.new_entry_mode {
|
NewEntryMode::Task => {
|
||||||
NewEntryMode::Task => {
|
self.new_entry_buffer.insert(self.new_entry_cursor, c);
|
||||||
self.new_entry_buffer.insert(self.new_entry_cursor, c);
|
self.new_entry_cursor += 1;
|
||||||
self.new_entry_cursor += 1;
|
}
|
||||||
}
|
NewEntryMode::Project => {
|
||||||
NewEntryMode::Project => {
|
if self.new_entry_cursor <= self.new_entry_project.len() {
|
||||||
self.new_entry_project.insert(self.new_entry_cursor, c);
|
self.new_entry_project.insert(self.new_entry_cursor, c);
|
||||||
self.new_entry_cursor += 1;
|
self.new_entry_cursor += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +228,13 @@ impl App {
|
||||||
|
|
||||||
fn toggle_current_item(&mut self) -> anyhow::Result<()> {
|
fn toggle_current_item(&mut self) -> anyhow::Result<()> {
|
||||||
if let Some(item) = self.get_current_item() {
|
if let Some(item) = self.get_current_item() {
|
||||||
if self.state.active_timer.as_ref().map(|(active, _)| active.name == item.name).unwrap_or(false) {
|
if self
|
||||||
|
.state
|
||||||
|
.active_timer
|
||||||
|
.as_ref()
|
||||||
|
.map(|(active, _)| active.name == item.name)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
self.state.stop_timer()?;
|
self.state.stop_timer()?;
|
||||||
} else {
|
} else {
|
||||||
self.state.start_timer(item)?;
|
self.state.start_timer(item)?;
|
||||||
|
|
@ -248,7 +255,7 @@ impl App {
|
||||||
};
|
};
|
||||||
|
|
||||||
let index = self.state.selected_indices[self.state.current_pane];
|
let index = self.state.selected_indices[self.state.current_pane];
|
||||||
|
|
||||||
if !items.is_empty() && index < items.len() {
|
if !items.is_empty() && index < items.len() {
|
||||||
if let Some((ref active, _)) = self.state.active_timer {
|
if let Some((ref active, _)) = self.state.active_timer {
|
||||||
items[index].name == active.name
|
items[index].name == active.name
|
||||||
|
|
@ -274,20 +281,20 @@ impl App {
|
||||||
};
|
};
|
||||||
|
|
||||||
let index = self.state.selected_indices[self.state.current_pane];
|
let index = self.state.selected_indices[self.state.current_pane];
|
||||||
|
|
||||||
if !items.is_empty() && index < items.len() {
|
if !items.is_empty() && index < items.len() {
|
||||||
// Remove the item
|
// Remove the item
|
||||||
items.remove(index);
|
items.remove(index);
|
||||||
|
|
||||||
// Adjust index if we're at the end
|
// Adjust index if we're at the end
|
||||||
if !items.is_empty() && index == items.len() {
|
if !items.is_empty() && index == items.len() {
|
||||||
self.state.selected_indices[self.state.current_pane] = items.len() - 1;
|
self.state.selected_indices[self.state.current_pane] = items.len() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save changes
|
// Save changes
|
||||||
self.state.save()?;
|
self.state.save()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,23 +321,21 @@ impl App {
|
||||||
|
|
||||||
fn edit_app_config(&mut self) -> anyhow::Result<()> {
|
fn edit_app_config(&mut self) -> anyhow::Result<()> {
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
terminal::{disable_raw_mode, enable_raw_mode},
|
|
||||||
execute,
|
execute,
|
||||||
terminal::{LeaveAlternateScreen, EnterAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode},
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use std::io::stdout;
|
use std::io::stdout;
|
||||||
|
|
||||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
||||||
let config_path = Config::config_path()?;
|
let config_path = Config::config_path()?;
|
||||||
|
|
||||||
// Leave TUI mode
|
// Leave TUI mode
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(stdout(), LeaveAlternateScreen)?;
|
execute!(stdout(), LeaveAlternateScreen)?;
|
||||||
|
|
||||||
// Run editor
|
// Run editor
|
||||||
let status = Command::new(editor)
|
let status = Command::new(editor).arg(&config_path).status()?;
|
||||||
.arg(&config_path)
|
|
||||||
.status()?;
|
|
||||||
|
|
||||||
if status.success() {
|
if status.success() {
|
||||||
// Reload entire application state
|
// Reload entire application state
|
||||||
|
|
@ -343,21 +348,21 @@ impl App {
|
||||||
// Return to TUI mode
|
// Return to TUI mode
|
||||||
execute!(stdout(), EnterAlternateScreen)?;
|
execute!(stdout(), EnterAlternateScreen)?;
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_config(&mut self) -> anyhow::Result<()> {
|
fn edit_config(&mut self) -> anyhow::Result<()> {
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
terminal::{disable_raw_mode, enable_raw_mode},
|
|
||||||
execute,
|
execute,
|
||||||
terminal::{LeaveAlternateScreen, EnterAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode},
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use std::io::stdout;
|
use std::io::stdout;
|
||||||
|
|
||||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
||||||
let config_path = AppState::config_file()?;
|
let config_path = AppState::config_file()?;
|
||||||
|
|
||||||
if !config_path.exists() {
|
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")?;
|
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")?;
|
||||||
}
|
}
|
||||||
|
|
@ -367,9 +372,7 @@ impl App {
|
||||||
execute!(stdout(), LeaveAlternateScreen)?;
|
execute!(stdout(), LeaveAlternateScreen)?;
|
||||||
|
|
||||||
// Run editor
|
// Run editor
|
||||||
let status = Command::new(editor)
|
let status = Command::new(editor).arg(&config_path).status()?;
|
||||||
.arg(&config_path)
|
|
||||||
.status()?;
|
|
||||||
|
|
||||||
if status.success() {
|
if status.success() {
|
||||||
// Reload entire application state
|
// Reload entire application state
|
||||||
|
|
@ -382,7 +385,7 @@ impl App {
|
||||||
// Return to TUI mode
|
// Return to TUI mode
|
||||||
execute!(stdout(), EnterAlternateScreen)?;
|
execute!(stdout(), EnterAlternateScreen)?;
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ impl Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_path() -> anyhow::Result<PathBuf> {
|
pub fn config_path() -> anyhow::Result<PathBuf> {
|
||||||
let mut path = dirs::config_dir()
|
let mut path =
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
|
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
|
||||||
path.push("wat");
|
path.push("wat");
|
||||||
std::fs::create_dir_all(&path)?;
|
std::fs::create_dir_all(&path)?;
|
||||||
path.push("config.yaml");
|
path.push("config.yaml");
|
||||||
|
|
@ -71,4 +71,4 @@ impl Config {
|
||||||
}
|
}
|
||||||
self.projects.contains(&project.to_string())
|
self.projects.contains(&project.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod config;
|
||||||
mod state;
|
mod state;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod config;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
|
|
@ -48,7 +48,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
app.needs_redraw = false;
|
app.needs_redraw = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if event::poll(std::time::Duration::from_millis(50))? {
|
if event::poll(std::time::Duration::from_millis(50))? {
|
||||||
if app.handle_event(event::read()?)? {
|
if app.handle_event(event::read()?)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -57,4 +57,4 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
src/state.rs
130
src/state.rs
|
|
@ -1,6 +1,10 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::Write,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimeItem {
|
pub struct TimeItem {
|
||||||
|
|
@ -34,8 +38,8 @@ impl Default for AppState {
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn config_dir() -> anyhow::Result<PathBuf> {
|
pub fn config_dir() -> anyhow::Result<PathBuf> {
|
||||||
let mut path = dirs::config_dir()
|
let mut path =
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
|
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
|
||||||
path.push("wat");
|
path.push("wat");
|
||||||
std::fs::create_dir_all(&path)?;
|
std::fs::create_dir_all(&path)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
|
|
@ -53,6 +57,12 @@ impl AppState {
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_file() -> anyhow::Result<PathBuf> {
|
||||||
|
let mut path = Self::config_dir()?;
|
||||||
|
path.push("watson.log");
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> anyhow::Result<Self> {
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
let path = Self::state_file()?;
|
let path = Self::state_file()?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -76,18 +86,50 @@ impl AppState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_log_lines(limit: usize) -> anyhow::Result<Vec<String>> {
|
||||||
|
let path = Self::log_file()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(path)?;
|
||||||
|
let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
|
||||||
|
if lines.len() > limit {
|
||||||
|
lines = lines.split_off(lines.len() - limit);
|
||||||
|
}
|
||||||
|
Ok(lines)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> {
|
pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> {
|
||||||
// Stop current timer if any
|
|
||||||
if let Some((_, _)) = self.active_timer {
|
if let Some((_, _)) = self.active_timer {
|
||||||
self.stop_timer()?;
|
self.stop_timer()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start new timer
|
let mut command = std::process::Command::new("watson");
|
||||||
std::process::Command::new("watson")
|
command.arg("start").arg(&item.name);
|
||||||
.args(["start", &item.name])
|
for tag in &item.tags {
|
||||||
.args(item.tags.iter().map(|t| format!("-T {}", t)))
|
let tag = tag.trim();
|
||||||
.spawn()?
|
if tag.is_empty() {
|
||||||
.wait()?;
|
continue;
|
||||||
|
}
|
||||||
|
if tag.starts_with('+') {
|
||||||
|
command.arg(tag);
|
||||||
|
} else {
|
||||||
|
command.arg(format!("+{}", tag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = command.output()?;
|
||||||
|
Self::log_command_output(
|
||||||
|
&format!("watson start {} {}", item.name, item.tags.join(" ")),
|
||||||
|
&output,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Watson start failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
self.active_timer = Some((item.clone(), Utc::now()));
|
self.active_timer = Some((item.clone(), Utc::now()));
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -95,12 +137,17 @@ impl AppState {
|
||||||
|
|
||||||
pub fn stop_timer(&mut self) -> anyhow::Result<()> {
|
pub fn stop_timer(&mut self) -> anyhow::Result<()> {
|
||||||
if let Some((ref item, _)) = self.active_timer {
|
if let Some((ref item, _)) = self.active_timer {
|
||||||
std::process::Command::new("watson")
|
let output = std::process::Command::new("watson").arg("stop").output()?;
|
||||||
.arg("stop")
|
|
||||||
.spawn()?
|
Self::log_command_output("watson stop", &output)?;
|
||||||
.wait()?;
|
|
||||||
|
if !output.status.success() {
|
||||||
// Update last_used timestamp
|
anyhow::bail!(
|
||||||
|
"Watson stop failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let update_last_used = |items: &mut Vec<TimeItem>| {
|
let update_last_used = |items: &mut Vec<TimeItem>| {
|
||||||
if let Some(target) = items.iter_mut().find(|i| i.name == item.name) {
|
if let Some(target) = items.iter_mut().find(|i| i.name == item.name) {
|
||||||
target.last_used = Some(Utc::now());
|
target.last_used = Some(Utc::now());
|
||||||
|
|
@ -115,4 +162,51 @@ impl AppState {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
fn log_command_output(description: &str, output: &std::process::Output) -> anyhow::Result<()> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.push(format!("[{}] {description}", Utc::now().to_rfc3339()));
|
||||||
|
lines.push(format!("status: {}", output.status));
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
lines.push("stdout:".into());
|
||||||
|
lines.push(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
lines.push("stderr:".into());
|
||||||
|
lines.push(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(String::new());
|
||||||
|
Self::append_log_entry(&lines)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_log_entry(lines: &[String]) -> anyhow::Result<()> {
|
||||||
|
let path = Self::log_file()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
for line in lines {
|
||||||
|
writeln!(file, "{line}")?;
|
||||||
|
}
|
||||||
|
Self::trim_log(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_log(path: &Path) -> anyhow::Result<()> {
|
||||||
|
const MAX_LINES: usize = 800;
|
||||||
|
let contents = fs::read_to_string(path)?;
|
||||||
|
let mut lines: Vec<&str> = contents.lines().collect();
|
||||||
|
if lines.len() > MAX_LINES {
|
||||||
|
lines = lines.split_off(lines.len() - MAX_LINES);
|
||||||
|
fs::write(path, lines.join("\n") + "\n")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
175
src/ui.rs
175
src/ui.rs
|
|
@ -1,12 +1,15 @@
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect, Alignment},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, List, ListItem, Paragraph, Clear},
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{state::{AppState, TimeItem}, app::{App, Screen, NewEntryMode}};
|
use crate::{
|
||||||
|
app::{App, NewEntryMode, Screen},
|
||||||
|
state::{AppState, TimeItem},
|
||||||
|
};
|
||||||
|
|
||||||
const ACTIVE_COLOR: Color = Color::Green;
|
const ACTIVE_COLOR: Color = Color::Green;
|
||||||
const INACTIVE_COLOR: Color = Color::Yellow;
|
const INACTIVE_COLOR: Color = Color::Yellow;
|
||||||
|
|
@ -25,24 +28,28 @@ fn render_main(frame: &mut Frame, app: &App) {
|
||||||
let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints;
|
let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints;
|
||||||
let has_status = app.status_message.is_some();
|
let has_status = app.status_message.is_some();
|
||||||
let bottom_height = if show_bottom_bar {
|
let bottom_height = if show_bottom_bar {
|
||||||
if has_status { 2 } else { 1 }
|
if has_status {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if has_status { 1 } else { 0 }
|
if has_status {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let constraints = if bottom_height > 0 {
|
let constraints = if bottom_height > 0 {
|
||||||
vec![
|
vec![
|
||||||
Constraint::Min(3), // At least 3 lines for each section
|
Constraint::Min(3), // At least 3 lines for each section
|
||||||
Constraint::Min(3),
|
Constraint::Min(3),
|
||||||
Constraint::Min(3),
|
Constraint::Min(3),
|
||||||
Constraint::Length(bottom_height), // Command bar + optional status
|
Constraint::Length(bottom_height), // Command bar + optional status
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
|
||||||
Constraint::Min(3),
|
|
||||||
Constraint::Min(3),
|
|
||||||
Constraint::Min(3),
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
|
|
@ -109,24 +116,22 @@ fn render_new_entry(frame: &mut Frame, app: &App) {
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([Constraint::Length(3), Constraint::Length(3)])
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Length(3),
|
|
||||||
])
|
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Task name input
|
// Task name input
|
||||||
let task_block = Block::default()
|
let task_block = Block::default()
|
||||||
.title("Task Name")
|
.title("Task Name")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) {
|
.style(
|
||||||
ACTIVE_COLOR
|
Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) {
|
||||||
} else {
|
ACTIVE_COLOR
|
||||||
Color::White
|
} else {
|
||||||
}));
|
Color::White
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let task_text = Paragraph::new(app.new_entry_buffer.as_str())
|
let task_text = Paragraph::new(app.new_entry_buffer.as_str()).block(task_block);
|
||||||
.block(task_block);
|
|
||||||
|
|
||||||
frame.render_widget(task_text, chunks[0]);
|
frame.render_widget(task_text, chunks[0]);
|
||||||
|
|
||||||
|
|
@ -134,14 +139,17 @@ fn render_new_entry(frame: &mut Frame, app: &App) {
|
||||||
let project_block = Block::default()
|
let project_block = Block::default()
|
||||||
.title("Project (optional)")
|
.title("Project (optional)")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) {
|
.style(
|
||||||
ACTIVE_COLOR
|
Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) {
|
||||||
} else {
|
ACTIVE_COLOR
|
||||||
Color::White
|
} else {
|
||||||
}));
|
Color::White
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let project_text = if !app.config.projects.is_empty() {
|
let project_text = if !app.config.projects.is_empty() {
|
||||||
format!("{} (available: {})",
|
format!(
|
||||||
|
"{} (available: {})",
|
||||||
app.new_entry_project,
|
app.new_entry_project,
|
||||||
app.config.projects.join(", ")
|
app.config.projects.join(", ")
|
||||||
)
|
)
|
||||||
|
|
@ -149,18 +157,12 @@ fn render_new_entry(frame: &mut Frame, app: &App) {
|
||||||
app.new_entry_project.clone()
|
app.new_entry_project.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let project_text = Paragraph::new(project_text)
|
let project_text = Paragraph::new(project_text).block(project_block);
|
||||||
.block(project_block);
|
|
||||||
|
|
||||||
frame.render_widget(project_text, chunks[1]);
|
frame.render_widget(project_text, chunks[1]);
|
||||||
|
|
||||||
// Render command bar
|
// Render command bar
|
||||||
let bar_area = Rect::new(
|
let bar_area = Rect::new(0, frame.size().height - 1, frame.size().width, 1);
|
||||||
0,
|
|
||||||
frame.size().height - 1,
|
|
||||||
frame.size().width,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
let command_text = match app.new_entry_mode {
|
let command_text = match app.new_entry_mode {
|
||||||
NewEntryMode::Task => "Enter task name, press Enter to continue",
|
NewEntryMode::Task => "Enter task name, press Enter to continue",
|
||||||
|
|
@ -180,8 +182,8 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(1), // Status message
|
Constraint::Length(1), // Status message
|
||||||
Constraint::Length(1), // Command bar
|
Constraint::Length(1), // Command bar
|
||||||
])
|
])
|
||||||
.split(area)
|
.split(area)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -210,19 +212,23 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
("d", "delete"),
|
("d", "delete"),
|
||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let command_text = format!(" {}", commands.iter()
|
let command_text = format!(
|
||||||
.map(|(key, desc)| format!("{} ({})", key, desc))
|
" {}",
|
||||||
.collect::<Vec<_>>()
|
commands
|
||||||
.join(" · "));
|
.iter()
|
||||||
|
.map(|(key, desc)| format!("{} ({})", key, desc))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · ")
|
||||||
|
);
|
||||||
|
|
||||||
let command_area = if app.config.show_help_hint {
|
let command_area = if app.config.show_help_hint {
|
||||||
// Leave space for help hint
|
// Leave space for help hint
|
||||||
Rect::new(
|
Rect::new(
|
||||||
chunks[command_line_idx].x,
|
chunks[command_line_idx].x,
|
||||||
chunks[command_line_idx].y,
|
chunks[command_line_idx].y,
|
||||||
chunks[command_line_idx].width.saturating_sub(12),
|
chunks[command_line_idx].width.saturating_sub(12),
|
||||||
1
|
1,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
chunks[command_line_idx]
|
chunks[command_line_idx]
|
||||||
|
|
@ -231,7 +237,7 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let command_bar = Paragraph::new(command_text)
|
let command_bar = Paragraph::new(command_text)
|
||||||
.style(Style::default().fg(Color::White))
|
.style(Style::default().fg(Color::White))
|
||||||
.alignment(Alignment::Left);
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
frame.render_widget(command_bar, command_area);
|
frame.render_widget(command_bar, command_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,49 +245,50 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let help_hint = Paragraph::new("(?) for help")
|
let help_hint = Paragraph::new("(?) for help")
|
||||||
.alignment(Alignment::Right)
|
.alignment(Alignment::Right)
|
||||||
.style(Style::default().fg(Color::DarkGray));
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
let help_area = Rect::new(
|
let help_area = Rect::new(
|
||||||
chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12),
|
chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12),
|
||||||
chunks[command_line_idx].y,
|
chunks[command_line_idx].y,
|
||||||
12,
|
12,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
frame.render_widget(help_hint, help_area);
|
frame.render_widget(help_hint, help_area);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_help_command_bar(frame: &mut Frame) {
|
fn render_help_command_bar(frame: &mut Frame) {
|
||||||
let commands = vec![
|
let commands = vec![("c", "configuration help"), ("q/ESC", "back")];
|
||||||
("c", "configuration help"),
|
|
||||||
("q/ESC", "back"),
|
let command_text = format!(
|
||||||
];
|
" {}",
|
||||||
|
commands
|
||||||
let command_text = format!(" {}", commands.iter()
|
.iter()
|
||||||
.map(|(key, desc)| format!("{} ({})", key, desc))
|
.map(|(key, desc)| format!("{} ({})", key, desc))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" · "));
|
.join(" · ")
|
||||||
|
);
|
||||||
|
|
||||||
let bar_area = Rect::new(
|
let bar_area = Rect::new(
|
||||||
0,
|
0,
|
||||||
frame.size().height.saturating_sub(1),
|
frame.size().height.saturating_sub(1),
|
||||||
frame.size().width,
|
frame.size().width,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
let command_bar = Paragraph::new(command_text)
|
let command_bar = Paragraph::new(command_text)
|
||||||
.style(Style::default().fg(Color::White))
|
.style(Style::default().fg(Color::White))
|
||||||
.alignment(Alignment::Left);
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
frame.render_widget(command_bar, bar_area);
|
frame.render_widget(command_bar, bar_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_help(frame: &mut Frame, _app: &App) {
|
fn render_help(frame: &mut Frame, _app: &App) {
|
||||||
let width = frame.size().width.saturating_sub(4).min(60);
|
let width = frame.size().width.saturating_sub(4).min(60);
|
||||||
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
|
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
|
||||||
|
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
let help_text = vec![
|
let help_text = vec![
|
||||||
"WAT - Watson Time Tracker Interface",
|
"WAT - Watson Time Tracker Interface",
|
||||||
"",
|
"",
|
||||||
|
|
@ -312,17 +319,17 @@ fn render_help(frame: &mut Frame, _app: &App) {
|
||||||
];
|
];
|
||||||
|
|
||||||
let text = help_text.join("\n");
|
let text = help_text.join("\n");
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title("Help")
|
.title("Help")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(Style::default().fg(Color::White));
|
.style(Style::default().fg(Color::White));
|
||||||
|
|
||||||
let paragraph = Paragraph::new(text)
|
let paragraph = Paragraph::new(text)
|
||||||
.block(block)
|
.block(block)
|
||||||
.style(Style::default().fg(Color::White))
|
.style(Style::default().fg(Color::White))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
render_help_command_bar(frame);
|
render_help_command_bar(frame);
|
||||||
}
|
}
|
||||||
|
|
@ -330,9 +337,9 @@ fn render_help(frame: &mut Frame, _app: &App) {
|
||||||
fn render_config_help(frame: &mut Frame, _app: &App) {
|
fn render_config_help(frame: &mut Frame, _app: &App) {
|
||||||
let width = frame.size().width.saturating_sub(4).min(60);
|
let width = frame.size().width.saturating_sub(4).min(60);
|
||||||
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
|
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
|
||||||
|
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
let help_text = vec![
|
let help_text = vec![
|
||||||
"WAT Configuration",
|
"WAT Configuration",
|
||||||
"",
|
"",
|
||||||
|
|
@ -360,17 +367,17 @@ fn render_config_help(frame: &mut Frame, _app: &App) {
|
||||||
];
|
];
|
||||||
|
|
||||||
let text = help_text.join("\n");
|
let text = help_text.join("\n");
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title("Configuration Help")
|
.title("Configuration Help")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(Style::default().fg(Color::White));
|
.style(Style::default().fg(Color::White));
|
||||||
|
|
||||||
let paragraph = Paragraph::new(text)
|
let paragraph = Paragraph::new(text)
|
||||||
.block(block)
|
.block(block)
|
||||||
.style(Style::default().fg(Color::White))
|
.style(Style::default().fg(Color::White))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
render_help_command_bar(frame);
|
render_help_command_bar(frame);
|
||||||
}
|
}
|
||||||
|
|
@ -384,8 +391,12 @@ fn render_section(
|
||||||
selected: usize,
|
selected: usize,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) {
|
) {
|
||||||
let border_color = if is_active { ACTIVE_COLOR } else { INACTIVE_COLOR };
|
let border_color = if is_active {
|
||||||
|
ACTIVE_COLOR
|
||||||
|
} else {
|
||||||
|
INACTIVE_COLOR
|
||||||
|
};
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(title)
|
.title(title)
|
||||||
|
|
@ -402,9 +413,13 @@ fn render_section(
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let style = if is_running {
|
let style = if is_running {
|
||||||
Style::default().fg(ACTIVE_COLOR).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(ACTIVE_COLOR)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else if i == selected && is_active {
|
} else if i == selected && is_active {
|
||||||
Style::default().fg(border_color).add_modifier(Modifier::REVERSED)
|
Style::default()
|
||||||
|
.fg(border_color)
|
||||||
|
.add_modifier(Modifier::REVERSED)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
@ -427,11 +442,11 @@ fn render_section(
|
||||||
fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
|
fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
|
||||||
let x = (r.width.saturating_sub(width)) / 2;
|
let x = (r.width.saturating_sub(width)) / 2;
|
||||||
let y = (r.height.saturating_sub(height)) / 2;
|
let y = (r.height.saturating_sub(height)) / 2;
|
||||||
|
|
||||||
Rect {
|
Rect {
|
||||||
x: r.x + x,
|
x: r.x + x,
|
||||||
y: r.y + y,
|
y: r.y + y,
|
||||||
width: width.min(r.width),
|
width: width.min(r.width),
|
||||||
height: height.min(r.height),
|
height: height.min(r.height),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue