Select grouped by project or day

This commit is contained in:
Ian Keane 2025-11-23 12:01:13 -05:00
parent 56f9322e36
commit 5aaa57a6a2
2 changed files with 480 additions and 43 deletions

View file

@ -35,6 +35,12 @@ pub enum LogViewGrouping {
ByProject,
}
pub enum LogViewSelection {
Entry, // Individual entry selected
Project, // Whole project selected (ByProject mode only)
Day, // Whole day selected
}
pub struct App {
pub state: AppState,
pub config: Config,
@ -48,6 +54,7 @@ pub struct App {
pub reassign_project_cursor: usize,
pub log_view_period: LogViewPeriod,
pub log_view_grouping: LogViewGrouping,
pub log_view_selection_level: LogViewSelection,
pub log_view_frames: Vec<WatsonFrame>,
pub log_view_content: Vec<String>,
pub log_view_scroll: usize,
@ -62,6 +69,14 @@ pub enum NewEntryMode {
}
impl App {
// Helper to determine if a line is an entry line based on grouping mode
fn is_entry_line(&self, line: &str) -> bool {
match self.log_view_grouping {
LogViewGrouping::ByProject => line.starts_with("\t\t"),
LogViewGrouping::ByDate => line.starts_with('\t') && !line.starts_with("\t\t"),
}
}
pub fn new() -> anyhow::Result<Self> {
Ok(Self {
state: AppState::load()?,
@ -76,6 +91,7 @@ impl App {
reassign_project_cursor: 0,
log_view_period: LogViewPeriod::Day,
log_view_grouping: LogViewGrouping::ByDate,
log_view_selection_level: LogViewSelection::Entry,
log_view_frames: Vec::new(),
log_view_content: Vec::new(),
log_view_scroll: 0,
@ -321,6 +337,7 @@ impl App {
self.log_view_period = LogViewPeriod::Day;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.load_log_content()?;
self.needs_clear = true;
}
@ -328,6 +345,7 @@ impl App {
self.log_view_period = LogViewPeriod::Week;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.load_log_content()?;
self.needs_clear = true;
}
@ -335,24 +353,75 @@ impl App {
self.log_view_period = LogViewPeriod::Month;
self.log_view_scroll = 0;
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.load_log_content()?;
self.needs_clear = true;
}
KeyCode::Char('j') | KeyCode::Down => {
if self.log_view_selected < self.log_view_frames.len().saturating_sub(1) {
self.log_view_selected += 1;
match self.log_view_selection_level {
LogViewSelection::Entry => {
// Move to next entry
if self.log_view_selected < self.log_view_frames.len().saturating_sub(1) {
self.log_view_selected += 1;
}
}
LogViewSelection::Project => {
// Jump to next project group
self.jump_to_next_project();
}
LogViewSelection::Day => {
// Jump to next day
self.jump_to_next_day();
}
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.log_view_selected > 0 {
self.log_view_selected -= 1;
match self.log_view_selection_level {
LogViewSelection::Entry => {
// Move to previous entry
if self.log_view_selected > 0 {
self.log_view_selected -= 1;
}
}
LogViewSelection::Project => {
// Jump to previous project group
self.jump_to_previous_project();
}
LogViewSelection::Day => {
// Jump to previous day
self.jump_to_previous_day();
}
}
}
KeyCode::Char('e') => {
self.edit_selected_frame()?;
// Only allow edit when selecting individual entry
if matches!(self.log_view_selection_level, LogViewSelection::Entry) {
self.edit_selected_frame()?;
}
}
KeyCode::Char('x') => {
self.delete_selected_frame()?;
// Only allow delete when selecting individual entry
if matches!(self.log_view_selection_level, LogViewSelection::Entry) {
self.delete_selected_frame()?;
}
}
KeyCode::Char('h') | KeyCode::Left => {
// Zoom out selection level
self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) {
(LogViewSelection::Day, _) => LogViewSelection::Day, // Already at highest
(LogViewSelection::Project, _) => LogViewSelection::Day,
(LogViewSelection::Entry, LogViewGrouping::ByProject) => LogViewSelection::Project,
(LogViewSelection::Entry, LogViewGrouping::ByDate) => LogViewSelection::Day,
};
}
KeyCode::Char('l') | KeyCode::Right => {
// Zoom in selection level
self.log_view_selection_level = match (&self.log_view_selection_level, &self.log_view_grouping) {
(LogViewSelection::Entry, _) => LogViewSelection::Entry, // Already at lowest
(LogViewSelection::Project, _) => LogViewSelection::Entry,
(LogViewSelection::Day, LogViewGrouping::ByProject) => LogViewSelection::Project,
(LogViewSelection::Day, LogViewGrouping::ByDate) => LogViewSelection::Entry,
};
}
KeyCode::Char('c') => {
self.copy_log_to_clipboard()?;
@ -364,6 +433,7 @@ impl App {
LogViewGrouping::ByProject => LogViewGrouping::ByDate,
};
self.log_view_selected = 0;
self.log_view_selection_level = LogViewSelection::Entry;
self.format_log_entries();
self.needs_clear = true;
}
@ -572,15 +642,27 @@ impl App {
return Ok(());
}
// Join all the formatted log content
let text = self.log_view_content.join("\n");
// Determine what to copy based on selection level
let text = match self.log_view_selection_level {
LogViewSelection::Entry => {
// Copy just the selected entry
self.get_selected_entry_text()
}
LogViewSelection::Project => {
// Copy the selected project group
self.get_selected_project_text()
}
LogViewSelection::Day => {
// Copy the entire day
self.get_selected_day_text()
}
};
// Copy to clipboard using the persistent clipboard instance
if let Some(ref mut clipboard) = self.clipboard {
if let Err(e) = clipboard.set_text(&text) {
self.set_status_message(format!("Failed to copy: {}", e));
}
// Success - clipboard instance stays alive in App struct
} else {
// Try to create a new clipboard if we don't have one
match arboard::Clipboard::new() {
@ -588,7 +670,6 @@ impl App {
if let Err(e) = clipboard.set_text(&text) {
self.set_status_message(format!("Failed to copy: {}", e));
}
// Store it for future use
self.clipboard = Some(clipboard);
}
Err(e) => {
@ -600,6 +681,260 @@ impl App {
Ok(())
}
fn get_selected_entry_text(&self) -> String {
// Find the entry line corresponding to the selected frame
let entry_lines: Vec<&String> = self
.log_view_content
.iter()
.filter(|l| self.is_entry_line(l))
.collect();
if self.log_view_selected < entry_lines.len() {
entry_lines[self.log_view_selected].to_string()
} else {
String::new()
}
}
fn get_selected_project_text(&self) -> String {
// Find which project group the selected entry belongs to
let mut current_project_lines = Vec::new();
let mut frame_count = 0;
let mut found = false;
for line in &self.log_view_content {
if line.starts_with(" ") && !line.starts_with("\t") {
// This is a project header
if found {
break; // We've collected the target project
}
current_project_lines.clear();
current_project_lines.push(line.clone());
} else if self.is_entry_line(line) {
// Entry within a project
current_project_lines.push(line.clone());
if frame_count == self.log_view_selected {
found = true;
}
frame_count += 1;
}
}
current_project_lines.join("\n")
}
fn get_selected_day_text(&self) -> String {
// Find which day the selected entry belongs to
let mut current_day_lines = Vec::new();
let mut frame_count = 0;
let mut found = false;
for line in &self.log_view_content {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
// This is a date header
if found {
break; // We've collected the target day
}
current_day_lines.clear();
current_day_lines.push(line.clone());
} else if line.starts_with('\t') || line.starts_with(" ") {
// Add all lines within the day (project headers and entries)
current_day_lines.push(line.clone());
// Count only actual entries
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
found = true;
}
frame_count += 1;
}
}
}
current_day_lines.join("\n")
}
fn jump_to_next_project(&mut self) {
// Find the current project, then jump to first entry of next project
let mut current_date = String::new();
let mut current_project = String::new();
let mut selected_project = String::new();
let mut selected_date = String::new();
let mut frame_count = 0;
let mut found_current = false;
// Find which project we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with("\t") {
current_project = line.clone();
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
selected_project = current_project.clone();
selected_date = current_date.clone();
found_current = true;
break;
}
frame_count += 1;
}
}
if !found_current {
return;
}
// Now find the next project within the same day first, then next day
current_project = String::new();
current_date = String::new();
let mut past_selected = false;
frame_count = 0;
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with("\t") {
current_project = line.clone();
if past_selected && current_project != selected_project {
// This is the next project, select its first entry
self.log_view_selected = frame_count;
return;
}
}
if self.is_entry_line(line) {
if current_project == selected_project && current_date == selected_date {
past_selected = true;
}
frame_count += 1;
}
}
}
fn jump_to_previous_project(&mut self) {
// Find the previous project and jump to its first entry
let mut current_date = String::new();
let mut current_project = String::new();
let mut selected_project = String::new();
let mut selected_date = String::new();
let mut frame_count = 0;
let mut previous_project_first_entry = None;
let mut last_different_project_entry = None;
// Find which project we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
} else if line.starts_with(" ") && !line.starts_with("\t") {
if !current_project.is_empty() && current_project != selected_project {
last_different_project_entry = previous_project_first_entry;
}
current_project = line.clone();
previous_project_first_entry = Some(frame_count);
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
selected_project = current_project.clone();
selected_date = current_date.clone();
// Jump to the last different project we saw
if let Some(entry) = last_different_project_entry {
self.log_view_selected = entry;
}
return;
}
frame_count += 1;
}
}
}
fn jump_to_next_day(&mut self) {
// Find the current day, then jump to first entry of next day
let mut current_date = String::new();
let mut selected_date = String::new();
let mut frame_count = 0;
let mut found_current = false;
// Find which day we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
current_date = line.clone();
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
selected_date = current_date.clone();
found_current = true;
break;
}
frame_count += 1;
}
}
if !found_current {
return;
}
// Now find the next day and jump to its first entry
current_date = String::new();
let mut past_selected = false;
frame_count = 0;
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
if past_selected && line != &selected_date {
// This is the next day, continue to find its first entry
current_date = line.clone();
} else if line == &selected_date {
past_selected = true;
}
}
if self.is_entry_line(line) {
if past_selected && !current_date.is_empty() && current_date != selected_date {
// First entry of next day
self.log_view_selected = frame_count;
return;
}
frame_count += 1;
}
}
}
fn jump_to_previous_day(&mut self) {
// Find the previous day and jump to its first entry
let mut current_date = String::new();
let mut selected_date = String::new();
let mut frame_count = 0;
let mut last_different_day_entry = None;
let mut previous_day_first_entry = None;
// Find which day we're currently in
for line in &self.log_view_content.clone() {
if !line.starts_with('\t') && !line.is_empty() && !line.starts_with(" ") {
if !current_date.is_empty() && current_date != selected_date {
last_different_day_entry = previous_day_first_entry;
}
current_date = line.clone();
previous_day_first_entry = Some(frame_count);
}
if self.is_entry_line(line) {
if frame_count == self.log_view_selected {
selected_date = current_date.clone();
// Jump to the last different day we saw
if let Some(entry) = last_different_day_entry {
self.log_view_selected = entry;
}
return;
}
frame_count += 1;
}
}
}
fn move_selection(&mut self, delta: i32) {
let items_len = match self.state.current_pane {
0 => self.state.permanent_items.len(),