diff --git a/Cargo.lock b/Cargo.lock index 59e1f3c2..5be120f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5666,7 +5666,7 @@ dependencies = [ [[package]] name = "toki-tui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", diff --git a/docs/plans/2026-03-02-tui-version-status-commands.md b/docs/plans/2026-03-02-tui-version-status-commands.md deleted file mode 100644 index 85cc9a8a..00000000 --- a/docs/plans/2026-03-02-tui-version-status-commands.md +++ /dev/null @@ -1,127 +0,0 @@ -# toki-tui version + status commands Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add `toki-tui version` and `toki-tui status` CLI subcommands and bump the crate version to 0.2.0. - -**Architecture:** Two new variants added to the existing `Commands` enum in `cli.rs`, handled in the `match` block in `main.rs`. No new files, no network calls. Version is read at compile time via `env!("CARGO_PKG_VERSION")`. Status is purely local — reads session and Milltime cookie files from disk. - -**Tech Stack:** clap 4 (derive), Rust std, existing `session_store` module. - ---- - -### Task 1: Bump crate version to 0.2.0 - -**Files:** -- Modify: `toki-tui/Cargo.toml:3` - -**Step 1: Edit version field** - -Change: -```toml -version = "0.1.0" -``` -To: -```toml -version = "0.2.0" -``` - -**Step 2: Verify build** - -```bash -SQLX_OFFLINE=true just check -``` -Expected: `Finished` with no errors. - ---- - -### Task 2: Add `Version` subcommand - -**Files:** -- Modify: `toki-tui/src/cli.rs` -- Modify: `toki-tui/src/main.rs` - -**Step 1: Add variant to Commands enum in `cli.rs`** - -```rust -/// Print the current version -Version, -``` - -**Step 2: Handle in `main.rs` match block** - -```rust -Commands::Version => { - println!("{}", env!("CARGO_PKG_VERSION")); -} -``` - -**Step 3: Verify build** - -```bash -SQLX_OFFLINE=true just check -``` -Expected: `Finished` with no errors. - ---- - -### Task 3: Add `Status` subcommand - -**Files:** -- Modify: `toki-tui/src/cli.rs` -- Modify: `toki-tui/src/main.rs` - -**Step 1: Add variant to Commands enum in `cli.rs`** - -```rust -/// Show current login and Milltime session status -Status, -``` - -**Step 2: Handle in `main.rs` match block** - -```rust -Commands::Status => { - let session = session_store::load_session()?; - let mt_cookies = session_store::load_mt_cookies()?; - let session_status = if session.is_some() { "logged in" } else { "not logged in" }; - let mt_status = if !mt_cookies.is_empty() { "authenticated" } else { "no cookies" }; - println!("Session: {}", session_status); - println!("Milltime: {}", mt_status); -} -``` - -**Step 3: Verify build** - -```bash -SQLX_OFFLINE=true just check -``` -Expected: `Finished` with no errors. - ---- - -### Task 4: Add justfile recipes - -**Files:** -- Modify: `justfile` - -**Step 1: Add two recipes after existing tui-* recipes** - -```just -# Print toki-tui version -tui-version: - cd toki-tui && cargo run -- version - -# Show toki-tui session status -tui-status: - cd toki-tui && cargo run -- status -``` - ---- - -### Task 5: Commit - -```bash -git add toki-tui/Cargo.toml toki-tui/src/cli.rs toki-tui/src/main.rs justfile docs/plans/2026-03-02-tui-version-status-commands.md -git commit -m "feat(tui): add version and status subcommands, bump to v0.2.0" -``` diff --git a/justfile b/justfile index 31ec55e1..a59cf72c 100644 --- a/justfile +++ b/justfile @@ -103,3 +103,7 @@ tui-version: # Show toki-tui session and Milltime status tui-status: cd toki-tui && cargo run -- status + +# Print the log notes directory path +tui-logs: + cd toki-tui && cargo run -- logs-path diff --git a/toki-tui/Cargo.toml b/toki-tui/Cargo.toml index a4189ffe..ff43f685 100644 --- a/toki-tui/Cargo.toml +++ b/toki-tui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toki-tui" -version = "0.2.0" +version = "0.3.0" edition = "2021" [dependencies] diff --git a/toki-tui/README.md b/toki-tui/README.md index 963364ca..020da274 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -16,37 +16,30 @@ just tui-dev # Clear saved session just tui-logout - -# Print config path and create default config if missing -just tui-config ``` -## Configuration - -Config file: `~/.config/toki-tui/config.toml` +## CLI Commands -Run `just tui-config` (or `cargo run -- config-path`) to print the path and create the file with defaults if it does not exist. +All commands are available via the binary directly (`toki-tui `) or through `just`: -All keys are optional. If the file is missing, built-in defaults are used. +| Command | `just` recipe | Description | +| -------------- | -------------- | -------------------------------------------------- | +| `run` | `just tui` | Run against the real toki-api server | +| `dev` | `just tui-dev` | Run in dev mode with in-memory mock data | +| `login` | `just tui-login` | Authenticate via browser OAuth | +| `logout` | `just tui-logout` | Clear saved session and Milltime cookies | +| `status` | `just tui-status` | Show current login and Milltime session status | +| `config-path` | `just tui-config` | Print config path; create default file if missing | +| `logs-path` | `just tui-logs` | Print the log notes directory path | +| `version` | `just tui-version` | Print the current version | -### Environment variables - -You can override config values with environment variables. - -- Prefix: `TOKI_TUI_` -- Key format: uppercase snake case -- Nested keys (if added later): use `__` as separator +## Configuration -Current variables: +Config file: `~/.config/toki-tui/config.toml` -```bash -TOKI_TUI_API_URL="http://localhost:8080" -TOKI_TUI_GIT_DEFAULT_PREFIX="Development" -TOKI_TUI_TASK_FILTER="+work project:Toki" -TOKI_TUI_AUTO_RESIZE_TIMER=true -``` +Run `just tui-config` (or `toki-tui config-path`) to print the path and create the file with defaults if it does not exist. -Environment variables override values from `config.toml`. +All keys are optional. If the file is missing, built-in defaults are used. ```toml # URL of the toki-api server. Defaults to the production instance. @@ -54,7 +47,7 @@ api_url = "https://toki-api.spinit.se" # Prefix used when converting a git branch name to a time entry note, # when no conventional commit prefix (feat/fix/etc.) or ticket number is found. -# Example: branch "branding/redesign" → "Utveckling: branding/redesign" +# Example: branch "branding/redesign" → "Development: branding/redesign" git_default_prefix = "Utveckling" # Taskwarrior filter tokens prepended before `status:pending export`. @@ -66,6 +59,32 @@ task_filter = "" # When true (default), the timer grows large when running and shrinks when stopped. # Set to false to keep the timer at a fixed (normal) size at all times. auto_resize_timer = true + +# Entry templates — pre-fill project, activity and note from a picker (press T). +# [[template]] sections can be repeated. +[[template]] +description = "My project" +project = "My Project" +activity = "Development" +note = "Working on stuff" +``` + +### Entry templates + +Define reusable presets in `config.toml`. In the timer view, press `T` to open the template picker and select one to pre-fill the current entry. + +### Environment variables + +Environment variables override values from `config.toml`. + +- Prefix: `TOKI_TUI_` +- Key format: uppercase snake case + +```bash +TOKI_TUI_API_URL="http://localhost:8080" +TOKI_TUI_GIT_DEFAULT_PREFIX="Development" +TOKI_TUI_TASK_FILTER="+work project:Toki" +TOKI_TUI_AUTO_RESIZE_TIMER=true ``` ### Example: local dev setup @@ -76,18 +95,71 @@ git_default_prefix = "Development" task_filter = "+work" ``` -## Standard key bindings - -| Key | Action | -| -------------- | ------------------ | -| `Space` | Start / stop timer | -| `Ctrl+S` | Save (options) | -| `Ctrl+X` | Clear | -| `Tab / ↑↓ / j/k` | Navigate | -| `H` | History view | -| `P` | Project | -| `N` | Note | -| `T` | Toggle timer size | -| `S` | Stats | -| `Esc` | Exit / cancel | -| `Q` | Quit | +## Log notes + +Attach a freeform markdown log file to any time entry. Log files are stored in `~/.local/share/toki-tui/logs/` and linked to entries via a tag embedded in the note (`[log:XXXXXX]`). The tag is hidden in all display locations — only the clean summary is shown. + +Run `just tui-logs` (or `toki-tui logs-path`) to print the log directory path. + +## Key bindings + +### Timer view + +| Key | Action | +| -------------------- | ----------------------------- | +| `Space` | Start / stop timer | +| `Ctrl+S` | Save (with options) | +| `Ctrl+R` | Resume last entry | +| `Ctrl+X` | Clear current entry | +| `Enter` | Edit description | +| `P` | Edit project / activity | +| `N` | Edit note (description editor) | +| `T` | Open template picker | +| `H` | Switch to history view | +| `S` | Switch to statistics view | +| `X` | Toggle timer size | +| `Z` | Zen mode (hide UI chrome) | +| `Tab / ↑↓ / j/k` | Navigate | +| `Q` | Quit | + +### Description editor (note / `N`) + +| Key | Action | +| -------------------- | ----------------------------- | +| `Ctrl+L` | Add / edit log file | +| `Ctrl+R` | Remove linked log file | +| `Ctrl+D` | Change working directory | +| `Ctrl+G` | Git: copy/paste branch or commit | +| `Ctrl+T` | Taskwarrior: pick a task | +| `Ctrl+X` | Clear note | +| `Ctrl+←/→` | Word-boundary navigation | +| `Ctrl+Backspace` | Delete word back | +| `Enter` | Confirm | +| `Esc` | Cancel | + +### History view + +| Key | Action | +| -------------------- | ----------------------------- | +| `↑↓` | Navigate entries | +| `Enter` | Edit entry | +| `Ctrl+R` | Resume entry (copy to timer) | +| `Ctrl+L` | Open linked log file | +| `H / Esc` | Back to timer view | +| `Q` | Quit | + +**While editing a history entry:** + +| Key | Action | +| -------------------- | ----------------------------- | +| `Tab` | Next field | +| `P / A` | Change project / activity | +| `Esc` | Save and exit edit mode | + +## Testing + +```bash +SQLX_OFFLINE=true cargo test -p toki-tui +``` + +Tests cover app and state behavior, text input helpers, runtime action handling, and focused Ratatui render assertions. diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index a2502052..d21bf371 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -13,7 +13,7 @@ impl App { let project_name = self.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = self.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = self.selected_activity.as_ref().map(|a| a.name.clone()); - let note = Some(self.description_input.value.clone()); + let note = Some(self.full_note_value()); self.create_edit_state( String::new(), // "" = running timer sentinel Some(start_time), @@ -403,6 +403,51 @@ impl App { } } + /// Move cursor left by one word in the Note field. + pub fn entry_edit_word_left(&mut self) { + let apply = |state: &mut EntryEditState| { + if state.focused_field == EntryEditField::Note { + state.note.move_word_left(); + } + }; + if let Some(s) = &mut self.this_week_edit_state { + apply(s); + } + if let Some(s) = &mut self.history_edit_state { + apply(s); + } + } + + /// Move cursor right by one word in the Note field. + pub fn entry_edit_word_right(&mut self) { + let apply = |state: &mut EntryEditState| { + if state.focused_field == EntryEditField::Note { + state.note.move_word_right(); + } + }; + if let Some(s) = &mut self.this_week_edit_state { + apply(s); + } + if let Some(s) = &mut self.history_edit_state { + apply(s); + } + } + + /// Delete the previous word in the Note field. + pub fn entry_edit_delete_word_back(&mut self) { + let apply = |state: &mut EntryEditState| { + if state.focused_field == EntryEditField::Note { + state.note.delete_word_back(); + } + }; + if let Some(s) = &mut self.this_week_edit_state { + apply(s); + } + if let Some(s) = &mut self.history_edit_state { + apply(s); + } + } + /// Clear the current time field for direct re-entry pub fn entry_edit_clear_time(&mut self) { if let Some(state) = &mut self.this_week_edit_state { @@ -641,18 +686,9 @@ impl App { project_id: entry.project_id.clone(), }); let note = entry.note.clone().unwrap_or_default(); - self.description_input = TextInput::from_str(¬e); + self.set_note_from_raw(¬e); self.description_is_default = false; } - - /// Copy project, activity, and note from a history entry into the running timer. - pub fn yank_entry_to_timer(&mut self, entry: &crate::types::TimeEntry) { - self.copy_entry_fields(entry); - self.set_status(format!( - "Copied: {}: {}", - entry.project_name, entry.activity_name - )); - } } /// Given an entry's optional start/end times, date string (YYYY-MM-DD), and hours, diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 4f940750..19f38d04 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -113,6 +113,27 @@ pub struct App { pub task_filter: String, pub git_default_prefix: String, pub auto_resize_timer: bool, + + // Templates + pub templates: Vec, + pub template_search_input: TextInput, + pub filtered_templates: Vec, + pub filtered_template_index: usize, + + /// Set to true after leaving/re-entering the alternate screen (e.g. after spawning an editor). + /// The event loop will call terminal.clear() to force a full redraw when this is true. + pub needs_full_redraw: bool, + + /// When a log note is linked to the current description, this holds the 8-char hex ID. + /// The `·log:` tag is stripped from `description_input` while editing so the user + /// sees (and edits) only the clean summary. The tag is re-appended when the editor + /// closes (Enter / Esc) or when `handle_open_log_note` reads the full note value. + pub description_log_id: Option, + + /// Cached content of the currently linked log file, refreshed whenever + /// `description_log_id` changes. Used by the render path to avoid per-frame + /// synchronous file I/O. + pub cached_log_content: Option, } impl App { @@ -177,6 +198,13 @@ impl App { task_filter: cfg.task_filter.clone(), git_default_prefix: cfg.git_default_prefix.clone(), auto_resize_timer: cfg.auto_resize_timer, + templates: cfg.template.clone(), + template_search_input: TextInput::new(), + filtered_templates: Vec::new(), + filtered_template_index: 0, + needs_full_redraw: false, + description_log_id: None, + cached_log_content: None, } } @@ -202,6 +230,8 @@ impl App { pub fn clear_note(&mut self) { self.description_input = TextInput::new(); self.description_is_default = true; + self.description_log_id = None; + self.cached_log_content = None; self.status_message = Some("Note cleared".to_string()); } @@ -214,6 +244,8 @@ impl App { self.selected_activity = None; self.description_input = TextInput::new(); self.description_is_default = true; + self.description_log_id = None; + self.cached_log_content = None; self.status_message = Some("Timer cleared".to_string()); } @@ -402,6 +434,13 @@ impl App { self.filter_activities(); self.selection_list_focused = false; } + View::SelectTemplate => { + self.template_search_input = TextInput::new(); + self.filtered_templates = self.templates.clone(); + self.filtered_template_index = 0; + self.selection_list_focused = false; + self.current_view = View::SelectTemplate; + } View::EditDescription => { if self.description_is_default && self.this_week_edit_state.is_none() @@ -410,6 +449,19 @@ impl App { self.description_input.clear(); self.description_is_default = false; } + // Strip the log tag from the editable buffer so the user sees only the + // clean summary. The ID is preserved in description_log_id and re-appended + // when the editor closes. + { + use crate::log_notes; + let raw = self.description_input.value.clone(); + if let Some(id) = log_notes::extract_id(&raw) { + self.description_log_id = Some(id.to_string()); + let stripped = log_notes::strip_tag(&raw).to_string(); + self.description_input = TextInput::from_str(&stripped); + self.refresh_log_cache(); + } + } self.editing_description = true; } View::Timer => { @@ -435,6 +487,12 @@ impl App { (self.filtered_activity_index + 1) % self.filtered_activities.len(); } } + View::SelectTemplate => { + if !self.filtered_templates.is_empty() { + self.filtered_template_index = + (self.filtered_template_index + 1) % self.filtered_templates.len(); + } + } View::History => { self.history_focus_down(); } @@ -463,6 +521,15 @@ impl App { }; } } + View::SelectTemplate => { + if !self.filtered_templates.is_empty() { + self.filtered_template_index = if self.filtered_template_index == 0 { + self.filtered_templates.len() - 1 + } else { + self.filtered_template_index - 1 + }; + } + } View::History => { self.history_focus_up(); } @@ -555,11 +622,66 @@ impl App { } } - /// Get current description for display + /// Get current description for display (clean summary, tag stripped) + #[allow(dead_code)] pub fn current_description(&self) -> String { self.description_input.value.clone() } + /// Returns the full note value: the clean summary from `description_input` with the + /// `·log:` tag re-appended if one is stored in `description_log_id`. + /// Use this instead of reading `description_input.value` directly whenever you need + /// the canonical note to save or sync. + pub fn full_note_value(&self) -> String { + use crate::log_notes; + match &self.description_log_id { + Some(id) => log_notes::append_tag(&self.description_input.value, id), + None => self.description_input.value.clone(), + } + } + + /// Restore the running timer's note from `saved_timer_note`, stripping any embedded + /// log tag into `description_log_id` so the invariant is maintained. + pub fn restore_saved_timer_note(&mut self) { + use crate::log_notes; + if let Some(saved) = self.saved_timer_note.take() { + if let Some(id) = log_notes::extract_id(&saved) { + self.description_log_id = Some(id.to_string()); + self.description_input = TextInput::from_str(log_notes::strip_tag(&saved)); + } else { + self.description_log_id = None; + self.description_input = TextInput::from_str(&saved); + } + self.refresh_log_cache(); + } + } + + /// Set the running timer's note from a raw string (which may contain a `·log:` tag). + /// Strips the tag into `description_log_id` to maintain the invariant that + /// `description_input` always holds only the clean summary. + pub fn set_note_from_raw(&mut self, raw: &str) { + use crate::log_notes; + if let Some(id) = log_notes::extract_id(raw) { + self.description_log_id = Some(id.to_string()); + self.description_input = TextInput::from_str(log_notes::strip_tag(raw)); + } else { + self.description_log_id = None; + self.description_input = TextInput::from_str(raw); + } + self.refresh_log_cache(); + } + + /// Refreshes `cached_log_content` from disk based on the current `description_log_id`. + /// Call this once whenever `description_log_id` is assigned. + pub fn refresh_log_cache(&mut self) { + use crate::log_notes; + self.cached_log_content = self + .description_log_id + .as_ref() + .and_then(|id| log_notes::log_path(id).ok()) + .and_then(|path| std::fs::read_to_string(path).ok()); + } + /// Handle character input for description editing pub fn input_char(&mut self, c: char) { if self.editing_description { @@ -594,6 +716,24 @@ impl App { } } + pub fn input_word_left(&mut self) { + if self.editing_description { + self.description_input.move_word_left(); + } + } + + pub fn input_word_right(&mut self) { + if self.editing_description { + self.description_input.move_word_right(); + } + } + + pub fn input_delete_word_back(&mut self) { + if self.editing_description { + self.description_input.delete_word_back(); + } + } + /// Confirm description edit pub fn confirm_description(&mut self) { self.editing_description = false; @@ -689,6 +829,27 @@ impl App { self.filter_activities(); } + pub fn filter_templates(&mut self) { + let query = &self.template_search_input.value; + if query.is_empty() { + self.filtered_templates = self.templates.clone(); + } else { + let matcher = SkimMatcherV2::default(); + let mut scored: Vec<_> = self + .templates + .iter() + .filter_map(|t| { + matcher + .fuzzy_match(&t.description, query) + .map(|score| (score, t.clone())) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0)); + self.filtered_templates = scored.into_iter().map(|(_, t)| t).collect(); + } + self.filtered_template_index = 0; + } + pub fn activity_search_input_backspace(&mut self) { self.activity_search_input.backspace(); self.filter_activities(); @@ -699,6 +860,50 @@ impl App { self.filter_activities(); } + pub fn template_search_input_char(&mut self, c: char) { + self.template_search_input.insert(c); + self.filter_templates(); + } + + pub fn template_search_input_backspace(&mut self) { + self.template_search_input.backspace(); + self.filter_templates(); + } + + pub fn template_search_input_clear(&mut self) { + self.template_search_input.clear(); + self.filter_templates(); + } + + pub fn template_search_move_cursor(&mut self, left: bool) { + if left { + self.template_search_input.move_left(); + } else { + self.template_search_input.move_right(); + } + } + + pub fn template_search_cursor_home_end(&mut self, home: bool) { + if home { + self.template_search_input.home(); + } else { + self.template_search_input.end(); + } + } + + pub fn template_search_word_left(&mut self) { + self.template_search_input.move_word_left(); + } + + pub fn template_search_word_right(&mut self) { + self.template_search_input.move_word_right(); + } + + pub fn template_search_delete_word_back(&mut self) { + self.template_search_input.delete_word_back(); + self.filter_templates(); + } + pub fn search_move_cursor(&mut self, left: bool) { if left { self.project_search_input.move_left(); @@ -715,6 +920,19 @@ impl App { } } + pub fn search_word_left(&mut self) { + self.project_search_input.move_word_left(); + } + + pub fn search_word_right(&mut self) { + self.project_search_input.move_word_right(); + } + + pub fn search_delete_word_back(&mut self) { + self.project_search_input.delete_word_back(); + self.filter_projects(); + } + pub fn activity_search_move_cursor(&mut self, left: bool) { if left { self.activity_search_input.move_left(); @@ -731,6 +949,19 @@ impl App { } } + pub fn activity_search_word_left(&mut self) { + self.activity_search_input.move_word_left(); + } + + pub fn activity_search_word_right(&mut self) { + self.activity_search_input.move_word_right(); + } + + pub fn activity_search_delete_word_back(&mut self) { + self.activity_search_input.delete_word_back(); + self.filter_activities(); + } + pub fn select_next_save_action(&mut self) { self.selected_save_action = match self.selected_save_action { SaveAction::SaveAndStop => SaveAction::ContinueNewProject, @@ -954,6 +1185,25 @@ impl App { } } + pub fn cwd_word_left(&mut self) { + if let Some(ref mut ti) = self.cwd_input { + ti.move_word_left(); + } + } + + pub fn cwd_word_right(&mut self) { + if let Some(ref mut ti) = self.cwd_input { + ti.move_word_right(); + } + } + + pub fn cwd_delete_word_back(&mut self) { + if let Some(ref mut ti) = self.cwd_input { + ti.delete_word_back(); + self.cwd_completions.clear(); + } + } + pub fn open_taskwarrior_overlay(&mut self) { let mut cmd = std::process::Command::new("task"); cmd.arg("rc.verbose=nothing"); @@ -1086,3 +1336,177 @@ fn longest_common_prefix(strings: &[String]) -> String { prefix.into_iter().collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{activity, project, test_app, time_entry}; + + #[test] + fn start_timer_sets_running_state_and_shifts_focus() { + let mut app = test_app(); + app.focused_this_week_index = Some(2); + + app.start_timer(false); + + assert_eq!(app.timer_state, TimerState::Running); + assert!(app.absolute_start.is_some()); + assert!(app.local_start.is_some()); + assert_eq!(app.focused_this_week_index, Some(3)); + } + + #[test] + fn stop_timer_clears_running_state() { + let mut app = test_app(); + app.focused_this_week_index = Some(2); + app.start_timer(false); + + app.stop_timer(false); + + assert_eq!(app.timer_state, TimerState::Stopped); + assert!(app.absolute_start.is_none()); + assert!(app.local_start.is_none()); + assert_eq!(app.focused_this_week_index, Some(2)); + } + + #[test] + fn clear_timer_resets_selected_fields_and_note() { + let mut app = test_app(); + app.timer_size = TimerSize::Large; + app.selected_project = Some(project("proj-1", "Project One")); + app.selected_activity = Some(activity("act-1", "proj-1", "Activity One")); + app.description_input = TextInput::from_str("Existing note"); + app.description_is_default = false; + + app.clear_timer(); + + assert_eq!(app.timer_state, TimerState::Stopped); + assert_eq!(app.timer_size, TimerSize::Normal); + assert!(app.selected_project.is_none()); + assert!(app.selected_activity.is_none()); + assert_eq!(app.description_input, TextInput::new()); + assert!(app.description_is_default); + } + + #[test] + fn navigate_to_edit_description_clears_default_note() { + let mut app = test_app(); + app.description_input = TextInput::from_str("Prefill"); + app.description_is_default = true; + + app.navigate_to(View::EditDescription); + + assert_eq!(app.current_view, View::EditDescription); + assert!(app.editing_description); + assert_eq!(app.description_input, TextInput::new()); + assert!(!app.description_is_default); + } + + #[test] + fn select_save_action_by_number_ignores_unknown_values() { + let mut app = test_app(); + app.selected_save_action = SaveAction::ContinueSameProject; + + app.select_save_action_by_number(9); + + assert_eq!(app.selected_save_action, SaveAction::ContinueSameProject); + } + + #[test] + fn filter_projects_orders_best_match_first() { + let mut app = test_app(); + app.projects = vec![ + project("proj-1", "Backend Platform"), + project("proj-2", "Timer UI"), + project("proj-3", "Documentation"), + ]; + app.project_search_input = TextInput::from_str("tmr"); + + app.filter_projects(); + + assert_eq!( + app.filtered_projects.first().map(|p| p.name.as_str()), + Some("Timer UI") + ); + } + + #[test] + fn filter_activities_respects_selected_project() { + let mut app = test_app(); + app.selected_project = Some(project("proj-2", "Timer UI")); + app.activities = vec![ + activity("act-1", "proj-1", "Planning"), + activity("act-2", "proj-2", "Implementation"), + activity("act-3", "proj-2", "Testing"), + ]; + + app.filter_activities(); + + assert_eq!(app.filtered_activities.len(), 2); + assert!(app + .filtered_activities + .iter() + .all(|activity| activity.project_id == "proj-2")); + } + + #[test] + fn parse_task_export_rejects_invalid_utf8_or_json() { + let utf8_err = parse_task_export(&[0xff]).expect_err("invalid UTF-8 should fail"); + let json_err = parse_task_export(b"not-json").expect_err("invalid JSON should fail"); + + assert!(utf8_err.contains("UTF-8")); + assert!(json_err.contains("JSON")); + } + + #[test] + fn parse_task_export_sorts_by_urgency_desc() { + let output = br#"[ + {"id": 2, "description": "Lower", "urgency": 1.0}, + {"id": 1, "description": "Higher", "urgency": 9.5}, + {"id": 3, "description": "Medium", "urgency": 3.2} + ]"#; + + let tasks = parse_task_export(output).expect("valid export should parse"); + + let descriptions: Vec<&str> = tasks.iter().map(|task| task.description.as_str()).collect(); + assert_eq!(descriptions, vec!["Higher", "Medium", "Lower"]); + } + + #[test] + fn update_history_sorts_entries_newest_first() { + let mut app = test_app(); + let early = time_entry( + "reg-1", + "proj-1", + "Project One", + "act-1", + "Activity One", + "2026-03-01", + 1.0, + None, + None, + None, + ); + let late = time_entry( + "reg-2", + "proj-1", + "Project One", + "act-1", + "Activity One", + "2026-03-02", + 1.0, + None, + None, + None, + ); + + app.update_history(vec![early, late]); + + let ids: Vec<&str> = app + .time_entries + .iter() + .map(|entry| entry.registration_id.as_str()) + .collect(); + assert_eq!(ids, vec!["reg-2", "reg-1"]); + } +} diff --git a/toki-tui/src/app/state.rs b/toki-tui/src/app/state.rs index c5ab2eda..b0472c0b 100644 --- a/toki-tui/src/app/state.rs +++ b/toki-tui/src/app/state.rs @@ -10,6 +10,7 @@ pub enum View { History, SelectProject, SelectActivity, + SelectTemplate, EditDescription, SaveAction, Statistics, @@ -209,6 +210,94 @@ impl TextInput { self.cursor = self.value.len(); } + /// Move cursor left by one whitespace-delimited word (bash/readline style). + /// Skips whitespace leftward, then skips non-whitespace leftward. + pub fn move_word_left(&mut self) { + if self.cursor == 0 { + return; + } + // Step 1: skip whitespace to the left + let mut p = self.cursor; + while p > 0 { + let prev = self.prev_boundary(p); + if self.value[prev..p] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + p = prev; + } else { + break; + } + } + // Step 2: skip non-whitespace to the left + while p > 0 { + let prev = self.prev_boundary(p); + if self.value[prev..p] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + break; + } else { + p = prev; + } + } + self.cursor = p; + } + + /// Move cursor right by one whitespace-delimited word (bash/readline style). + /// Skips non-whitespace rightward, then skips whitespace rightward. + pub fn move_word_right(&mut self) { + let len = self.value.len(); + if self.cursor >= len { + return; + } + // Step 1: skip non-whitespace to the right + let mut p = self.cursor; + while p < len { + let next = self.next_boundary(p); + if self.value[p..next] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + break; + } else { + p = next; + } + } + // Step 2: skip whitespace to the right + while p < len { + let next = self.next_boundary(p); + if self.value[p..next] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + p = next; + } else { + break; + } + } + self.cursor = p; + } + + /// Delete the word immediately before the cursor (Alt+Backspace / readline kill-word-back). + /// Equivalent to move_word_left then delete from new position to old cursor. + pub fn delete_word_back(&mut self) { + if self.cursor == 0 { + return; + } + let old_cursor = self.cursor; + self.move_word_left(); + self.value.drain(self.cursor..old_cursor); + } + pub fn clear(&mut self) { self.value.clear(); self.cursor = 0; @@ -285,3 +374,98 @@ pub enum MilltimeReauthField { } // Keep Instant re-exported so App struct can use it without needing to import state internals + +#[cfg(test)] +mod tests { + use super::TextInput; + + #[test] + fn text_input_inserts_and_backspaces_at_utf8_boundaries() { + let mut input = TextInput::from_str("a"); + input.insert('e'); + input.insert('\u{301}'); + + assert_eq!(input.value, "ae\u{301}"); + + input.backspace(); + assert_eq!(input.value, "ae"); + + input.backspace(); + assert_eq!(input.value, "a"); + } + + #[test] + fn text_input_moves_cursor_left_and_right_by_char() { + let mut input = TextInput::from_str("a😀b"); + + input.move_left(); + assert_eq!(input.cursor, "a😀".len()); + + input.move_left(); + assert_eq!(input.cursor, "a".len()); + + input.move_right(); + assert_eq!(input.cursor, "a😀".len()); + + input.move_right(); + assert_eq!(input.cursor, "a😀b".len()); + } + + #[test] + fn text_input_move_word_left_basic() { + let mut ti = TextInput::from_str("hello world foo"); + // cursor at end (15) + ti.move_word_left(); // skip 0 whitespace, skip "foo" → cursor at 12 + assert_eq!(ti.cursor, 12); + ti.move_word_left(); // skip 1 space, skip "world" → cursor at 6 + assert_eq!(ti.cursor, 6); + ti.move_word_left(); // skip 1 space, skip "hello" → cursor at 0 + assert_eq!(ti.cursor, 0); + ti.move_word_left(); // at start, no-op + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_move_word_left_from_middle_of_word() { + let mut ti = TextInput::from_str("hello world"); + ti.cursor = 8; // inside "world" at byte 8 (w=6,o=7,r=8) + ti.move_word_left(); // no leading whitespace, skip non-ws back to 6 + assert_eq!(ti.cursor, 6); + } + + #[test] + fn text_input_move_word_right_basic() { + let mut ti = TextInput::from_str("hello world foo"); + ti.cursor = 0; + ti.move_word_right(); // skip "hello" (5), skip " " (1) → cursor at 6 + assert_eq!(ti.cursor, 6); + ti.move_word_right(); // skip "world" (5), skip " " (1) → cursor at 12 + assert_eq!(ti.cursor, 12); + ti.move_word_right(); // skip "foo" (3), no trailing ws → cursor at 15 + assert_eq!(ti.cursor, 15); + ti.move_word_right(); // at end, no-op + assert_eq!(ti.cursor, 15); + } + + #[test] + fn text_input_delete_word_back_basic() { + let mut ti = TextInput::from_str("hello world"); + // cursor at end (11): skip 0 ws, skip "world" (5) back to 6. drain [6..11]. + ti.delete_word_back(); + assert_eq!(ti.value, "hello "); + assert_eq!(ti.cursor, 6); + // now skip " " (1 ws), skip "hello" (5) → cursor 0. drain [0..6]. + ti.delete_word_back(); + assert_eq!(ti.value, ""); + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_delete_word_back_at_start() { + let mut ti = TextInput::from_str("hello"); + ti.cursor = 0; + ti.delete_word_back(); // no-op + assert_eq!(ti.value, "hello"); + assert_eq!(ti.cursor, 0); + } +} diff --git a/toki-tui/src/cli.rs b/toki-tui/src/cli.rs index ed204cb3..1091afd0 100644 --- a/toki-tui/src/cli.rs +++ b/toki-tui/src/cli.rs @@ -20,6 +20,8 @@ pub enum Commands { Logout, /// Print config path and create default file if missing ConfigPath, + /// Print the log notes directory path + LogsPath, /// Print the current version Version, /// Show current login and Milltime session status diff --git a/toki-tui/src/config.rs b/toki-tui/src/config.rs index fa2c995f..806195a8 100644 --- a/toki-tui/src/config.rs +++ b/toki-tui/src/config.rs @@ -2,6 +2,14 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TemplateConfig { + pub description: String, + pub project: String, + pub activity: String, + pub note: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokiConfig { /// URL of the toki-api server. Defaults to the production instance. @@ -19,6 +27,9 @@ pub struct TokiConfig { /// and back to Normal when stopped. Default: true. #[serde(default = "default_auto_resize_timer")] pub auto_resize_timer: bool, + /// Named presets of (project, activity, note) applied via the template picker. + #[serde(default)] + pub template: Vec, } fn default_api_url() -> String { @@ -40,6 +51,7 @@ impl Default for TokiConfig { task_filter: String::new(), git_default_prefix: default_git_prefix(), auto_resize_timer: default_auto_resize_timer(), + template: Vec::new(), } } } diff --git a/toki-tui/src/editor.rs b/toki-tui/src/editor.rs new file mode 100644 index 00000000..9d84d8f3 --- /dev/null +++ b/toki-tui/src/editor.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::path::Path; + +/// Suspend the TUI, open $EDITOR on `path`, then restore the TUI. +/// Returns Ok(()) on success. The file is NOT read back here — caller reads it. +pub async fn open_editor(path: &Path) -> Result<()> { + let editor = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "nano".to_string()); + + // Split "program arg1 arg2" on whitespace (no quote handling needed for typical $EDITOR values). + let mut parts = editor.split_whitespace(); + let program = parts.next().unwrap_or("nano"); + let args: Vec<&str> = parts.collect(); + + // Leave TUI + disable_raw_mode()?; + execute!(std::io::stdout(), LeaveAlternateScreen)?; + + // Spawn editor and wait; capture result so TUI is always restored below. + let status_res = tokio::process::Command::new(program) + .args(&args) + .arg(path) + .status() + .await; + + // Re-enter TUI — always attempt restoration even if the editor failed. + // If restoration itself fails, combine with any prior editor error. + let restore_res = enable_raw_mode() + .and_then(|_| execute!(std::io::stdout(), EnterAlternateScreen)); + + // Prefer the original editor error; surface restoration error only if no prior error. + let status = match (status_res, restore_res) { + (Err(editor_err), _) => return Err(editor_err.into()), + (Ok(_), Err(restore_err)) => return Err(restore_err.into()), + (Ok(status), Ok(_)) => status, + }; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status: {}", status); + } + + Ok(()) +} diff --git a/toki-tui/src/log_notes.rs b/toki-tui/src/log_notes.rs new file mode 100644 index 00000000..dcfcd061 --- /dev/null +++ b/toki-tui/src/log_notes.rs @@ -0,0 +1,134 @@ +use std::path::PathBuf; + +const TAG_PREFIX: &str = " [log:"; +const TAG_SUFFIX: &str = "]"; + +/// Returns the log storage directory: ~/.local/share/toki-tui/logs/ +pub fn log_dir() -> anyhow::Result { + let dir = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine local data directory"))? + .join("toki-tui") + .join("logs"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Returns the path for a given log ID. +/// Returns an error if `id` contains non-lowercase-hex characters (prevents path traversal). +pub fn log_path(id: &str) -> anyhow::Result { + if id.is_empty() + || !id + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')) + { + anyhow::bail!("Invalid log id: must be lowercase hex characters only"); + } + Ok(log_dir()?.join(format!("{}.md", id))) +} + +/// Derives a 6-character lowercase hex ID from the current timestamp and a +/// process-local monotonic counter. Not cryptographically random — intended +/// only to be unique enough for thousands of personal log files. +pub fn generate_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let nanos = now.subsec_nanos(); + let secs = now.as_secs(); + let hash = (secs ^ (nanos as u64) ^ seq).wrapping_mul(0x9e3779b97f4a7c15); + format!("{:06x}", hash & 0xffffff) +} + +/// Extracts the log ID from a note string, if the tag is present. +/// e.g. "Fixed auth bug [log:a3f8b2]" → Some("a3f8b2") +pub fn extract_id(note: &str) -> Option<&str> { + let pos = note.find(TAG_PREFIX)?; + let after = ¬e[pos + TAG_PREFIX.len()..]; + // ID is exactly 6 hex chars followed by ']' + if after.len() >= 7 + && after[..6].chars().all(|c| c.is_ascii_hexdigit()) + && after.as_bytes()[6] == b']' + { + Some(&after[..6]) + } else { + None + } +} + +/// Strips the log tag from a note for display purposes. +pub fn strip_tag(note: &str) -> &str { + if let Some(pos) = note.find(TAG_PREFIX) { + note[..pos].trim_end() + } else { + note + } +} + +/// Appends a log tag to a note string (returns new String). +pub fn append_tag(note: &str, id: &str) -> String { + format!("{}{}{}{}", note.trim_end(), TAG_PREFIX, id, TAG_SUFFIX) +} + +/// Writes the initial log file with YAML frontmatter. +/// Only `id` and `date` are stored — project/activity/note are not tracked +/// since they can change after creation and would quickly go stale. +pub fn create_log_file(id: &str, date: &str) -> anyhow::Result { + let path = log_path(id)?; + if !path.exists() { + let content = format!("---\nid: {}\ndate: {}\n---\n\n", id, date); + std::fs::write(&path, content)?; + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_id_present() { + let note = "Fixed auth bug [log:a3f8b2]"; + assert_eq!(extract_id(note), Some("a3f8b2")); + } + + #[test] + fn test_extract_id_absent() { + assert_eq!(extract_id("No log here"), None); + } + + #[test] + fn test_strip_tag() { + let note = "Fixed auth bug [log:a3f8b2]"; + assert_eq!(strip_tag(note), "Fixed auth bug"); + } + + #[test] + fn test_strip_tag_no_tag() { + assert_eq!(strip_tag("Plain note"), "Plain note"); + } + + #[test] + fn test_append_tag() { + let result = append_tag("Fixed auth bug", "a3f8b2"); + assert_eq!(result, "Fixed auth bug [log:a3f8b2]"); + } + + #[test] + fn test_append_tag_trims_trailing_space() { + let result = append_tag("Fixed auth bug ", "a3f8b2"); + assert_eq!(result, "Fixed auth bug [log:a3f8b2]"); + } + + #[test] + fn test_generate_id_length() { + let id = generate_id(); + assert_eq!(id.len(), 6); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/toki-tui/src/main.rs b/toki-tui/src/main.rs index fca00b82..3db6c5b2 100644 --- a/toki-tui/src/main.rs +++ b/toki-tui/src/main.rs @@ -3,11 +3,15 @@ mod app; mod bootstrap; mod cli; mod config; +mod editor; mod git; +mod log_notes; mod login; mod runtime; mod session_store; mod terminal; +#[cfg(test)] +mod test_support; mod time_utils; mod types; mod ui; @@ -28,6 +32,10 @@ async fn main() -> Result<()> { let path = config::TokiConfig::ensure_exists()?; println!("{}", path.display()); } + Commands::LogsPath => { + let path = log_notes::log_dir()?; + println!("{}", path.display()); + } Commands::Version => { println!("{}", env!("CARGO_PKG_VERSION")); } diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs index e55a88fe..a41480f0 100644 --- a/toki-tui/src/runtime/action_queue.rs +++ b/toki-tui/src/runtime/action_queue.rs @@ -28,8 +28,12 @@ pub(super) enum Action { ConfirmDelete, StopServerTimerAndClear, RefreshHistoryBackground, - YankEntryToTimer(TimeEntry), ResumeEntry(TimeEntry), + ApplyTemplate { + template: crate::config::TemplateConfig, + }, + OpenLogNote, + OpenEntryLogNote(String), } pub(super) type ActionTx = UnboundedSender; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index f56021f0..79c3d15c 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -1,5 +1,5 @@ use crate::api::{ApiClient, SaveTimerRequest}; -use crate::app::{self, App, TextInput}; +use crate::app::{self, App}; use crate::types; use anyhow::{Context, Result}; use std::time::{Duration, Instant}; @@ -30,7 +30,7 @@ pub(crate) fn restore_active_timer(app: &mut App, timer: crate::types::ActiveTim }); } if !timer.note.is_empty() { - app.description_input = app::TextInput::from_str(&timer.note); + app.set_note_from_raw(&timer.note); app.description_is_default = false; } } @@ -118,12 +118,20 @@ pub(super) async fn run_action( Action::RefreshHistoryBackground => { refresh_history_background(app, client).await; } - Action::YankEntryToTimer(entry) => { - yank_entry_to_timer(entry, app, client).await; - } Action::ResumeEntry(entry) => { resume_entry(entry, app, client).await; } + Action::ApplyTemplate { template } => { + handle_apply_template(template, app, client).await?; + } + Action::OpenLogNote => { + if let Err(e) = handle_open_log_note(app, client).await { + app.set_status(format!("Log note error: {}", e)); + } + } + Action::OpenEntryLogNote(id) => { + handle_open_entry_log_note(&id, app).await; + } } Ok(()) } @@ -135,10 +143,9 @@ pub(super) async fn handle_start_timer(app: &mut App, client: &mut ApiClient) -> let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) + let note = { + let full = app.full_note_value(); + if full.is_empty() { None } else { Some(full) } }; if let Err(e) = client .start_timer(project_id, project_name, activity_id, activity_name, note) @@ -285,6 +292,77 @@ async fn sync_running_timer_note(note: String, app: &mut App, client: &mut ApiCl } } +async fn handle_apply_template( + template: crate::config::TemplateConfig, + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + // Find project by name (case-insensitive) + let project = app + .projects + .iter() + .find(|p| p.name.eq_ignore_ascii_case(&template.project)) + .cloned(); + + let Some(project) = project else { + // Project not found — tell the user and navigate back + app.set_status(format!( + "Project '{}' not found — template not applied", + template.project + )); + app.navigate_to(app::View::Timer); + return Ok(()); + }; + + app.selected_project = Some(project.clone()); + + // Ensure activities are loaded for this project + ensure_activities_for_project(app, client, &project.id).await; + + // Find activity by name (case-insensitive) + let activity = app + .activity_cache + .get(&project.id) + .and_then(|acts| { + acts.iter() + .find(|a| a.name.eq_ignore_ascii_case(&template.activity)) + .cloned() + }); + + if let Some(activity) = activity { + app.selected_activity = Some(activity); + } else { + app.set_status(format!( + "Activity '{}' not found — skipped", + template.activity + )); + } + + // Set note + app.description_input = app::TextInput::from_str(&template.note); + app.description_is_default = template.note.is_empty(); + + // Navigate back to timer + app.navigate_to(app::View::Timer); + + // If timer is running, sync to server + if app.timer_state == app::TimerState::Running { + let note = app.full_note_value(); + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); + let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); + let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); + let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); + if let Err(e) = client + .update_active_timer(project_id, project_name, activity_id, activity_name, Some(note), None) + .await + { + app.set_status(format!("Warning: Could not sync template to server: {}", e)); + } + } + + Ok(()) +} + async fn load_history_and_open(app: &mut App, client: &mut ApiClient) { match fetch_recent_history(client).await { Ok(entries) => { @@ -339,20 +417,18 @@ async fn refresh_history_background(app: &mut App, client: &mut ApiClient) { } } -async fn yank_entry_to_timer(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) { - // Apply locally first so the UI updates immediately - app.yank_entry_to_timer(&entry); - - // Sync the new project/activity/note to the server so save works correctly +async fn resume_entry(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) { if app.timer_state == app::TimerState::Running { + // Timer already running — copy fields and sync to server (yank behaviour) + app.copy_entry_fields(&entry); + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) + let note = { + let full = app.full_note_value(); + if full.is_empty() { None } else { Some(full) } }; if let Err(e) = client .update_active_timer( @@ -366,14 +442,9 @@ async fn yank_entry_to_timer(entry: types::TimeEntry, app: &mut App, client: &mu .await { app.set_status(format!("Warning: Could not sync copied entry to server: {}", e)); + } else { + app.set_status(format!("Copied: {}: {}", entry.project_name, entry.activity_name)); } - } -} - -async fn resume_entry(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) { - // Guard: should not be called while running, but be safe - if app.timer_state == app::TimerState::Running { - app.set_status("Timer already running — stop it first (Space or Ctrl+X)".to_string()); return; } @@ -419,10 +490,9 @@ pub(super) async fn handle_save_timer_with_action( } let duration = app.elapsed_duration(); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) + let note = { + let full = app.full_note_value(); + if full.is_empty() { None } else { Some(full) } }; let project_display = app.current_project_name(); @@ -562,10 +632,13 @@ pub(super) fn handle_entry_edit_enter(app: &mut App, action_tx: &ActionTx) { let _ = action_tx.send(Action::OpenEditActivityPicker { project_id }); } EditEnterAction::NoteEditor { note } => { - // Save running timer's note before overwriting with entry's note - app.saved_timer_note = Some(app.description_input.value.clone()); - // Set description_input from the edit state before navigating - app.description_input = TextInput::from_str(¬e); + // Save running timer's full note (including any log tag) before overwriting + // with the entry's note. On return, this will be restored to description_input + // and navigate_to(EditDescription) will re-strip the tag if present. + app.saved_timer_note = Some(app.full_note_value()); + // Load the entry's note, stripping any embedded log tag into description_log_id. + // This prevents the running timer's log from leaking into the entry's Notes view. + app.set_note_from_raw(¬e); // Open description editor app.navigate_to(app::View::EditDescription); } @@ -666,7 +739,7 @@ async fn handle_running_timer_edit_save(app: &mut App, client: &mut ApiClient) { name, project_id: String::new(), }); - app.description_input = TextInput::from_str(&state.note.value); + app.set_note_from_raw(&state.note.value); app.set_status("Running timer updated".to_string()); @@ -675,10 +748,11 @@ async fn handle_running_timer_edit_save(app: &mut App, client: &mut ApiClient) { let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { + let full_note = app.full_note_value(); + let note = if full_note.is_empty() { None } else { - Some(app.description_input.value.clone()) + Some(full_note) }; if let Err(e) = client .update_active_timer( @@ -852,3 +926,224 @@ pub(super) fn is_milltime_auth_error(e: &anyhow::Error) -> bool { let msg = e.to_string().to_lowercase(); msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") } + +/// Open an existing log file for a history/today entry. +/// Takes a pre-extracted log ID (may be empty if the entry has no log tag). +/// Does NOT create a new log file and does NOT mutate running-timer state. +pub(super) async fn handle_open_entry_log_note(id: &str, app: &mut App) { + use crate::log_notes; + + if id.is_empty() { + app.set_status("No log linked to this entry".to_string()); + return; + } + + let path = match log_notes::log_path(id) { + Ok(p) => p, + Err(e) => { + app.set_status(format!("Log error: {}", e)); + return; + } + }; + + if !path.exists() { + app.set_status("Log file not found".to_string()); + return; + } + + if let Err(e) = crate::editor::open_editor(&path).await { + app.set_status(format!("Editor error: {}", e)); + return; + } + + app.needs_full_redraw = true; +} + +async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow::Result<()> { + use crate::log_notes; + use time::OffsetDateTime; + + // The description editor strips the tag into `description_log_id` so + // `description_input.value` is always the clean summary. + let summary = app.description_input.value.clone(); + + // Determine or generate the log ID. Prefer the one already stored on App; + // fall back to generating a new one (first time Ctrl+L is pressed). + let id = app + .description_log_id + .clone() + .unwrap_or_else(log_notes::generate_id); + + // Date string + let today = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); + let date = format!( + "{:04}-{:02}-{:02}", + today.year(), + today.month() as u8, + today.day() + ); + + // Create log file if it doesn't exist yet + let log_path = log_notes::create_log_file(&id, &date)?; + + // Open the editor (suspends TUI) + crate::editor::open_editor(&log_path).await?; + + // Store the ID on App so subsequent Ctrl+L presses reuse it and the tag + // survives further editing. Refresh the cache so the render path sees the + // newly written file immediately. + app.description_log_id = Some(id.clone()); + app.refresh_log_cache(); + + // Build the full note value (summary + tag) to save/sync. + let new_note = log_notes::append_tag(&summary, &id); + + // description_input stays as the clean summary (tag lives in description_log_id). + app.description_is_default = false; + + // If in edit mode, also sync the edit state's note field so Enter saves it correctly. + if app.is_in_edit_mode() { + app.update_edit_state_note(new_note.clone()); + } + + // Signal the event loop to do a full terminal redraw after the editor exits + app.needs_full_redraw = true; + + // If timer is running AND we are NOT in edit mode, sync the updated note to the server. + // (In edit mode the note belongs to a history entry — it will be saved on Enter.) + if app.timer_state == app::TimerState::Running && !app.is_in_edit_mode() { + sync_running_timer_note(new_note, app, client).await; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::ApiClient; + use crate::app::{DeleteContext, DeleteOrigin, SaveAction, View}; + use crate::test_support::test_app; + use crate::types::ActiveTimerState; + use time::macros::datetime; + + #[test] + fn restore_active_timer_populates_local_app_state() { + let mut app = test_app(); + let timer = ActiveTimerState { + start_time: datetime!(2026-03-06 09:15 UTC), + project_id: Some("proj-1".to_string()), + project_name: Some("Project One".to_string()), + activity_id: Some("act-1".to_string()), + activity_name: Some("Activity One".to_string()), + note: "Investigate tests".to_string(), + hours: 1, + minutes: 2, + seconds: 3, + }; + + restore_active_timer(&mut app, timer); + + assert_eq!(app.timer_state, app::TimerState::Running); + assert_eq!(app.selected_project.as_ref().map(|p| p.id.as_str()), Some("proj-1")); + assert_eq!( + app.selected_activity.as_ref().map(|a| a.name.as_str()), + Some("Activity One") + ); + assert_eq!(app.description_input.value, "Investigate tests"); + assert!(!app.description_is_default); + assert_eq!(app.absolute_start, Some(datetime!(2026-03-06 09:15 UTC))); + assert!(app.local_start.is_some()); + } + + #[tokio::test] + async fn handle_start_timer_starts_timer_in_dev_mode() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + + handle_start_timer(&mut app, &mut client) + .await + .expect("start timer should succeed"); + + assert_eq!(app.timer_state, app::TimerState::Running); + assert!(app.absolute_start.is_some()); + assert!(app.local_start.is_some()); + assert!(app.status_message.is_none()); + } + + #[tokio::test] + async fn handle_save_timer_cancel_returns_to_timer_without_saving() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + app.current_view = View::SaveAction; + app.selected_save_action = SaveAction::Cancel; + app.timer_state = app::TimerState::Running; + + handle_save_timer_with_action(&mut app, &mut client) + .await + .expect("cancel should succeed"); + + assert_eq!(app.current_view, View::Timer); + assert_eq!(app.timer_state, app::TimerState::Running); + } + + #[tokio::test] + async fn open_entry_log_note_no_log_linked_sets_status() { + let mut app = test_app(); + // Empty id → sets "No log linked to this entry" + handle_open_entry_log_note("", &mut app).await; + assert!(app.status_message.as_deref().unwrap_or("").contains("No log")); + } + + #[tokio::test] + async fn open_entry_log_note_invalid_id_sets_status() { + let mut app = test_app(); + // Invalid id (non-hex) → log_path returns Err → sets "Log error: ..." + handle_open_entry_log_note("ZZZZZZ", &mut app).await; + assert!(app.status_message.as_deref().unwrap_or("").contains("Log error")); + } + + #[tokio::test] + async fn open_entry_log_note_missing_file_sets_status() { + let mut app = test_app(); + // Valid hex id but file doesn't exist → sets "Log file not found" + handle_open_entry_log_note("abcdef", &mut app).await; + assert_eq!( + app.status_message.as_deref(), + Some("Log file not found") + ); + } + + #[tokio::test] + async fn handle_confirm_delete_removes_entry_and_returns_to_origin_view() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + let today = time::OffsetDateTime::now_utc().date(); + let entries = client + .get_time_entries(today, today) + .await + .expect("history should load"); + let entry = entries.first().expect("seeded history entry").clone(); + + app.update_history(entries); + app.rebuild_history_list(); + app.current_view = View::ConfirmDelete; + app.delete_context = Some(DeleteContext { + registration_id: entry.registration_id.clone(), + display_label: format!("{} / {}", entry.project_name, entry.activity_name), + display_date: entry.date.clone(), + display_hours: entry.hours, + origin: DeleteOrigin::History, + }); + + handle_confirm_delete(&mut app, &mut client).await; + + assert_eq!(app.current_view, View::History); + assert!(app.status_message.is_none()); + assert!(app + .time_entries + .iter() + .all(|item| item.registration_id != entry.registration_id)); + assert!(app.delete_context.is_none()); + } +} diff --git a/toki-tui/src/runtime/event_loop.rs b/toki-tui/src/runtime/event_loop.rs index 6a373e70..669e4dcd 100644 --- a/toki-tui/src/runtime/event_loop.rs +++ b/toki-tui/src/runtime/event_loop.rs @@ -27,6 +27,13 @@ pub async fn run_app( let (action_tx, mut action_rx) = channel(); loop { + // Clear before drawing to avoid a flash when the screen needs a full repaint + // (e.g. after returning from an external editor or waking from sleep). + if app.needs_full_redraw { + terminal.clear()?; + app.needs_full_redraw = false; + } + terminal.draw(|f| ui::render(f, app))?; if app.is_loading { @@ -37,15 +44,22 @@ pub async fn run_app( } if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; + match event::read()? { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + if app.milltime_reauth.is_some() { + handle_milltime_reauth_key(key, app, &action_tx); + } else { + handle_view_key(key, app, &action_tx); + } } - if app.milltime_reauth.is_some() { - handle_milltime_reauth_key(key, app, &action_tx); - } else { - handle_view_key(key, app, &action_tx); + // Force a full redraw when the terminal regains focus (e.g. after sleep/wake) + Event::FocusGained => { + app.needs_full_redraw = true; } + _ => {} } } diff --git a/toki-tui/src/runtime/views.rs b/toki-tui/src/runtime/views.rs index 72ece672..5c496b5f 100644 --- a/toki-tui/src/runtime/views.rs +++ b/toki-tui/src/runtime/views.rs @@ -9,6 +9,7 @@ mod history; mod save_action; mod selection; mod statistics; +mod template_selection; mod timer; fn enqueue_action(action_tx: &ActionTx, action: Action) { @@ -41,6 +42,9 @@ pub(super) fn handle_view_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx match &app.current_view { app::View::SelectProject => selection::handle_select_project_key(key, app, action_tx), app::View::SelectActivity => selection::handle_select_activity_key(key, app, action_tx), + app::View::SelectTemplate => { + template_selection::handle_select_template_key(key, app, action_tx) + } app::View::EditDescription => { edit_description::handle_edit_description_key(key, app, action_tx) } diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs index f2872ce3..fd83cf5c 100644 --- a/toki-tui/src/runtime/views/edit_description.rs +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -1,4 +1,4 @@ -use crate::app::{self, App, TextInput}; +use crate::app::{self, App}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::super::action_queue::{Action, ActionTx}; @@ -17,8 +17,17 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } } KeyCode::Tab => app.cwd_tab_complete(), + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.cwd_delete_word_back(); + } KeyCode::Backspace => app.cwd_input_backspace(), + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.cwd_word_left(); + } KeyCode::Left => app.cwd_move_cursor(true), + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.cwd_word_right(); + } KeyCode::Right => app.cwd_move_cursor(false), KeyCode::Home => app.cwd_cursor_home_end(true), KeyCode::End => app.cwd_cursor_home_end(false), @@ -57,6 +66,8 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t KeyCode::Char('x') | KeyCode::Char('X') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Clear the note text only — the log link is preserved intentionally. + // Use Ctrl+R to remove the log link. app.description_input.clear(); } KeyCode::Char('g') | KeyCode::Char('G') @@ -75,20 +86,48 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t { app.open_taskwarrior_overlay(); } + KeyCode::Char('r') | KeyCode::Char('R') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + // Remove (detach) the linked log from the current note (orphans the file, keeps note text) + app.description_log_id = None; + app.cached_log_content = None; + app.status_message = Some("Log removed".to_string()); + } + KeyCode::Char('l') | KeyCode::Char('L') + if key.modifiers.contains(KeyModifiers::SHIFT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + // no-op: previously Shift+Ctrl+L was detach, now Ctrl+R handles this + } + KeyCode::Char('l') | KeyCode::Char('L') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + enqueue_action(action_tx, Action::OpenLogNote); + } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { app.input_char(c); } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.input_delete_word_back(); + } KeyCode::Backspace => app.input_backspace(), + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.input_word_left(); + } KeyCode::Left => app.input_move_cursor(true), + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.input_word_right(); + } KeyCode::Right => app.input_move_cursor(false), KeyCode::Home => app.input_cursor_home_end(true), KeyCode::End => app.input_cursor_home_end(false), KeyCode::Enter => { if was_in_edit_mode { - app.update_edit_state_note(app.description_input.value.clone()); - if let Some(saved_note) = app.saved_timer_note.take() { - app.description_input = TextInput::from_str(&saved_note); - } + let note = app.full_note_value(); + app.description_log_id = None; + app.update_edit_state_note(note); + app.restore_saved_timer_note(); let return_view = app.get_return_view_from_edit(); app.navigate_to(return_view); if return_view == app::View::Timer { @@ -96,7 +135,9 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } } else { let should_sync_running_note = app.timer_state == app::TimerState::Running; - let note = app.description_input.value.clone(); + let note = app.full_note_value(); + // description_log_id intentionally preserved — the log stays linked + // to the running timer after confirming. Use Ctrl+R to remove it. app.confirm_description(); if should_sync_running_note { enqueue_action(action_tx, Action::SyncRunningTimerNote { note }); @@ -105,15 +146,19 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } KeyCode::Esc => { if was_in_edit_mode { - if let Some(saved_note) = app.saved_timer_note.take() { - app.description_input = TextInput::from_str(&saved_note); - } + // Discarding the edit — drop the log ID (it belongs to the entry being + // edited, not to the running timer, and will be restored via saved_timer_note). + app.description_log_id = None; + app.restore_saved_timer_note(); let return_view = app.get_return_view_from_edit(); app.navigate_to(return_view); if return_view == app::View::Timer { app.focused_box = app::FocusedBox::Today; } } else { + // Cancelling description editing for the running timer — don't sync + // anything, just exit the editor. description_log_id stays intact so the + // log link is preserved for the next time the user opens the editor. app.cancel_selection(); } } @@ -130,7 +175,7 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t #[cfg(test)] mod tests { use super::*; - use crate::app::{EntryEditField, EntryEditState, TimerState, View}; + use crate::app::{EntryEditField, EntryEditState, TextInput, TimerState, View}; use crate::config::TokiConfig; use crossterm::event::{KeyEvent, KeyModifiers}; diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index 18a521fc..eb22409b 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -21,6 +21,17 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio KeyCode::Up | KeyCode::Char('k') => { app.entry_edit_prev_field(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app + .history_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) + { + app.entry_edit_word_right(); + } else { + app.entry_edit_next_field(); + } + } KeyCode::Right => { if app .history_edit_state @@ -32,9 +43,22 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio app.entry_edit_next_field(); } } - KeyCode::Char('l') | KeyCode::Char('L') => { + KeyCode::Char('l') | KeyCode::Char('L') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { app.entry_edit_next_field(); } + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app + .history_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) + { + app.entry_edit_word_left(); + } else { + app.entry_edit_prev_field(); + } + } KeyCode::Left => { if app .history_edit_state @@ -54,6 +78,9 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio KeyCode::Char(c) if c.is_ascii_digit() => { app.entry_edit_input_char(c); } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.entry_edit_delete_word_back(); + } KeyCode::Backspace => { app.entry_edit_backspace(); } @@ -126,38 +153,35 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio app.enter_delete_confirm(app::DeleteOrigin::History); } } - KeyCode::Char('y') | KeyCode::Char('Y') if app.focused_history_index.is_some() => { - if app.timer_state != app::TimerState::Running { - app.set_status( - "No running timer — use R to resume this entry instead".to_string(), - ); + KeyCode::Char('r') | KeyCode::Char('R') + if app.focused_history_index.is_some() + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + let entry = app + .focused_history_index + .and_then(|idx| app.history_list_entries.get(idx).copied()) + .and_then(|te_idx| app.time_entries.get(te_idx).cloned()); + if let Some(entry) = entry { + enqueue_action(action_tx, Action::ResumeEntry(entry)); } else { - let entry = app - .focused_history_index - .and_then(|idx| app.history_list_entries.get(idx).copied()) - .and_then(|te_idx| app.time_entries.get(te_idx).cloned()); - if let Some(entry) = entry { - enqueue_action(action_tx, Action::YankEntryToTimer(entry)); - } else { - app.set_status("Error: could not resolve selected entry".to_string()); - } + app.set_status("Error: could not resolve selected entry".to_string()); } } - KeyCode::Char('r') | KeyCode::Char('R') if app.focused_history_index.is_some() => { - if app.timer_state == app::TimerState::Running { - app.set_status( - "Timer already running — stop it first (Space or Ctrl+X)".to_string(), - ); + KeyCode::Char('l') | KeyCode::Char('L') + if app.focused_history_index.is_some() + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + let note = app + .focused_history_index + .and_then(|idx| app.history_list_entries.get(idx).copied()) + .and_then(|te_idx| app.time_entries.get(te_idx)) + .and_then(|e| e.note.as_deref()) + .unwrap_or(""); + let id = crate::log_notes::extract_id(note).unwrap_or("").to_string(); + if id.is_empty() { + app.set_status("No log linked to this entry".to_string()); } else { - let entry = app - .focused_history_index - .and_then(|idx| app.history_list_entries.get(idx).copied()) - .and_then(|te_idx| app.time_entries.get(te_idx).cloned()); - if let Some(entry) = entry { - enqueue_action(action_tx, Action::ResumeEntry(entry)); - } else { - app.set_status("Error: could not resolve selected entry".to_string()); - } + enqueue_action(action_tx, Action::OpenEntryLogNote(id)); } } _ => {} diff --git a/toki-tui/src/runtime/views/selection.rs b/toki-tui/src/runtime/views/selection.rs index 706ec152..f2c81487 100644 --- a/toki-tui/src/runtime/views/selection.rs +++ b/toki-tui/src/runtime/views/selection.rs @@ -21,6 +21,9 @@ pub(super) fn handle_select_project_key(key: KeyEvent, app: &mut App, action_tx: input_backspace: App::search_input_backspace, move_cursor: App::search_move_cursor, cursor_home_end: App::search_cursor_home_end, + word_left: App::search_word_left, + word_right: App::search_word_right, + delete_word_back: App::search_delete_word_back, }, ) { return; @@ -61,6 +64,9 @@ pub(super) fn handle_select_activity_key(key: KeyEvent, app: &mut App, action_tx input_backspace: App::activity_search_input_backspace, move_cursor: App::activity_search_move_cursor, cursor_home_end: App::activity_search_cursor_home_end, + word_left: App::activity_search_word_left, + word_right: App::activity_search_word_right, + delete_word_back: App::activity_search_delete_word_back, }, ) { return; @@ -84,7 +90,7 @@ pub(super) fn handle_select_activity_key(key: KeyEvent, app: &mut App, action_tx } } -fn handle_selection_input_key( +pub(super) fn handle_selection_input_key( key: KeyEvent, app: &mut App, list_index: usize, @@ -124,6 +130,10 @@ fn handle_selection_input_key( } true } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + (ops.delete_word_back)(app); + true + } KeyCode::Backspace => { (ops.input_backspace)(app); true @@ -144,12 +154,24 @@ fn handle_selection_input_key( } true } + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + (ops.word_left)(app); + } + true + } KeyCode::Left => { if !app.selection_list_focused { (ops.move_cursor)(app, true); } true } + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + (ops.word_right)(app); + } + true + } KeyCode::Right => { if !app.selection_list_focused { (ops.move_cursor)(app, false); @@ -173,10 +195,13 @@ fn handle_selection_input_key( } #[derive(Clone, Copy)] -struct SelectionInputOps { +pub(super) struct SelectionInputOps { clear_input: fn(&mut App), input_char: fn(&mut App, char), input_backspace: fn(&mut App), move_cursor: fn(&mut App, bool), cursor_home_end: fn(&mut App, bool), + word_left: fn(&mut App), + word_right: fn(&mut App), + delete_word_back: fn(&mut App), } diff --git a/toki-tui/src/runtime/views/template_selection.rs b/toki-tui/src/runtime/views/template_selection.rs new file mode 100644 index 00000000..8b9713c4 --- /dev/null +++ b/toki-tui/src/runtime/views/template_selection.rs @@ -0,0 +1,129 @@ +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::action_queue::{Action, ActionTx}; +use super::enqueue_action; + +pub(super) fn handle_select_template_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + if handle_template_input_key(key, app) { + return; + } + + match key.code { + KeyCode::Enter => { + if let Some(template) = app + .filtered_templates + .get(app.filtered_template_index) + .cloned() + { + enqueue_action(action_tx, Action::ApplyTemplate { template }); + } else { + app.navigate_to(crate::app::View::Timer); + } + } + KeyCode::Esc => app.navigate_to(crate::app::View::Timer), + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + _ => {} + } +} + +fn handle_template_input_key(key: KeyEvent, app: &mut App) -> bool { + let list_index = app.filtered_template_index; + let list_len = app.filtered_templates.len(); + + match key.code { + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.template_search_input_clear(); + true + } + KeyCode::Tab => { + app.selection_list_focused = true; + true + } + KeyCode::BackTab => { + app.selection_list_focused = false; + true + } + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) && c != 'q' && c != 'Q' => + { + if app.selection_list_focused && c == 'j' { + if list_index + 1 >= list_len { + app.selection_list_focused = false; + } else { + app.select_next(); + } + } else if app.selection_list_focused && c == 'k' { + if list_index == 0 { + app.selection_list_focused = false; + } else { + app.select_previous(); + } + } else if !app.selection_list_focused { + app.template_search_input_char(c); + } + true + } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.template_search_delete_word_back(); + true + } + KeyCode::Backspace => { + app.template_search_input_backspace(); + true + } + KeyCode::Up => { + if app.selection_list_focused && list_index == 0 { + app.selection_list_focused = false; + } else { + app.select_previous(); + } + true + } + KeyCode::Down => { + if app.selection_list_focused && list_index + 1 >= list_len { + app.selection_list_focused = false; + } else { + app.select_next(); + } + true + } + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + app.template_search_word_left(); + } + true + } + KeyCode::Left => { + if !app.selection_list_focused { + app.template_search_move_cursor(true); + } + true + } + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + app.template_search_word_right(); + } + true + } + KeyCode::Right => { + if !app.selection_list_focused { + app.template_search_move_cursor(false); + } + true + } + KeyCode::Home => { + if !app.selection_list_focused { + app.template_search_cursor_home_end(true); + } + true + } + KeyCode::End => { + if !app.selection_list_focused { + app.template_search_cursor_home_end(false); + } + true + } + _ => false, + } +} diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 1803b291..2d00e457 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -55,7 +55,9 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT app.focus_previous(); } } - KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => { + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { if is_editing_this_week(app) { app.entry_edit_next_field(); } @@ -89,6 +91,12 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT KeyCode::Char(c) if is_editing_this_week(app) && c.is_ascii_digit() => { app.entry_edit_input_char(c); } + KeyCode::Backspace + if key.modifiers.contains(KeyModifiers::ALT) + && is_note_focused_in_this_week_edit(app) => + { + app.entry_edit_delete_word_back(); + } KeyCode::Backspace => { if is_editing_this_week(app) { if !is_note_focused_in_this_week_edit(app) { @@ -122,7 +130,9 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT KeyCode::Char('n') | KeyCode::Char('N') => { app.navigate_to(app::View::EditDescription); } - KeyCode::Char('t') | KeyCode::Char('T') => { + KeyCode::Char('x') | KeyCode::Char('X') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { app.toggle_timer_size(); } // S: Open Stats view (unmodified only - Ctrl+S is save) @@ -142,32 +152,51 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT } } KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(), - KeyCode::Char('y') | KeyCode::Char('Y') if !is_editing_this_week(app) => { - if app.timer_state != app::TimerState::Running { - app.set_status("No running timer — use R to resume this entry instead".to_string()); - } else if is_persisted_today_row_selected(app) { + KeyCode::Char('r') | KeyCode::Char('R') + if !is_editing_this_week(app) && key.modifiers.contains(KeyModifiers::CONTROL) => + { + if is_persisted_today_row_selected(app) { let idx = app.focused_this_week_index.unwrap(); - let db_idx = idx.saturating_sub(1); // timer row at 0 shifts DB entries by 1 + // When running, index 0 is the running-timer row so DB entries are shifted by 1 + let db_idx = if app.timer_state == app::TimerState::Running { + idx.saturating_sub(1) + } else { + idx + }; let entry = app.this_week_history().get(db_idx).cloned().cloned(); if let Some(entry) = entry { - enqueue_action(action_tx, Action::YankEntryToTimer(entry)); + enqueue_action(action_tx, Action::ResumeEntry(entry)); } } } - KeyCode::Char('r') | KeyCode::Char('R') if !is_editing_this_week(app) => { - if app.timer_state == app::TimerState::Running { - app.set_status( - "Timer already running — stop it first (Space or Ctrl+X)".to_string(), - ); - } else if is_persisted_today_row_selected(app) { - let idx = app.focused_this_week_index.unwrap(); - // Timer is stopped: no running-timer row at index 0, so idx is the DB index directly - let entry = app.this_week_history().get(idx).cloned().cloned(); - if let Some(entry) = entry { - enqueue_action(action_tx, Action::ResumeEntry(entry)); - } + KeyCode::Char('l') | KeyCode::Char('L') + if !is_editing_this_week(app) + && key.modifiers.contains(KeyModifiers::CONTROL) + && is_persisted_today_row_selected(app) => + { + let idx = app.focused_this_week_index.unwrap(); + let db_idx = if app.timer_state == app::TimerState::Running { + idx.saturating_sub(1) + } else { + idx + }; + let note = app + .this_week_history() + .get(db_idx) + .and_then(|e| e.note.as_deref()) + .unwrap_or(""); + let id = crate::log_notes::extract_id(note).unwrap_or("").to_string(); + if id.is_empty() { + app.set_status("No log linked to this entry".to_string()); + } else { + enqueue_action(action_tx, Action::OpenEntryLogNote(id)); } } + KeyCode::Char('t') | KeyCode::Char('T') + if !is_editing_this_week(app) && !app.templates.is_empty() => + { + app.navigate_to(app::View::SelectTemplate); + } _ => {} } } diff --git a/toki-tui/src/terminal.rs b/toki-tui/src/terminal.rs index 3bfbab4a..c0749a64 100644 --- a/toki-tui/src/terminal.rs +++ b/toki-tui/src/terminal.rs @@ -1,5 +1,6 @@ use anyhow::Result; use crossterm::{ + event::{DisableFocusChange, EnableFocusChange}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -14,7 +15,7 @@ impl TerminalGuard { pub fn new() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableFocusChange)?; let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; @@ -29,7 +30,11 @@ impl TerminalGuard { impl Drop for TerminalGuard { fn drop(&mut self) { let _ = disable_raw_mode(); - let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let _ = execute!( + self.terminal.backend_mut(), + DisableFocusChange, + LeaveAlternateScreen + ); let _ = self.terminal.show_cursor(); } } diff --git a/toki-tui/src/test_support.rs b/toki-tui/src/test_support.rs new file mode 100644 index 00000000..409cccea --- /dev/null +++ b/toki-tui/src/test_support.rs @@ -0,0 +1,68 @@ +use crate::app::App; +use crate::config::TokiConfig; +use crate::types::{Activity, AttestLevel, Project, TimeEntry}; +use time::OffsetDateTime; + +pub fn test_config() -> TokiConfig { + TokiConfig::default() +} + +pub fn test_app() -> App { + App::new(1, &test_config()) +} + +#[allow(dead_code)] +pub fn project(id: &str, name: &str) -> Project { + Project { + id: id.to_string(), + name: name.to_string(), + } +} + +#[allow(dead_code)] +pub fn activity(id: &str, project_id: &str, name: &str) -> Activity { + Activity { + id: id.to_string(), + project_id: project_id.to_string(), + name: name.to_string(), + } +} + +#[allow(clippy::too_many_arguments)] +#[allow(dead_code)] +pub fn time_entry( + registration_id: &str, + project_id: &str, + project_name: &str, + activity_id: &str, + activity_name: &str, + date: &str, + hours: f64, + note: Option<&str>, + start_time: Option, + end_time: Option, +) -> TimeEntry { + TimeEntry { + registration_id: registration_id.to_string(), + project_id: project_id.to_string(), + project_name: project_name.to_string(), + activity_id: activity_id.to_string(), + activity_name: activity_name.to_string(), + date: date.to_string(), + hours, + note: note.map(ToString::to_string), + start_time, + end_time, + week_number: 1, + attest_level: AttestLevel::None, + } +} + +#[test] +fn app_defaults_to_timer_view() { + let app = test_app(); + + assert!(app.running); + assert_eq!(app.current_view, crate::app::View::Timer); + assert_eq!(app.timer_state, crate::app::TimerState::Stopped); +} diff --git a/toki-tui/src/ui/description_editor.rs b/toki-tui/src/ui/description_editor.rs index bfd46548..75a31c1c 100644 --- a/toki-tui/src/ui/description_editor.rs +++ b/toki-tui/src/ui/description_editor.rs @@ -1,15 +1,19 @@ use super::utils::centered_rect; use super::*; +use crate::log_notes; pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { + let has_log = app.description_log_id.is_some(); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([ Constraint::Length(3), // 0: Input field or CWD input - Constraint::Length(5), // 1: Git context panel - Constraint::Min(0), // 2: Spacer - Constraint::Length(3), // 3: Controls + Constraint::Length(6), // 1: Info panel (4 lines: cwd, branch, commit, log path) + Constraint::Min(3), // 2: Log content box (empty space when no log) + Constraint::Min(0), // 3: Spacer + Constraint::Length(3), // 4: Controls ]) .split(body); @@ -20,10 +24,8 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { } else { format!(" [{}]", app.cwd_completions.join(" ")) }; - let input_text = { - let (before, after) = cwd_input.split_at_cursor(); - format!("{}█{}{}", before, after, completions_hint) - }; + let (before, after) = cwd_input.split_at_cursor(); + let input_text = format!("{}{}{}", before, after, completions_hint); let input = Paragraph::new(input_text) .style(Style::default().fg(Color::Yellow)) .block( @@ -34,9 +36,20 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(input, chunks[0]); + // Place terminal cursor: border(1) + padding(1) + char offset + let cx = chunks[0].x + 2 + before.chars().count() as u16; + let cy = chunks[0].y + 1; + frame.set_cursor_position((cx, cy)); } else { - let (before, after) = app.description_input.split_at_cursor(); - let input_text = format!("{}█{}", before, after); + // Strip the log tag from the displayed value — the user sees the clean summary. + // The raw value (including tag) is preserved in app.description_input.value. + let raw = &app.description_input.value; + let stripped = log_notes::strip_tag(raw); + // Compute cursor position in the stripped view (capped at stripped length) + let cursor = app.description_input.cursor.min(stripped.chars().count()); + let before: String = stripped.chars().take(cursor).collect(); + let after: String = stripped.chars().skip(cursor).collect(); + let input_text = format!("{}{}", before, after); let input = Paragraph::new(input_text) .style(Style::default().fg(Color::White)) .block( @@ -46,9 +59,13 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(input, chunks[0]); + // Place terminal cursor: border(1) + padding(1) + char offset + let cx = chunks[0].x + 2 + cursor as u16; + let cy = chunks[0].y + 1; + frame.set_cursor_position((cx, cy)); } - // Git context panel + // Info panel let has_git = app.git_context.branch.is_some(); let git_color = if has_git { Color::White @@ -61,6 +78,33 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { let branch_str = app.git_context.branch.as_deref().unwrap_or("(no git repo)"); let commit_str = app.git_context.last_commit.as_deref().unwrap_or("(none)"); + // Build log file path label (4th info line) + let log_path_line = if let Some(ref id) = app.description_log_id { + match log_notes::log_path(id) { + Ok(path) => { + // Show path relative to home if possible + let home = dirs::home_dir().unwrap_or_default(); + let display = match path.strip_prefix(&home) { + Ok(rel) => format!("~/{}", rel.to_string_lossy()), + Err(_) => path.to_string_lossy().to_string(), + }; + Line::from(vec![ + Span::styled("Log file: ", Style::default().fg(muted)), + Span::styled(display, Style::default().fg(Color::Cyan)), + ]) + } + Err(_) => Line::from(vec![Span::styled( + "Log file: (error)", + Style::default().fg(muted), + )]), + } + } else { + Line::from(vec![Span::styled( + "Log file: ", + Style::default().fg(muted), + )]) + }; + let git_lines = vec![ Line::from(vec![ Span::styled("Current directory: ", Style::default().fg(muted)), @@ -74,6 +118,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::styled("Last commit: ", Style::default().fg(muted)), Span::styled(commit_str, Style::default().fg(git_color)), ]), + log_path_line, ]; let git_panel = Paragraph::new(git_lines).block( @@ -85,6 +130,27 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { ); frame.render_widget(git_panel, chunks[1]); + // Log content box (read-only, shown when a log is linked) + if has_log { + let log_content = app + .cached_log_content + .as_deref() + .unwrap_or_default() + .to_string(); + + let log_paragraph = Paragraph::new(log_content) + .style(Style::default().fg(Color::DarkGray)) + .wrap(ratatui::widgets::Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled(" Log ", Style::default().fg(Color::DarkGray))) + .padding(Padding::horizontal(1)), + ); + frame.render_widget(log_paragraph, chunks[2]); + } + // Controls (context-sensitive) let controls_text: Vec = if app.cwd_input.is_some() { vec![ @@ -119,7 +185,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { } else { Style::default().fg(Color::DarkGray) }; - vec![ + let mut spans = vec![ Span::styled("Type", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), @@ -128,11 +194,19 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::raw(": Confirm "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Cancel "), + Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), + Span::raw(": Add/edit log "), + ]; + if has_log { + spans.push(Span::styled("Ctrl+R", Style::default().fg(Color::Yellow))); + spans.push(Span::raw(": Remove log ")); + } + spans.extend([ Span::styled("Ctrl+D", Style::default().fg(Color::Yellow)), Span::raw(": Change directory "), Span::styled("Ctrl+G", git_key_style), Span::styled( - ": Git quick commands ", + ": Git ", Style::default().fg(if has_git { Color::Reset } else { @@ -141,7 +215,8 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { ), Span::styled("Ctrl+T", Style::default().fg(Color::Yellow)), Span::raw(": Taskwarrior"), - ] + ]); + spans }; let controls = Paragraph::new(Line::from(controls_text)) @@ -156,7 +231,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { )) .padding(ratatui::widgets::Padding::horizontal(1)), ); - frame.render_widget(controls, chunks[3]); + frame.render_widget(controls, chunks[4]); } pub fn render_taskwarrior_overlay(frame: &mut Frame, app: &App, body: Rect) { diff --git a/toki-tui/src/ui/history_panel.rs b/toki-tui/src/ui/history_panel.rs index 33860e33..85e7086a 100644 --- a/toki-tui/src/ui/history_panel.rs +++ b/toki-tui/src/ui/history_panel.rs @@ -83,17 +83,25 @@ pub fn render_this_week_history(frame: &mut Frame, area: ratatui::layout::Rect, visible_entry_idx = 1; // DB entries start at visible_entry_idx = 1 } + // Compute total hours per date for separator labels + let mut date_totals: std::collections::HashMap<&str, f64> = std::collections::HashMap::new(); + for entry in &this_week_entries { + *date_totals.entry(entry.date.as_str()).or_insert(0.0) += entry.hours; + } + for entry in &this_week_entries { let entry_date = &entry.date; if last_date.as_deref() != Some(entry_date.as_str()) { + let total = date_totals.get(entry_date.as_str()).copied().unwrap_or(0.0); + let total_str = super::utils::format_hours_hm(total); let label = if entry_date == &today_str { - "── Today ──".to_string() + format!("── Today ({}) ──", total_str) } else if entry_date == &yesterday_str { - "── Yesterday ──".to_string() + format!("── Yesterday ({}) ──", total_str) } else { // Parse YYYY-MM-DD to get weekday - let weekday_label = parse_date_weekday(entry_date); - format!("── {} ({}) ──", weekday_label, entry_date) + let weekday_label = super::utils::parse_date_weekday(entry_date); + format!("── {}, {} ({}) ──", weekday_label, entry_date, total_str) }; logical_rows.push(ThisWeekRow::Separator(label)); last_date = Some(entry_date.clone()); @@ -239,33 +247,3 @@ pub fn render_this_week_history(frame: &mut Frame, area: ratatui::layout::Rect, ); } } - -/// Parse a YYYY-MM-DD string and return the weekday name, or "Unknown" on failure. -fn parse_date_weekday(date_str: &str) -> &'static str { - let parts: Vec<&str> = date_str.splitn(3, '-').collect(); - if parts.len() != 3 { - return "Unknown"; - } - let (Ok(year), Ok(month_u8), Ok(day)) = ( - parts[0].parse::(), - parts[1].parse::(), - parts[2].parse::(), - ) else { - return "Unknown"; - }; - let Ok(month) = time::Month::try_from(month_u8) else { - return "Unknown"; - }; - let Ok(date) = time::Date::from_calendar_date(year, month, day) else { - return "Unknown"; - }; - match date.weekday() { - time::Weekday::Monday => "Monday", - time::Weekday::Tuesday => "Tuesday", - time::Weekday::Wednesday => "Wednesday", - time::Weekday::Thursday => "Thursday", - time::Weekday::Friday => "Friday", - time::Weekday::Saturday => "Saturday", - time::Weekday::Sunday => "Sunday", - } -} diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 6a260109..40d26d0c 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -78,14 +78,24 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { let mut logical_rows: Vec> = Vec::new(); let mut last_date: Option = None; + // Compute total hours per date for separator labels + let mut date_totals: std::collections::HashMap<&str, f64> = + std::collections::HashMap::new(); + for (_, entry) in &entries { + *date_totals.entry(entry.date.as_str()).or_insert(0.0) += entry.hours; + } + for (history_idx, entry) in &entries { if last_date.as_deref() != Some(&entry.date) { + let total = date_totals.get(entry.date.as_str()).copied().unwrap_or(0.0); + let total_str = super::utils::format_hours_hm(total); let label = if entry.date == today_str { - "── Today ──".to_string() + format!("── Today ({}) ──", total_str) } else if entry.date == yesterday_str { - "── Yesterday ──".to_string() + format!("── Yesterday ({}) ──", total_str) } else { - format!("── {} ──", entry.date) + let weekday = super::utils::parse_date_weekday(&entry.date); + format!("── {}, {} ({}) ──", weekday, entry.date, total_str) }; logical_rows.push(HistoryRow::Separator(label)); last_date = Some(entry.date.clone()); @@ -222,10 +232,10 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { Span::raw(": Navigate "), Span::styled("Enter", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), - Span::styled("Y", Style::default().fg(Color::Yellow)), - Span::raw(": Copy to running timer "), - Span::styled("R", Style::default().fg(Color::Yellow)), + Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), + Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), + Span::raw(": Open log "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), Span::raw(": Back to timer "), Span::styled("Q", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/mod.rs b/toki-tui/src/ui/mod.rs index 169b4e42..9dc15771 100644 --- a/toki-tui/src/ui/mod.rs +++ b/toki-tui/src/ui/mod.rs @@ -17,6 +17,7 @@ mod history_view; mod save_dialog; mod selection_views; mod statistics_view; +mod template_selection_view; mod timer_view; pub(super) mod utils; pub(super) mod widgets; @@ -46,6 +47,9 @@ pub fn render(frame: &mut Frame, app: &mut App) { View::History => history_view::render_history_view(frame, app, body), View::SelectProject => selection_views::render_project_selection(frame, app, body), View::SelectActivity => selection_views::render_activity_selection(frame, app, body), + View::SelectTemplate => { + template_selection_view::render_template_selection(frame, app, body) + } View::EditDescription => { if app.taskwarrior_overlay.is_some() { description_editor::render_taskwarrior_overlay(frame, app, body); @@ -158,3 +162,94 @@ fn render_milltime_reauth_overlay(frame: &mut Frame, app: &App) { frame.render_widget(paragraph, area); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{FocusedBox, MilltimeReauthState, TimerState}; + use crate::test_support::{activity, project, test_app}; + use ratatui::{backend::TestBackend, Terminal}; + use time::macros::datetime; + + fn render_lines(app: &mut App) -> Vec { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| render(frame, app)) + .expect("render should succeed"); + + let backend = terminal.backend(); + let buffer = backend.buffer(); + + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect() + } + + fn rendered_text(app: &mut App) -> String { + render_lines(app).join("\n") + } + + #[test] + fn render_shows_running_timer_project_and_note() { + let mut app = test_app(); + app.timer_state = TimerState::Running; + app.absolute_start = Some(datetime!(2026-03-06 09:15 UTC)); + app.selected_project = Some(project("proj-1", "Project One")); + app.selected_activity = Some(activity("act-1", "proj-1", "Activity One")); + app.description_input.value = "Investigate tests".to_string(); + app.description_input.cursor = app.description_input.value.len(); + app.focused_box = FocusedBox::ProjectActivity; + + let text = rendered_text(&mut app); + + assert!(text.contains("Timer")); + assert!(text.contains("(running)")); + assert!(text.contains("Project One: Activity One")); + assert!(text.contains("Investigate tests")); + } + + #[test] + fn render_status_shows_error_copy() { + let mut app = test_app(); + app.status_message = Some("Error starting timer: boom".to_string()); + + let text = rendered_text(&mut app); + + assert!(text.contains("Status")); + assert!(text.contains("Error starting timer: boom")); + } + + #[test] + fn render_status_shows_success_copy() { + let mut app = test_app(); + app.status_message = Some("Saved 00:15:00 to Project / Activity".to_string()); + + let text = rendered_text(&mut app); + + assert!(text.contains("Saved 00:15:00 to Project / Activity")); + } + + #[test] + fn render_milltime_reauth_masks_password() { + let mut app = test_app(); + let mut reauth = MilltimeReauthState::default(); + reauth.username_input.value = "alice".to_string(); + reauth.username_input.cursor = reauth.username_input.value.len(); + reauth.password_input.value = "secret".to_string(); + reauth.password_input.cursor = reauth.password_input.value.len(); + app.milltime_reauth = Some(reauth); + + let text = rendered_text(&mut app); + + assert!(text.contains("Milltime session expired. Please re-authenticate.")); + assert!(text.contains("alice")); + assert!(text.contains("Password:")); + assert!(!text.contains("secret")); + assert!(text.contains("••••••")); + } +} diff --git a/toki-tui/src/ui/selection_views.rs b/toki-tui/src/ui/selection_views.rs index d73d3713..6e5cf024 100644 --- a/toki-tui/src/ui/selection_views.rs +++ b/toki-tui/src/ui/selection_views.rs @@ -12,17 +12,18 @@ pub fn render_project_selection(frame: &mut Frame, app: &App, body: Rect) { .split(body); // Search input box - let search_text = if app.project_search_input.value.is_empty() { + let (search_text, project_cursor_col) = if app.project_search_input.value.is_empty() { if app.selection_list_focused { - "Type to search...".to_string() + ("Type to search...".to_string(), None) } else { - "█".to_string() + (String::new(), Some(0u16)) } } else if app.selection_list_focused { - app.project_search_input.value.clone() + (app.project_search_input.value.clone(), None) } else { let (before, after) = app.project_search_input.split_at_cursor(); - format!("{}█{}", before, after) + let col = before.chars().count() as u16; + (format!("{}{}", before, after), Some(col)) }; let search_border = if app.selection_list_focused { Style::default().fg(Color::DarkGray) @@ -40,6 +41,9 @@ pub fn render_project_selection(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(search_box, chunks[0]); + if let Some(col) = project_cursor_col { + frame.set_cursor_position((chunks[0].x + 2 + col, chunks[0].y + 1)); + } // Project list let items: Vec = app @@ -131,17 +135,18 @@ pub fn render_activity_selection(frame: &mut Frame, app: &App, body: Rect) { .split(body); // Search input box - let search_text = if app.activity_search_input.value.is_empty() { + let (search_text, activity_cursor_col) = if app.activity_search_input.value.is_empty() { if app.selection_list_focused { - "Type to search...".to_string() + ("Type to search...".to_string(), None) } else { - "█".to_string() + (String::new(), Some(0u16)) } } else if app.selection_list_focused { - app.activity_search_input.value.clone() + (app.activity_search_input.value.clone(), None) } else { let (before, after) = app.activity_search_input.split_at_cursor(); - format!("{}█{}", before, after) + let col = before.chars().count() as u16; + (format!("{}{}", before, after), Some(col)) }; let search_border = if app.selection_list_focused { Style::default().fg(Color::DarkGray) @@ -159,6 +164,9 @@ pub fn render_activity_selection(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(search_box, chunks[0]); + if let Some(col) = activity_cursor_col { + frame.set_cursor_position((chunks[0].x + 2 + col, chunks[0].y + 1)); + } // Activity list let items: Vec = app diff --git a/toki-tui/src/ui/statistics_view.rs b/toki-tui/src/ui/statistics_view.rs index 60a6a04b..8e3ee594 100644 --- a/toki-tui/src/ui/statistics_view.rs +++ b/toki-tui/src/ui/statistics_view.rs @@ -25,12 +25,12 @@ pub fn render_statistics_view(frame: &mut Frame, app: &App, body: Rect) { .constraints([Constraint::Min(10), Constraint::Length(3)]) .split(body); - // Outer "Stats" box + // Outer "Statistics" box let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::White)) .title(Span::styled( - " Stats ", + " Statistics ", Style::default().fg(Color::White), )); let stats_inner = stats_block.inner(outer[0]); diff --git a/toki-tui/src/ui/template_selection_view.rs b/toki-tui/src/ui/template_selection_view.rs new file mode 100644 index 00000000..9a6a8508 --- /dev/null +++ b/toki-tui/src/ui/template_selection_view.rs @@ -0,0 +1,130 @@ +use super::*; + +pub fn render_template_selection(frame: &mut Frame, app: &App, body: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Search input + Constraint::Min(0), // Template list + Constraint::Length(3), // Controls + ]) + .split(body); + + // Search input box + let (search_text, template_cursor_col) = if app.template_search_input.value.is_empty() { + if app.selection_list_focused { + ("Type to search...".to_string(), None) + } else { + (String::new(), Some(0u16)) + } + } else if app.selection_list_focused { + (app.template_search_input.value.clone(), None) + } else { + let (before, after) = app.template_search_input.split_at_cursor(); + let col = before.chars().count() as u16; + (format!("{}{}", before, after), Some(col)) + }; + let search_border = if app.selection_list_focused { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::White) + }; + let search_box = Paragraph::new(search_text) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Left) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(search_border) + .title(" Search ") + .padding(Padding::horizontal(1)), + ); + frame.render_widget(search_box, chunks[0]); + if let Some(col) = template_cursor_col { + frame.set_cursor_position((chunks[0].x + 2 + col, chunks[0].y + 1)); + } + + // Template list + let items: Vec = app + .filtered_templates + .iter() + .enumerate() + .map(|(i, template)| { + let selected = i == app.filtered_template_index; + let desc_style = if selected { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let sub_style = Style::default().fg(Color::DarkGray); + + let line1 = Line::from(Span::styled(template.description.clone(), desc_style)); + let line2 = Line::from(Span::styled( + format!("{}: {}", template.project, template.activity), + sub_style, + )); + + ListItem::new(vec![line1, line2]) + }) + .collect(); + + // Show count: filtered / total + let title = if app.template_search_input.value.is_empty() { + format!(" Templates ({}) ", app.templates.len()) + } else { + format!( + " Templates ({}/{}) ", + app.filtered_templates.len(), + app.templates.len() + ) + }; + + let list_border = if app.selection_list_focused { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(list_border) + .title(title) + .padding(Padding::horizontal(1)), + ) + .style(Style::default()); + + frame.render_widget(list, chunks[1]); + + // Controls + let controls_text = vec![ + Span::styled("Type", Style::default().fg(Color::Yellow)), + Span::raw(": Filter "), + Span::styled("Tab", Style::default().fg(Color::Yellow)), + Span::raw(": Focus list "), + Span::styled("↑↓/j/k", Style::default().fg(Color::Yellow)), + Span::raw(": Navigate "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(": Apply "), + Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), + Span::raw(": Clear "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(": Cancel"), + ]; + + let controls = Paragraph::new(Line::from(controls_text)) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + " Controls ", + Style::default().fg(Color::DarkGray), + )) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + frame.render_widget(controls, chunks[2]); +} diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 80529a69..63b0eaf9 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -26,7 +26,7 @@ pub fn render_timer_view(frame: &mut Frame, app: &mut App, body: Rect) { render_description(frame, chunks[2], app); super::history_panel::render_this_week_history(frame, chunks[3], app); render_status(frame, chunks[4], app); - render_controls(frame, chunks[5]); + render_controls(frame, chunks[5], app); } fn render_timer(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { @@ -147,36 +147,44 @@ fn render_project(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { } fn render_description(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { - let description = app.current_description(); + let description = crate::log_notes::strip_tag(&app.description_input.value).to_string(); let is_empty = description.is_empty(); + let has_log = app.description_log_id.is_some(); let is_focused = app.focused_box == crate::app::FocusedBox::Description; let border_style = if is_focused { Style::default().fg(Color::Magenta) - } else if !is_empty { - // White border when note has content and not focused + } else if !is_empty || has_log { + // White border when note has content (or a log is attached) and not focused Style::default().fg(Color::White) } else { // Default when empty and not focused Style::default() }; - // Title with underlined A + // Title with underlined N let title = vec![ Span::raw(" "), Span::styled("N", Style::default().add_modifier(Modifier::UNDERLINED)), Span::raw("ote "), ]; - let widget = Paragraph::new(description) - .style(Style::default().fg(Color::White)) - .block( - Block::default() - .borders(Borders::ALL) - .title(Line::from(title)) - .border_style(border_style) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); + // Build the paragraph content: summary text + optional muted "[…]" log indicator + let mut spans: Vec = vec![Span::styled(description, Style::default().fg(Color::White))]; + if has_log { + spans.push(Span::styled( + " [\u{2026}]", + Style::default().fg(Color::DarkGray), + )); + } + + let widget = Paragraph::new(Line::from(spans)).block( + Block::default() + .borders(Borders::ALL) + .title(Line::from(title)) + .border_style(border_style) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); frame.render_widget(widget, area); } @@ -227,12 +235,16 @@ pub fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) frame.render_widget(status, area); } -fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { +fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { let line1 = vec![ Span::styled("Space", Style::default().fg(Color::Yellow)), Span::raw(": Start/Stop "), Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), - Span::raw(": Save (options) "), + Span::raw(": Save "), + Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), + Span::raw(": Resume "), + Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), + Span::raw(": Open log "), Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), Span::raw(": Clear "), Span::styled("Tab / ↑↓ / j/k", Style::default().fg(Color::Yellow)), @@ -241,28 +253,32 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { Span::raw(": Edit"), ]; - let line2 = vec![ + let mut line2 = vec![ Span::styled("P", Style::default().fg(Color::Yellow)), Span::raw(": Project "), Span::styled("N", Style::default().fg(Color::Yellow)), Span::raw(": Note "), + ]; + + if !app.templates.is_empty() { + line2.push(Span::styled("T", Style::default().fg(Color::Yellow))); + line2.push(Span::raw(": Template ")); + } + + line2.extend([ Span::styled("H", Style::default().fg(Color::Yellow)), Span::raw(": History "), Span::styled("S", Style::default().fg(Color::Yellow)), - Span::raw(": Stats "), - Span::styled("T", Style::default().fg(Color::Yellow)), + Span::raw(": Statistics "), + Span::styled("X", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), - Span::styled("Y", Style::default().fg(Color::Yellow)), - Span::raw(": Copy to running timer "), - Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume "), Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Exit edit "), Span::styled("Q", Style::default().fg(Color::Yellow)), Span::raw(": Quit"), - ]; + ]); let controls = Paragraph::new(vec![Line::from(line1), Line::from(line2)]) .alignment(Alignment::Center) diff --git a/toki-tui/src/ui/utils.rs b/toki-tui/src/ui/utils.rs index f095cacd..1f4afe53 100644 --- a/toki-tui/src/ui/utils.rs +++ b/toki-tui/src/ui/utils.rs @@ -24,3 +24,55 @@ pub fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { ]) .split(popup_layout[1])[1] } + +/// Parses a "YYYY-MM-DD" date string and returns the English weekday name. +/// Returns "Unknown" if parsing fails. +pub fn parse_date_weekday(date_str: &str) -> &'static str { + let parts: Vec<&str> = date_str.splitn(3, '-').collect(); + if parts.len() != 3 { + return "Unknown"; + } + let (Ok(year), Ok(month_u8), Ok(day)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) else { + return "Unknown"; + }; + let Ok(month) = time::Month::try_from(month_u8) else { + return "Unknown"; + }; + let Ok(date) = time::Date::from_calendar_date(year, month, day) else { + return "Unknown"; + }; + match date.weekday() { + time::Weekday::Monday => "Monday", + time::Weekday::Tuesday => "Tuesday", + time::Weekday::Wednesday => "Wednesday", + time::Weekday::Thursday => "Thursday", + time::Weekday::Friday => "Friday", + time::Weekday::Saturday => "Saturday", + time::Weekday::Sunday => "Sunday", + } +} + +/// Formats a duration in hours (f64) as "HHh:MMm", e.g. 6.5 → "06h:30m". +pub fn format_hours_hm(hours: f64) -> String { + let total_minutes = (hours * 60.0).round() as u64; + let h = total_minutes / 60; + let m = total_minutes % 60; + format!("{:02}h:{:02}m", h, m) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_hours_hm() { + assert_eq!(format_hours_hm(0.0), "00h:00m"); + assert_eq!(format_hours_hm(6.5), "06h:30m"); + assert_eq!(format_hours_hm(1.0 / 60.0), "00h:01m"); // 1 minute + assert_eq!(format_hours_hm(10.0), "10h:00m"); + } +} diff --git a/toki-tui/src/ui/widgets.rs b/toki-tui/src/ui/widgets.rs index d6d61a4c..5df04cbf 100644 --- a/toki-tui/src/ui/widgets.rs +++ b/toki-tui/src/ui/widgets.rs @@ -1,5 +1,6 @@ use super::utils::to_local_time; use crate::app::{EntryEditField, EntryEditState}; +use crate::log_notes; use crate::types::TimeEntry; use ratatui::{ style::{Color, Modifier, Style}, @@ -8,16 +9,16 @@ use ratatui::{ use crate::app::App; -/// Render a partial or complete time string with a block cursor. +/// Render a partial or complete time string with a hairline cursor. /// - len >= 5 ("HH:MM"): display as-is, no cursor -/// - len < 5: show typed chars + '█' + space padding to fill 5-char slot +/// - len < 5: show typed chars + '▏' + space padding to fill 5-char slot fn time_input_display(s: &str) -> String { if s.len() >= 5 { format!("[{}]", s) } else { let filled = s.len(); let spaces = 5 - filled - 1; - format!("[{}█{}]", s, " ".repeat(spaces)) + format!("[{}▏{}]", s, " ".repeat(spaces)) } } @@ -110,7 +111,9 @@ pub fn build_display_row( let project = &entry.project_name; let activity = &entry.activity_name; - let note = entry.note.as_deref().unwrap_or(""); + let note_raw = entry.note.as_deref().unwrap_or(""); + let note = log_notes::strip_tag(note_raw); + let has_log = log_notes::extract_id(note_raw).is_some(); // Start time let start_str = entry @@ -176,6 +179,14 @@ pub fn build_display_row( spans.push(Span::styled(note_display, Style::default().fg(note_color))); } + // Log indicator: "[…]" in white for entries with a linked log note + if has_log { + spans.push(Span::styled( + " [\u{2026}]", + Style::default().fg(Color::White), + )); + } + // Apply focus styling: white background with black text if is_focused { let focused_style = Style::default() @@ -227,7 +238,8 @@ pub fn build_running_timer_display_row( .as_ref() .map(|a| a.name.clone()) .unwrap_or_else(|| "[None]".to_string()); - let note = app.description_input.value.clone(); + let note = log_notes::strip_tag(&app.description_input.value).to_string(); + let has_log = app.description_log_id.is_some(); let prefix_len: usize = 28; // "▶ " (2) + "HH:MM - HH:MM " (14) + "[DDh:DDm]" (9) + " | " (3) let remaining = (available_width as usize).saturating_sub(prefix_len); @@ -272,6 +284,12 @@ pub fn build_running_timer_display_row( spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); spans.push(Span::styled(note_display, Style::default().fg(Color::Gray))); } + if has_log { + spans.push(Span::styled( + " [\u{2026}]", + Style::default().fg(Color::White), + )); + } Line::from(spans) } @@ -327,7 +345,7 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { spans.push(Span::styled(" | ", Style::default().fg(Color::White))); - // Note field + // Note field — display only (editing opens the full-screen Notes overlay via Enter) let note_style = match edit_state.focused_field { EntryEditField::Note => Style::default() .fg(Color::Black) @@ -335,23 +353,8 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { .add_modifier(Modifier::BOLD), _ => Style::default().fg(Color::White), }; - let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { - let (before, after) = edit_state.note.split_at_cursor(); - if edit_state.note.value.is_empty() { - "[█]".to_string() - } else { - format!("[{}█{}]", before, after) - } - } else { - format!( - "[{}]", - if edit_state.note.value.is_empty() { - "Empty" - } else { - &edit_state.note.value - } - ) - }; + let display = log_notes::strip_tag(&edit_state.note.value); + let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); spans.push(Span::styled(note_value, note_style)); Line::from(spans) @@ -423,7 +426,7 @@ pub fn build_edit_row<'a>( // Separator spans.push(Span::styled(" | ", Style::default().fg(Color::White))); - // Note field + // Note field — display only (editing opens the full-screen Notes overlay via Enter) let note_style = match edit_state.focused_field { EntryEditField::Note => Style::default() .fg(Color::Black) @@ -431,23 +434,8 @@ pub fn build_edit_row<'a>( .add_modifier(Modifier::BOLD), _ => Style::default().fg(Color::White), }; - let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { - let (before, after) = edit_state.note.split_at_cursor(); - if edit_state.note.value.is_empty() { - "[█]".to_string() - } else { - format!("[{}█{}]", before, after) - } - } else { - format!( - "[{}]", - if edit_state.note.value.is_empty() { - "Empty" - } else { - &edit_state.note.value - } - ) - }; + let display = log_notes::strip_tag(&edit_state.note.value); + let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); spans.push(Span::styled(note_value, note_style)); Line::from(spans)