Edit and delete entries from view page
This commit is contained in:
parent
3220ff50cb
commit
605d5f5792
5 changed files with 229 additions and 33 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -301,6 +301,12 @@ dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "0.8.11"
|
version = "0.8.11"
|
||||||
|
|
@ -484,6 +490,19 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.145"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_yaml"
|
name = "serde_yaml"
|
||||||
version = "0.9.34+deprecated"
|
version = "0.9.34+deprecated"
|
||||||
|
|
@ -715,6 +734,7 @@ dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ ratatui = "0.24.0"
|
||||||
crossterm = "0.27.0"
|
crossterm = "0.27.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
serde_json = "1.0"
|
||||||
tokio = { version = "1.34", features = ["full"] }
|
tokio = { version = "1.34", features = ["full"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
- **Timer control**: `Enter` toggles the selected item. Starting a different item automatically stops the previous timer.
|
- **Timer control**: `Enter` toggles the selected item. Starting a different item automatically stops the previous timer.
|
||||||
- **New entries**: `n` launches a modal (task name + optional project tag). Item is added to the current pane.
|
- **New entries**: `n` launches a modal (task name + optional project tag). Item is added to the current pane.
|
||||||
- **Project reassignment**: `p` opens a modal to change the project tag for the selected item.
|
- **Project reassignment**: `p` opens a modal to change the project tag for the selected item.
|
||||||
- **Log viewing**: `v` opens Watson's log viewer with daily/weekly/monthly views. Use `d`/`w`/`m` to switch periods, `j`/`k` to scroll.
|
- **Log viewing**: `v` opens Watson's log viewer with daily/weekly/monthly views. Use `d`/`w`/`m` to switch periods, `j`/`k` to select entries, `e` to edit, `x` to delete.
|
||||||
- **Deletion**: `d` removes the selected entry; no noisy status message.
|
- **Deletion**: `d` removes the selected entry; no noisy status message.
|
||||||
- **Config editing**:
|
- **Config editing**:
|
||||||
- `Ctrl+e` edits task config (`state.yaml`).
|
- `Ctrl+e` edits task config (`state.yaml`).
|
||||||
|
|
@ -56,7 +56,7 @@ Requires `watson` on your `PATH` with a configured workspace.
|
||||||
| `Enter` | Start/stop timer |
|
| `Enter` | Start/stop timer |
|
||||||
| `n` | New entry (task + optional project) |
|
| `n` | New entry (task + optional project) |
|
||||||
| `p` | Reassign project for selected item |
|
| `p` | Reassign project for selected item |
|
||||||
| `v` | View Watson log (d/w/m for day/week/month) |
|
| `v` | View Watson log (d/w/m for day/week/month, e to edit, x to delete) |
|
||||||
| `d` | Delete entry |
|
| `d` | Delete entry |
|
||||||
| `Ctrl+e` | Edit task config (`state.yaml`) |
|
| `Ctrl+e` | Edit task config (`state.yaml`) |
|
||||||
| `c` | Edit app config (`config.yaml`) or show config help |
|
| `c` | Edit app config (`config.yaml`) or show config help |
|
||||||
|
|
|
||||||
181
src/app.rs
181
src/app.rs
|
|
@ -2,8 +2,18 @@ use crate::config::Config;
|
||||||
use crate::state::{AppState, TimeItem};
|
use crate::state::{AppState, TimeItem};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub(crate) struct WatsonFrame {
|
||||||
|
id: String,
|
||||||
|
project: String,
|
||||||
|
start: String,
|
||||||
|
stop: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
Main,
|
Main,
|
||||||
Help,
|
Help,
|
||||||
|
|
@ -31,8 +41,10 @@ pub struct App {
|
||||||
pub reassign_project_buffer: String,
|
pub reassign_project_buffer: String,
|
||||||
pub reassign_project_cursor: usize,
|
pub reassign_project_cursor: usize,
|
||||||
pub log_view_period: LogViewPeriod,
|
pub log_view_period: LogViewPeriod,
|
||||||
|
pub log_view_frames: Vec<WatsonFrame>,
|
||||||
pub log_view_content: Vec<String>,
|
pub log_view_content: Vec<String>,
|
||||||
pub log_view_scroll: usize,
|
pub log_view_scroll: usize,
|
||||||
|
pub log_view_selected: usize,
|
||||||
pub status_message: Option<(String, std::time::Instant)>,
|
pub status_message: Option<(String, std::time::Instant)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,8 +67,10 @@ impl App {
|
||||||
reassign_project_buffer: String::new(),
|
reassign_project_buffer: String::new(),
|
||||||
reassign_project_cursor: 0,
|
reassign_project_cursor: 0,
|
||||||
log_view_period: LogViewPeriod::Day,
|
log_view_period: LogViewPeriod::Day,
|
||||||
|
log_view_frames: Vec::new(),
|
||||||
log_view_content: Vec::new(),
|
log_view_content: Vec::new(),
|
||||||
log_view_scroll: 0,
|
log_view_scroll: 0,
|
||||||
|
log_view_selected: 0,
|
||||||
status_message: None,
|
status_message: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -275,42 +289,52 @@ impl App {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
self.current_screen = Screen::Main;
|
self.current_screen = Screen::Main;
|
||||||
self.log_view_scroll = 0;
|
self.log_view_scroll = 0;
|
||||||
|
self.log_view_selected = 0;
|
||||||
self.needs_clear = true;
|
self.needs_clear = true;
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') => {
|
KeyCode::Char('d') => {
|
||||||
self.log_view_period = LogViewPeriod::Day;
|
self.log_view_period = LogViewPeriod::Day;
|
||||||
self.log_view_scroll = 0;
|
self.log_view_scroll = 0;
|
||||||
|
self.log_view_selected = 0;
|
||||||
self.load_log_content()?;
|
self.load_log_content()?;
|
||||||
self.needs_clear = true;
|
self.needs_clear = true;
|
||||||
}
|
}
|
||||||
KeyCode::Char('w') => {
|
KeyCode::Char('w') => {
|
||||||
self.log_view_period = LogViewPeriod::Week;
|
self.log_view_period = LogViewPeriod::Week;
|
||||||
self.log_view_scroll = 0;
|
self.log_view_scroll = 0;
|
||||||
|
self.log_view_selected = 0;
|
||||||
self.load_log_content()?;
|
self.load_log_content()?;
|
||||||
self.needs_clear = true;
|
self.needs_clear = true;
|
||||||
}
|
}
|
||||||
KeyCode::Char('m') => {
|
KeyCode::Char('m') => {
|
||||||
self.log_view_period = LogViewPeriod::Month;
|
self.log_view_period = LogViewPeriod::Month;
|
||||||
self.log_view_scroll = 0;
|
self.log_view_scroll = 0;
|
||||||
|
self.log_view_selected = 0;
|
||||||
self.load_log_content()?;
|
self.load_log_content()?;
|
||||||
self.needs_clear = true;
|
self.needs_clear = true;
|
||||||
}
|
}
|
||||||
KeyCode::Char('j') | KeyCode::Down => {
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
if self.log_view_scroll < self.log_view_content.len().saturating_sub(1) {
|
if self.log_view_selected < self.log_view_frames.len().saturating_sub(1) {
|
||||||
self.log_view_scroll += 1;
|
self.log_view_selected += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') | KeyCode::Up => {
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
if self.log_view_scroll > 0 {
|
if self.log_view_selected > 0 {
|
||||||
self.log_view_scroll -= 1;
|
self.log_view_selected -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('e') => {
|
||||||
|
self.edit_selected_frame()?;
|
||||||
|
}
|
||||||
|
KeyCode::Char('x') => {
|
||||||
|
self.delete_selected_frame()?;
|
||||||
|
}
|
||||||
KeyCode::PageDown => {
|
KeyCode::PageDown => {
|
||||||
self.log_view_scroll = (self.log_view_scroll + 10)
|
self.log_view_selected = (self.log_view_selected + 10)
|
||||||
.min(self.log_view_content.len().saturating_sub(1));
|
.min(self.log_view_frames.len().saturating_sub(1));
|
||||||
}
|
}
|
||||||
KeyCode::PageUp => {
|
KeyCode::PageUp => {
|
||||||
self.log_view_scroll = self.log_view_scroll.saturating_sub(10);
|
self.log_view_selected = self.log_view_selected.saturating_sub(10);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
|
@ -319,6 +343,124 @@ impl App {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_log_entries(&mut self) {
|
||||||
|
use chrono::{DateTime, Local, Timelike};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
if self.log_view_frames.is_empty() {
|
||||||
|
self.log_view_content = vec!["No log entries for this period.".to_string()];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group frames by date
|
||||||
|
let mut by_date: BTreeMap<String, Vec<&WatsonFrame>> = BTreeMap::new();
|
||||||
|
|
||||||
|
for frame in &self.log_view_frames {
|
||||||
|
if let Ok(start_dt) = DateTime::parse_from_rfc3339(&frame.start) {
|
||||||
|
let local_dt: DateTime<Local> = start_dt.into();
|
||||||
|
let date_key = local_dt.format("%A %d %B %Y").to_string();
|
||||||
|
by_date.entry(date_key).or_insert_with(Vec::new).push(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for (date, frames) in by_date.iter().rev() {
|
||||||
|
lines.push(date.clone());
|
||||||
|
|
||||||
|
for frame in frames {
|
||||||
|
if let (Ok(start_dt), Ok(stop_dt)) = (
|
||||||
|
DateTime::parse_from_rfc3339(&frame.start),
|
||||||
|
DateTime::parse_from_rfc3339(&frame.stop),
|
||||||
|
) {
|
||||||
|
let start_local: DateTime<Local> = start_dt.into();
|
||||||
|
let stop_local: DateTime<Local> = stop_dt.into();
|
||||||
|
|
||||||
|
let start_time = format!("{:02}:{:02}", start_local.hour(), start_local.minute());
|
||||||
|
let stop_time = format!("{:02}:{:02}", stop_local.hour(), stop_local.minute());
|
||||||
|
|
||||||
|
let tags_str = if frame.tags.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" [{}]", frame.tags.join(", "))
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(format!(
|
||||||
|
"\t{} to {} {}{}",
|
||||||
|
start_time, stop_time, frame.project, tags_str
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(String::new()); // Empty line between dates
|
||||||
|
}
|
||||||
|
|
||||||
|
self.log_view_content = lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_selected_frame(&mut self) -> anyhow::Result<()> {
|
||||||
|
if self.log_view_frames.is_empty() || self.log_view_selected >= self.log_view_frames.len() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame_id = &self.log_view_frames[self.log_view_selected].id;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode},
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use std::io::stdout;
|
||||||
|
|
||||||
|
// Leave TUI mode
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(stdout(), LeaveAlternateScreen)?;
|
||||||
|
|
||||||
|
// Run watson edit
|
||||||
|
let status = Command::new("watson").arg("edit").arg(frame_id).status()?;
|
||||||
|
|
||||||
|
// Return to TUI mode
|
||||||
|
execute!(stdout(), EnterAlternateScreen)?;
|
||||||
|
enable_raw_mode()?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
// Reload log content
|
||||||
|
self.load_log_content()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.needs_clear = true;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_selected_frame(&mut self) -> anyhow::Result<()> {
|
||||||
|
if self.log_view_frames.is_empty() || self.log_view_selected >= self.log_view_frames.len() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame_id = self.log_view_frames[self.log_view_selected].id.clone();
|
||||||
|
|
||||||
|
// Run watson remove with --force flag (no confirmation)
|
||||||
|
let output = Command::new("watson")
|
||||||
|
.arg("remove")
|
||||||
|
.arg("--force")
|
||||||
|
.arg(&frame_id)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
// Reload log content
|
||||||
|
self.load_log_content()?;
|
||||||
|
|
||||||
|
// Adjust selection if we deleted the last item
|
||||||
|
if self.log_view_selected >= self.log_view_frames.len() && !self.log_view_frames.is_empty() {
|
||||||
|
self.log_view_selected = self.log_view_frames.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.needs_clear = true;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn move_selection(&mut self, delta: i32) {
|
fn move_selection(&mut self, delta: i32) {
|
||||||
let items_len = match self.state.current_pane {
|
let items_len = match self.state.current_pane {
|
||||||
0 => self.state.permanent_items.len(),
|
0 => self.state.permanent_items.len(),
|
||||||
|
|
@ -469,13 +611,32 @@ impl App {
|
||||||
LogViewPeriod::Month => "--month",
|
LogViewPeriod::Month => "--month",
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = Command::new("watson").arg("log").arg(flag).output()?;
|
let output = Command::new("watson")
|
||||||
|
.arg("log")
|
||||||
|
.arg(flag)
|
||||||
|
.arg("--json")
|
||||||
|
.output()?;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let content = String::from_utf8_lossy(&output.stdout);
|
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||||
self.log_view_content = content.lines().map(|s| s.to_string()).collect();
|
|
||||||
|
// Parse JSON frames
|
||||||
|
match serde_json::from_str::<Vec<WatsonFrame>>(&json_str) {
|
||||||
|
Ok(frames) => {
|
||||||
|
self.log_view_frames = frames;
|
||||||
|
self.format_log_entries();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.log_view_frames.clear();
|
||||||
|
self.log_view_content = vec![
|
||||||
|
"Failed to parse Watson log JSON:".to_string(),
|
||||||
|
e.to_string(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let error = String::from_utf8_lossy(&output.stderr);
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
self.log_view_frames.clear();
|
||||||
self.log_view_content = vec![
|
self.log_view_content = vec![
|
||||||
"Failed to load Watson log:".to_string(),
|
"Failed to load Watson log:".to_string(),
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
|
|
|
||||||
56
src/ui.rs
56
src/ui.rs
|
|
@ -316,7 +316,7 @@ fn render_help(frame: &mut Frame, _app: &App) {
|
||||||
"Enter - Start/stop timer",
|
"Enter - Start/stop timer",
|
||||||
"d - Delete task",
|
"d - Delete task",
|
||||||
"p - Reassign project",
|
"p - Reassign project",
|
||||||
"v - View Watson log",
|
"v - View Watson log (e to edit entries, x to delete)",
|
||||||
"Ctrl+e - Edit tasks config",
|
"Ctrl+e - Edit tasks config",
|
||||||
"c - Edit app config",
|
"c - Edit app config",
|
||||||
"n - New task",
|
"n - New task",
|
||||||
|
|
@ -527,36 +527,50 @@ fn render_log_view(frame: &mut Frame, app: &App) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(title)
|
.title(title)
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(Style::default().fg(ACTIVE_COLOR).bg(Color::Reset));
|
.style(Style::default().fg(ACTIVE_COLOR));
|
||||||
|
|
||||||
// Calculate visible area for content
|
// Build list items with selection highlighting
|
||||||
let inner_area = block.inner(chunks[0]);
|
let items: Vec<ListItem> = app
|
||||||
let visible_lines = inner_area.height as usize;
|
.log_view_content
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, line)| {
|
||||||
|
// Check if this is a frame line (starts with tab) and matches selected frame
|
||||||
|
let is_selected = if line.starts_with('\t') {
|
||||||
|
// Count how many frame lines we've seen so far
|
||||||
|
let frame_idx = app.log_view_content[..=idx]
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.starts_with('\t'))
|
||||||
|
.count()
|
||||||
|
.saturating_sub(1);
|
||||||
|
frame_idx == app.log_view_selected
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
// Get the visible slice of content
|
let style = if is_selected {
|
||||||
let start = app.log_view_scroll;
|
Style::default()
|
||||||
let end = (start + visible_lines).min(app.log_view_content.len());
|
.fg(ACTIVE_COLOR)
|
||||||
let visible_content: Vec<String> = app.log_view_content[start..end].to_vec();
|
.add_modifier(Modifier::REVERSED)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
let text = if visible_content.is_empty() {
|
ListItem::new(Line::from(vec![Span::styled(line.clone(), style)]))
|
||||||
"No log entries for this period.".to_string()
|
})
|
||||||
} else {
|
.collect();
|
||||||
visible_content.join("\n")
|
|
||||||
};
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(text)
|
let list = List::new(items).block(block);
|
||||||
.block(block)
|
frame.render_widget(list, chunks[0]);
|
||||||
.style(Style::default().fg(Color::White))
|
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
|
||||||
|
|
||||||
frame.render_widget(paragraph, chunks[0]);
|
|
||||||
|
|
||||||
// Render command bar
|
// Render command bar
|
||||||
let commands = vec![
|
let commands = vec![
|
||||||
("d", "day"),
|
("d", "day"),
|
||||||
("w", "week"),
|
("w", "week"),
|
||||||
("m", "month"),
|
("m", "month"),
|
||||||
("j/k", "scroll"),
|
("j/k", "select"),
|
||||||
|
("e", "edit"),
|
||||||
|
("x", "delete"),
|
||||||
("q/ESC", "back"),
|
("q/ESC", "back"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue