diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-28 00:00:15 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-28 00:00:15 +0300 |
| commit | 0e065b3b0f5e935fc769be2f1e84779fa9897e99 (patch) | |
| tree | e72775ab2fba73100955ac04b2c66e2d567fe7b6 /internal/ui | |
| parent | e527f6084f4a3f592d06c25e34e08cc3769706a8 (diff) | |
fix: resolve test failures and improve code quality
- Fix file handle leak in SetDebugLog by tracking and closing previous files
- Add stderr capture to all taskwarrior commands for better error messages
- Fix timezone issues in date handling tests by normalizing to UTC
- Change Update method to pointer receiver for consistency
- Update all test type assertions to handle pointer receivers correctly
- Remove unused imports and variables
All tests now pass successfully.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/ui')
| -rw-r--r-- | internal/ui/handlers.go | 437 | ||||
| -rw-r--r-- | internal/ui/helpers.go | 191 | ||||
| -rw-r--r-- | internal/ui/helpers_test.go | 363 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 493 | ||||
| -rw-r--r-- | internal/ui/table.go | 723 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 149 | ||||
| -rw-r--r-- | internal/ui/theme.go | 3 |
7 files changed, 1638 insertions, 721 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go new file mode 100644 index 0000000..7fc734b --- /dev/null +++ b/internal/ui/handlers.go @@ -0,0 +1,437 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// handleTextInput provides generic text input handling for all input modes +func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter func(string) error, onExit func()) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + value := input.Value() + if err := onEnter(value); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + input.Blur() + onExit() + m.updateTableHeight() + return m, nil + case tea.KeyEsc: + input.Blur() + onExit() + m.updateTableHeight() + return m, nil + } + var cmd tea.Cmd + *input, cmd = input.Update(msg) + return m, cmd +} + +// handleAnnotationMode handles keyboard input when in annotation mode +func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + // Annotation can be empty when replacing (to remove all) + if !m.replaceAnnotations && strings.TrimSpace(value) == "" { + return fmt.Errorf("annotation cannot be empty") + } + + if m.replaceAnnotations { + if err := task.ReplaceAnnotations(m.annotateID, value); err != nil { + return err + } + m.replaceAnnotations = false + } else { + if err := task.Annotate(m.annotateID, value); err != nil { + return err + } + } + m.reload() + return nil + } + + onExit := func() { + m.annotating = false + m.replaceAnnotations = false + } + + model, cmd := m.handleTextInput(msg, &m.annotateInput, onEnter, onExit) + if msg.Type == tea.KeyEnter && m.annotateInput.Value() != "" { + // Start blink after successful annotation + return model, m.startBlink(m.annotateID, false) + } + return model, cmd +} + +// handleDescriptionMode handles keyboard input when editing description +func (m *Model) handleDescriptionMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + if err := validateDescription(value); err != nil { + return err + } + if err := task.SetDescription(m.descID, value); err != nil { + return err + } + m.reload() + return nil + } + + onExit := func() { + m.descEditing = false + } + + model, cmd := m.handleTextInput(msg, &m.descInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + return model, m.startBlink(m.descID, false) + } + return model, cmd +} + +// handleTagsMode handles keyboard input when editing tags +func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + words := strings.Fields(value) + var adds, removes []string + for _, w := range words { + if strings.HasPrefix(w, "-") { + if len(w) > 1 { + tagName := w[1:] + if err := validateTagName(tagName); err != nil { + return fmt.Errorf("remove tag '%s': %w", tagName, err) + } + removes = append(removes, tagName) + } + } else { + if strings.HasPrefix(w, "+") { + w = w[1:] + } + if w != "" { + if err := validateTagName(w); err != nil { + return fmt.Errorf("add tag '%s': %w", w, err) + } + adds = append(adds, w) + } + } + } + if len(adds) > 0 { + if err := task.AddTags(m.tagsID, adds); err != nil { + return err + } + } + if len(removes) > 0 { + if err := task.RemoveTags(m.tagsID, removes); err != nil { + return err + } + } + m.reload() + return nil + } + + onExit := func() { + m.tagsEditing = false + } + + model, cmd := m.handleTextInput(msg, &m.tagsInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + return model, m.startBlink(m.tagsID, false) + } + return model, cmd +} + +// handleDueEditMode handles due date editing +func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + m.dueEditing = false + m.reload() + cmd := m.startBlink(m.dueID, false) + m.updateTableHeight() + return m, cmd + case tea.KeyEsc: + m.dueEditing = false + m.updateTableHeight() + return m, nil + } + + switch msg.String() { + case "h", "left": + m.dueDate = m.dueDate.AddDate(0, 0, -1) + case "l", "right": + m.dueDate = m.dueDate.AddDate(0, 0, 1) + case "k", "up": + m.dueDate = m.dueDate.AddDate(0, 0, -7) + case "j", "down": + m.dueDate = m.dueDate.AddDate(0, 0, 7) + } + return m, nil +} + +// handleRecurrenceMode handles recurrence editing +func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + if err := validateRecurrence(value); err != nil { + return err + } + if err := task.SetRecurrence(m.recurID, value); err != nil { + return err + } + m.reload() + return nil + } + + onExit := func() { + m.recurEditing = false + } + + model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + return model, m.startBlink(m.recurID, false) + } + return model, cmd +} + +// handlePriorityMode handles priority selection +func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + priority := priorityOptions[m.priorityIndex] + if err := validatePriority(priority); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + if err := task.SetPriority(m.priorityID, priority); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + m.prioritySelecting = false + m.reload() + cmd := m.startBlink(m.priorityID, false) + m.updateTableHeight() + return m, cmd + case tea.KeyEsc: + m.prioritySelecting = false + m.updateTableHeight() + return m, nil + } + + switch msg.String() { + case "h", "left": + m.priorityIndex = (m.priorityIndex + len(priorityOptions) - 1) % len(priorityOptions) + case "l", "right": + m.priorityIndex = (m.priorityIndex + 1) % len(priorityOptions) + } + return m, nil +} + +// handleFilterMode handles filter editing +func (m *Model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + m.filters = strings.Fields(value) + m.reload() + return nil + } + + onExit := func() { + m.filterEditing = false + } + + return m.handleTextInput(msg, &m.filterInput, onEnter, onExit) +} + +// handleAddTaskMode handles adding a new task +func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + oldIDs := make(map[int]struct{}) + for _, tsk := range m.tasks { + oldIDs[tsk.ID] = struct{}{} + } + + if err := task.AddLine(m.addInput.Value()); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + + m.addingTask = false + m.addInput.Blur() + m.reload() + + // Find the newly added task + var newID int + row := -1 + for i, tsk := range m.tasks { + if _, ok := oldIDs[tsk.ID]; !ok { + newID = tsk.ID + row = i + break + } + } + + m.updateTableHeight() + if row >= 0 { + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(row) + m.tbl.SetColumnCursor(7) // Description column + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + return m, m.startBlink(newID, false) + } + return m, nil + + case tea.KeyEsc: + m.addingTask = false + m.addInput.Blur() + m.updateTableHeight() + return m, nil + } + + var cmd tea.Cmd + m.addInput, cmd = m.addInput.Update(msg) + return m, cmd +} + +// handleSearchMode handles search input +func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + pattern := m.searchInput.Value() + if pattern != "" { + // Check cache first + if cached, ok := searchRegexCache[pattern]; ok { + m.searchRegex = cached + } else { + // Compile and cache if not found + re, err := compileAndCacheRegex(pattern) + if err == nil { + m.searchRegex = re + } else { + m.searchRegex = nil + m.statusMsg = fmt.Sprintf("Invalid regex: %v", err) + } + } + } else { + m.searchRegex = nil + } + m.searching = false + m.searchInput.Blur() + m.reload() + m.updateTableHeight() + + if len(m.searchMatches) > 0 { + match := m.searchMatches[m.searchIndex] + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(match.row) + m.tbl.SetColumnCursor(match.col) + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return m, nil + + case tea.KeyEsc: + m.searching = false + m.searchInput.Blur() + m.updateTableHeight() + return m, nil + } + + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd +} + +// handleBlinkingState handles input when a task is blinking +func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.KeyMsg); ok { + // Only allow navigation while blinking + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + var cmd tea.Cmd + m.tbl, cmd = m.tbl.Update(msg) + if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return m, cmd + } + return m, nil +} + +// handleEditingModes checks if we're in any editing mode and handles it +func (m *Model) handleEditingModes(msg tea.KeyMsg) (handled bool, model tea.Model, cmd tea.Cmd) { + switch { + case m.annotating: + model, cmd = m.handleAnnotationMode(msg) + return true, model, cmd + case m.descEditing: + model, cmd = m.handleDescriptionMode(msg) + return true, model, cmd + case m.tagsEditing: + model, cmd = m.handleTagsMode(msg) + return true, model, cmd + case m.dueEditing: + model, cmd = m.handleDueEditMode(msg) + return true, model, cmd + case m.recurEditing: + model, cmd = m.handleRecurrenceMode(msg) + return true, model, cmd + case m.prioritySelecting: + model, cmd = m.handlePriorityMode(msg) + return true, model, cmd + case m.filterEditing: + model, cmd = m.handleFilterMode(msg) + return true, model, cmd + case m.addingTask: + model, cmd = m.handleAddTaskMode(msg) + return true, model, cmd + case m.searching: + model, cmd = m.handleSearchMode(msg) + return true, model, cmd + } + return false, m, nil +} + +// getSelectedTaskID extracts the task ID from the selected row +func (m *Model) getSelectedTaskID() (int, error) { + row := m.tbl.SelectedRow() + if row == nil { + return 0, fmt.Errorf("no row selected") + } + idStr := ansi.Strip(row[1]) + return strconv.Atoi(idStr) +} + +// getTaskAtCursor returns the task at the current cursor position +func (m *Model) getTaskAtCursor() *task.Task { + cursor := m.tbl.Cursor() + if cursor < 0 || cursor >= len(m.tasks) { + return nil + } + return &m.tasks[cursor] +}
\ No newline at end of file diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go new file mode 100644 index 0000000..71c21db --- /dev/null +++ b/internal/ui/helpers.go @@ -0,0 +1,191 @@ +package ui + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// Date format used by Taskwarrior +const taskDateFormat = "20060102T150405Z" + +// parseTaskDate parses a date string in Taskwarrior format +func parseTaskDate(dateStr string) (time.Time, error) { + if dateStr == "" { + return time.Time{}, fmt.Errorf("empty date string") + } + return time.Parse(taskDateFormat, dateStr) +} + +// formatTaskDate formats a time as a Taskwarrior date string +func formatTaskDate(t time.Time) string { + return t.UTC().Format(taskDateFormat) +} + +// daysSince returns the number of days since the given time +func daysSince(t time.Time) int { + return int(time.Since(t).Hours() / 24) +} + +// daysUntil returns the number of days until the given time +func daysUntil(t time.Time) int { + now := time.Now() + // Normalize both times to midnight UTC to avoid timezone and fractional day issues + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + return int(target.Sub(today).Hours() / 24) +} + +// formatDueText returns a human-readable due date string +func formatDueText(dueStr string) string { + if dueStr == "" { + return "" + } + + ts, err := parseTaskDate(dueStr) + if err != nil { + return dueStr + } + + days := daysUntil(ts) + switch days { + case 0: + return "today" + case 1: + return "tomorrow" + case -1: + return "yesterday" + default: + return fmt.Sprintf("%dd", days) + } +} + +// compileAndCacheRegex compiles a regex and adds it to the cache +func compileAndCacheRegex(pattern string) (*regexp.Regexp, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + // Limit cache size to prevent memory leak + if len(searchRegexCache) > 100 { + // Clear cache when it gets too large + searchRegexCache = make(map[string]*regexp.Regexp) + } + searchRegexCache[pattern] = re + + return re, nil +} + +// Validation functions + +// validateTagName validates a tag name +func validateTagName(tag string) error { + if tag == "" { + return fmt.Errorf("tag cannot be empty") + } + + // Remove leading + or - for validation + tag = strings.TrimPrefix(strings.TrimPrefix(tag, "+"), "-") + + // Check for invalid characters + if strings.ContainsAny(tag, " \t\n\r") { + return fmt.Errorf("tag cannot contain whitespace") + } + + return nil +} + +// validateTags validates a list of tags +func validateTags(tags []string) error { + for _, tag := range tags { + if err := validateTagName(tag); err != nil { + return fmt.Errorf("invalid tag '%s': %w", tag, err) + } + } + return nil +} + +// validateDueDate validates a due date string +func validateDueDate(due string) error { + if due == "" { + return nil // Empty due date is valid + } + + // Try common formats + formats := []string{ + "2006-01-02", + "2006-01-02T15:04:05", + "2006-01-02T15:04:05Z", + taskDateFormat, + } + + for _, format := range formats { + if _, err := time.Parse(format, due); err == nil { + return nil + } + } + + // Check for relative dates that taskwarrior understands + relatives := []string{"now", "today", "tomorrow", "yesterday", "monday", "tuesday", + "wednesday", "thursday", "friday", "saturday", "sunday", "eod", "eow", "eom", "eoy"} + + due = strings.ToLower(due) + for _, rel := range relatives { + if due == rel || strings.HasPrefix(due, rel+"+") || strings.HasPrefix(due, rel+"-") { + return nil + } + } + + return fmt.Errorf("invalid due date format: %s", due) +} + +// validatePriority validates a priority value +func validatePriority(priority string) error { + switch priority { + case "", "H", "M", "L": + return nil + default: + return fmt.Errorf("invalid priority: %s (must be H, M, L, or empty)", priority) + } +} + +// validateRecurrence validates a recurrence string +func validateRecurrence(recur string) error { + if recur == "" { + return nil // Empty recurrence is valid + } + + // Basic validation - taskwarrior will do the full validation + if len(recur) < 2 { + return fmt.Errorf("recurrence too short") + } + + // Check for common patterns + validPrefixes := []string{"daily", "weekly", "monthly", "yearly", "biweekly", "bimonthly"} + for _, prefix := range validPrefixes { + if strings.HasPrefix(strings.ToLower(recur), prefix) { + return nil + } + } + + // Check for duration format (e.g., "3d", "2w", "1m") + if len(recur) >= 2 { + last := recur[len(recur)-1] + if (last == 'd' || last == 'w' || last == 'm' || last == 'y') && + recur[:len(recur)-1] != "" { + return nil + } + } + + return nil // Let taskwarrior handle complex validation +} + +// validateDescription validates a task description +func validateDescription(desc string) error { + if strings.TrimSpace(desc) == "" { + return fmt.Errorf("description cannot be empty") + } + return nil +}
\ No newline at end of file diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go new file mode 100644 index 0000000..9ba620c --- /dev/null +++ b/internal/ui/helpers_test.go @@ -0,0 +1,363 @@ +package ui + +import ( + "testing" + "time" +) + +func TestParseTaskDate(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid date", + input: "20250627T150405Z", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "invalid format", + input: "2025-06-27", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseTaskDate(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseTaskDate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatDueText(t *testing.T) { + now := time.Now() + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty", + input: "", + expected: "", + }, + { + name: "today", + input: now.UTC().Format("20060102T150405Z"), + expected: "today", + }, + { + name: "tomorrow", + input: now.Add(24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "tomorrow", + }, + { + name: "yesterday", + input: now.Add(-24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "yesterday", + }, + { + name: "future", + input: now.Add(5 * 24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "5d", + }, + { + name: "past", + input: now.Add(-3 * 24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "-3d", + }, + { + name: "invalid", + input: "invalid", + expected: "invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatDueText(tt.input) + if got != tt.expected { + t.Errorf("formatDueText() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestValidateTagName(t *testing.T) { + tests := []struct { + name string + tag string + wantErr bool + }{ + { + name: "valid tag", + tag: "work", + wantErr: false, + }, + { + name: "valid with plus", + tag: "+work", + wantErr: false, + }, + { + name: "valid with minus", + tag: "-work", + wantErr: false, + }, + { + name: "empty tag", + tag: "", + wantErr: true, + }, + { + name: "tag with space", + tag: "my tag", + wantErr: true, + }, + { + name: "tag with tab", + tag: "my\ttag", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTagName(tt.tag) + if (err != nil) != tt.wantErr { + t.Errorf("validateTagName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidatePriority(t *testing.T) { + tests := []struct { + name string + priority string + wantErr bool + }{ + { + name: "high", + priority: "H", + wantErr: false, + }, + { + name: "medium", + priority: "M", + wantErr: false, + }, + { + name: "low", + priority: "L", + wantErr: false, + }, + { + name: "empty", + priority: "", + wantErr: false, + }, + { + name: "invalid", + priority: "X", + wantErr: true, + }, + { + name: "lowercase", + priority: "h", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePriority(tt.priority) + if (err != nil) != tt.wantErr { + t.Errorf("validatePriority() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateDescription(t *testing.T) { + tests := []struct { + name string + desc string + wantErr bool + }{ + { + name: "valid description", + desc: "Fix the bug", + wantErr: false, + }, + { + name: "empty description", + desc: "", + wantErr: true, + }, + { + name: "whitespace only", + desc: " ", + wantErr: true, + }, + { + name: "description with whitespace", + desc: " Fix the bug ", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDescription(tt.desc) + if (err != nil) != tt.wantErr { + t.Errorf("validateDescription() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateDueDate(t *testing.T) { + tests := []struct { + name string + due string + wantErr bool + }{ + { + name: "empty", + due: "", + wantErr: false, + }, + { + name: "ISO date", + due: "2025-06-27", + wantErr: false, + }, + { + name: "ISO datetime", + due: "2025-06-27T15:04:05", + wantErr: false, + }, + { + name: "ISO datetime with Z", + due: "2025-06-27T15:04:05Z", + wantErr: false, + }, + { + name: "taskwarrior format", + due: "20250627T150405Z", + wantErr: false, + }, + { + name: "relative - today", + due: "today", + wantErr: false, + }, + { + name: "relative - tomorrow", + due: "tomorrow", + wantErr: false, + }, + { + name: "relative - monday", + due: "monday", + wantErr: false, + }, + { + name: "relative - eod", + due: "eod", + wantErr: false, + }, + { + name: "relative - tomorrow+2d", + due: "tomorrow+2d", + wantErr: false, + }, + { + name: "invalid format", + due: "27/06/2025", + wantErr: true, + }, + { + name: "invalid relative", + due: "someday", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDueDate(tt.due) + if (err != nil) != tt.wantErr { + t.Errorf("validateDueDate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateRecurrence(t *testing.T) { + tests := []struct { + name string + recur string + wantErr bool + }{ + { + name: "empty", + recur: "", + wantErr: false, + }, + { + name: "daily", + recur: "daily", + wantErr: false, + }, + { + name: "weekly", + recur: "weekly", + wantErr: false, + }, + { + name: "3 days", + recur: "3d", + wantErr: false, + }, + { + name: "2 weeks", + recur: "2w", + wantErr: false, + }, + { + name: "1 month", + recur: "1m", + wantErr: false, + }, + { + name: "too short", + recur: "d", + wantErr: true, + }, + { + name: "single char", + recur: "x", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRecurrence(tt.recur) + if (err != nil) != tt.wantErr { + t.Errorf("validateRecurrence() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}
\ No newline at end of file diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go new file mode 100644 index 0000000..780ee8c --- /dev/null +++ b/internal/ui/keyhandlers.go @@ -0,0 +1,493 @@ +package ui + +import ( + "fmt" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// handleNormalMode handles keyboard input in normal mode (not editing) +func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "H": + return m.handleToggleHelp() + case "q", "esc": + return m.handleQuitOrEscape() + case "e", "E": + return m.handleEditTask() + case "s": + return m.handleToggleStart() + case "d": + return m.handleMarkDone() + case "o": + return m.handleOpenURL() + case "U": + return m.handleUndo() + case "D": + return m.handleSetDueDate() + case "r": + return m.handleRandomDueDate() + case "R": + return m.handleSetRecurrence() + case "p": + return m.handleSetPriority() + case "a": + return m.handleAnnotate(false) + case "A": + return m.handleAnnotate(true) + case "f": + return m.handleFilter() + case "+": + return m.handleAddTask() + case "t": + return m.handleEditTags() + case "c": + return m.handleRandomTheme() + case "C": + return m.handleResetTheme() + case "x": + return m.handleToggleDisco() + case " ": + return m.handleRefresh() + case "/", "?": + return m.handleSearch() + case "n": + return m.handleNextSearchMatch() + case "N": + return m.handlePrevSearchMatch() + case "enter", "i": + return m.handleEnterOrEdit() + default: + // Pass through to table for navigation + return m.handleTableNavigation(msg) + } +} + +func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) { + m.showHelp = true + return m, nil +} + +func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) { + if m.cellExpanded { + m.cellExpanded = false + m.updateTableHeight() + return m, nil + } + if m.showHelp { + m.showHelp = false + return m, nil + } + if m.searchRegex != nil { + m.searchRegex = nil + m.searchMatches = nil + m.searchIndex = 0 + m.reload() + return m, nil + } + return m, tea.Quit +} + +func (m *Model) handleEditTask() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + m.editID = id + return m, editCmd(id) +} + +func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + // Check if task is started + started := false + for _, tsk := range m.tasks { + if tsk.ID == id { + started = tsk.Start != "" + break + } + } + + if started { + if err := task.Stop(id); err != nil { + m.showError(err) + return m, nil + } + } else { + if err := task.Start(id); err != nil { + m.showError(err) + return m, nil + } + } + + m.reload() + return m, m.startBlink(id, false) +} + +func (m *Model) handleMarkDone() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + return m, m.startBlink(id, true) +} + +func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { + task := m.getTaskAtCursor() + if task == nil { + return m, nil + } + + url := urlRegex.FindString(task.Description) + if url == "" { + return m, nil + } + + if err := exec.Command(m.browserCmd, url).Run(); err != nil { + m.showError(fmt.Errorf("opening browser: %w", err)) + return m, nil + } + + return m, m.startBlink(task.ID, false) +} + +func (m *Model) handleUndo() (tea.Model, tea.Cmd) { + if len(m.undoStack) == 0 { + return m, nil + } + + uuid := m.undoStack[len(m.undoStack)-1] + m.undoStack = m.undoStack[:len(m.undoStack)-1] + + if err := task.SetStatusUUID(uuid, "pending"); err != nil { + m.showError(err) + return m, nil + } + + m.reload() + + // Find the task ID for blinking + var id int + for _, tsk := range m.tasks { + if tsk.UUID == uuid { + id = tsk.ID + break + } + } + + return m, m.startBlink(id, false) +} + +func (m *Model) handleSetDueDate() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.dueID = id + m.dueEditing = true + m.dueDate = time.Now() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleRandomDueDate() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + days := rng.Intn(31) + 7 + due := time.Now().AddDate(0, 0, days).Format("2006-01-02") + + if err := task.SetDueDate(id, due); err != nil { + m.showError(err) + return m, nil + } + + m.reload() + return m, m.startBlink(id, false) +} + +func (m *Model) handleSetRecurrence() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + task := m.getTaskAtCursor() + if task == nil { |
