diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d9d04c --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# wat + +`wat` is a terminal UI wrapper around [Watson](https://tailordev.github.io/Watson/) that lets you browse and launch timers via a keyboard-driven interface. + +--- + +## Goals & Workflow +- Never type `watson start ...` again—start/stop timers with `Enter`. +- Maintain three panes simultaneously: + 1. **Permanent Items** – curated recurring tasks defined in `state.yaml`. + 2. **Recurring Items** – semi-regular tasks you hop between. + 3. **Ad-Hoc Items** – last ~20 one-offs, automatically tracked. +- Highlight clearly when a timer is running (bold green) vs idle (yellow). +- Keep everything human-editable: YAML state/config under `~/.config/wat`. + +--- + +## Key Features +- **Navigation**: `j/k` or arrows move within a pane; `h/l`, arrows, or `Ctrl+n/p` switch panes; `q` quits. +- **Timer control**: `Enter` toggles the selected item. Starting a different item automatically stops the previous timer. +- **New entries**: `n` launches a modal (task name + optional project tag). Item is added to the current pane. +- **Deletion**: `d` removes the selected entry; no noisy status message. +- **Config editing**: + - `Ctrl+e` edits task config (`state.yaml`). + - `c` edits app config (`config.yaml`). +- **Help overlays**: `?` for help, `c` inside help for config instructions. + +--- + +## Files & Persistence +``` +~/.config/wat/ +├─ state.yaml # serialized AppState (items, selections, active timer) +└─ config.yaml # UI/app options (help hints, allowed projects, etc.) +``` +- `state.yaml` and `config.yaml` use `serde_yaml` for readability. + +--- + +## Building & Running +``` +cargo build +cargo run +``` +Requires `watson` on your `PATH` with a configured workspace. + +--- + +## Keybindings (Summary) +| Keys | Action | +|------|--------| +| `j` / `k`, arrows | Move selection | +| `h` / `l`, arrows, `Ctrl+n` / `Ctrl+p` | Switch panes | +| `Enter` | Start/stop timer | +| `n` | New entry (task + optional project) | +| `d` | Delete entry | +| `Ctrl+e` | Edit task config (`state.yaml`) | +| `c` | Edit app config (`config.yaml`) or show config help | +| `?` | Help screen | +| `q` | Quit | + +--- + +## TODO / Follow-ups +- Add inline editing of permanent/recurring items instead of dropping to `$EDITOR`. +- Consider status messaging for critical errors (currently unused after deleting the delete confirmation). +- Add tests/snapshots for UI components. diff --git a/src/state.rs b/src/state.rs index 4fa7e47..5a735d0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,10 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::{ - fs::{self, OpenOptions}, - io::Write, - path::{Path, PathBuf}, -}; +use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeItem { @@ -57,12 +53,6 @@ impl AppState { Ok(path) } - fn log_file() -> anyhow::Result { - let mut path = Self::config_dir()?; - path.push("watson.log"); - Ok(path) - } - pub fn load() -> anyhow::Result { let path = Self::state_file()?; if !path.exists() { @@ -86,19 +76,6 @@ impl AppState { } } - pub fn read_log_lines(limit: usize) -> anyhow::Result> { - let path = Self::log_file()?; - if !path.exists() { - return Ok(Vec::new()); - } - let contents = fs::read_to_string(path)?; - let mut lines: Vec = 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<()> { if let Some((_, _)) = self.active_timer { self.stop_timer()?; @@ -119,10 +96,6 @@ impl AppState { } let output = command.output()?; - Self::log_command_output( - &format!("watson start {} {}", item.name, item.tags.join(" ")), - &output, - )?; if !output.status.success() { anyhow::bail!( @@ -139,8 +112,6 @@ impl AppState { if let Some((ref item, _)) = self.active_timer { let output = std::process::Command::new("watson").arg("stop").output()?; - Self::log_command_output("watson stop", &output)?; - if !output.status.success() { anyhow::bail!( "Watson stop failed: {}", @@ -162,51 +133,4 @@ impl AppState { } 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(()) - } }