diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-27 00:10:51 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-27 00:10:51 +0300 |
| commit | 25c1fe6439892e9b5ba5bf36c195cfe538490352 (patch) | |
| tree | 875eafb1633c2c503590c9e1c74c51fae92b1535 | |
| parent | bca7d87f11a3ee88c75dc1ed706009d63022ef98 (diff) | |
fr0 review fixes: preserve recurrence error cmd, normalize agent hotkey ctrl+ case, detail-view ctrl+r, esc-reset test
| -rw-r--r-- | internal/ui/detail_handlers.go | 10 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 6 | ||||
| -rw-r--r-- | internal/ui/keyhandlers_test.go | 17 | ||||
| -rw-r--r-- | internal/ui/table.go | 8 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 126 |
5 files changed, 165 insertions, 2 deletions
diff --git a/internal/ui/detail_handlers.go b/internal/ui/detail_handlers.go index 783af45..c0248f2 100644 --- a/internal/ui/detail_handlers.go +++ b/internal/ui/detail_handlers.go @@ -83,6 +83,8 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleDetailDeleteTask() case "U": return m.handleDetailUndo() + case "ctrl+r": + return m.handleDetailSetRecurringSeriesRecurrence() case "i", "enter": // Check if current field is editable return m.handleDetailFieldEdit() @@ -140,6 +142,14 @@ func (m *Model) handleDetailUndo() (tea.Model, tea.Cmd) { return m.handleUndo() } +func (m *Model) handleDetailSetRecurringSeriesRecurrence() (tea.Model, tea.Cmd) { + t := m.currentDetailTask() + if t == nil { + return m, nil + } + return m.activateRecurringSeriesRecurrenceEdit(t.ID, *t) +} + // closeDetailView resets the detail-view state so the table view is shown // again. Used by detail-view actions that intentionally exit the view (mark // done, undo). diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 9e5fcec..1b9693c 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -232,6 +232,12 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit) if msg.String() == "enter" { + // On failure, handleTextInput returns without calling onExit, so + // recurEditing stays true: skip the success blink and keep the + // timed error command returned above. + if m.recurEditing { + return model, cmd + } if m.showTaskDetail { if t := m.currentDetailTask(); t != nil && t.Recur != "" { return model, m.startDetailBlink(fieldRecur) diff --git a/internal/ui/keyhandlers_test.go b/internal/ui/keyhandlers_test.go index d8a831c..d70c51f 100644 --- a/internal/ui/keyhandlers_test.go +++ b/internal/ui/keyhandlers_test.go @@ -45,3 +45,20 @@ func TestAgentFilterHotkeyValidationRejectsSharedKeyBindings(t *testing.T) { } } } + +func TestAgentFilterHotkeyRejectsCtrlRCaseVariants(t *testing.T) { + for _, key := range []string{"ctrl+r", "Ctrl+R", "CTRL+R"} { + t.Run(key, func(t *testing.T) { + var m Model + if err := m.SetAgentFilterHotkey(key); err == nil { + t.Fatalf("expected collision for agent hotkey %q", key) + } + if err := validateAgentFilterHotkey(key); err == nil { + t.Fatalf("expected direct validation collision for agent hotkey %q", key) + } + if got := m.agentFilterHotkeyLabel(); got != "3" { + t.Fatalf("colliding hotkey changed label: got %q want %q", got, "3") + } + }) + } +} diff --git a/internal/ui/table.go b/internal/ui/table.go index ea24051..b1e8914 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1431,7 +1431,7 @@ func (m *Model) agentFilterHotkeyLabel() string { } func validateAgentFilterHotkey(key string) error { - key = strings.TrimSpace(key) + key = normalizeAgentFilterHotkey(key) if key == "" { return nil } @@ -1462,7 +1462,8 @@ func normalizeAgentFilterHotkey(key string) string { if key == "" || len(key) == 1 { return key } - switch strings.ToLower(key) { + lowerKey := strings.ToLower(key) + switch lowerKey { case "down": return "down" case "end": @@ -1488,6 +1489,9 @@ func normalizeAgentFilterHotkey(key string) string { case "up": return "up" } + if strings.HasPrefix(lowerKey, "ctrl+") { + return "ctrl+" + strings.ToLower(key[5:]) + } return key } diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 30716f1..505f937 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -1545,6 +1545,132 @@ func TestRecurringSeriesRecurrenceHotkey(t *testing.T) { } } +func TestRecurringSeriesRecurrenceEscExitsAndResetsSeriesState(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 7, UUID: "child", Parent: "root", Description: "child", Status: "pending", Recur: "daily", RType: "periodic"}, + }, + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).Update(ctrlRKey()) + m = *mv.(*Model) + if !m.recurEditing || !m.recurSeries || m.recurRoot != "root" { + t.Fatalf("series edit not active: editing=%v series=%v root=%q", m.recurEditing, m.recurSeries, m.recurRoot) + } + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = *mv.(*Model) + + if m.recurEditing { + t.Fatalf("esc did not exit recurrence editing") + } + if m.recurSeries || m.recurRoot != "" { + t.Fatalf("series state not reset on esc: series=%v root=%q", m.recurSeries, m.recurRoot) + } + if len(fake.seriesRecurrences) != 0 { + t.Fatalf("esc committed a series update: %#v", fake.seriesRecurrences) + } +} + +func TestRecurringSeriesRecurrenceFailurePreservesErrorCommand(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 7, UUID: "child", Parent: "root", Description: "child", Status: "pending", Recur: "daily", RType: "periodic"}, + }, + setSeriesRecurrenceErr: errors.New("series failed"), + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).Update(ctrlRKey()) + m = *mv.(*Model) + m.recurInput.SetValue("weekly") + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = *mv.(*Model) + + if cmd == nil { + t.Fatalf("expected timed error command") + } + if !m.recurEditing { + t.Fatalf("recurrence input should stay active after failure") + } + if m.blinkID != 0 { + t.Fatalf("success blink started for failed recurrence edit: blinkID=%d", m.blinkID) + } + if !strings.Contains(m.statusMsg, "series failed") { + t.Fatalf("statusMsg = %q, want recurrence error", m.statusMsg) + } +} + +func TestRecurringSeriesRecurrenceFailureKeepsTimedErrorWhenBlinkDisabled(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 7, UUID: "child", Parent: "root", Description: "child", Status: "pending", Recur: "daily", RType: "periodic"}, + }, + setSeriesRecurrenceErr: errors.New("series failed"), + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + m.blinkEnabled = false + + mv, _ := (&m).Update(ctrlRKey()) + m = *mv.(*Model) + m.recurInput.SetValue("weekly") + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = *mv.(*Model) + + if cmd == nil { + t.Fatalf("expected timed error command when blink is disabled") + } + if !strings.Contains(m.statusMsg, "series failed") { + t.Fatalf("statusMsg = %q, want recurrence error", m.statusMsg) + } +} + +func TestRecurringSeriesRecurrenceHotkeyInDetailMode(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 7, UUID: "child", Parent: "root", Description: "child", Status: "pending", Recur: "daily", RType: "periodic"}, + }, + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = *mv.(*Model) + if !m.showTaskDetail { + t.Fatalf("enter did not open detail mode") + } + + mv, _ = (&m).Update(ctrlRKey()) + m = *mv.(*Model) + + if !m.recurEditing { + t.Fatalf("detail ctrl+r did not activate recurrence editing") + } + if !m.recurSeries { + t.Fatalf("detail ctrl+r did not mark recurrence edit as series scoped") + } + if m.recurRoot != "root" { + t.Fatalf("recurring root = %q, want root", m.recurRoot) + } + if m.recurInput.Value() != "daily" { + t.Fatalf("recur input = %q, want daily", m.recurInput.Value()) + } +} + func TestRecurringSeriesRecurrenceHotkeyRejectsNonRecurringTask(t *testing.T) { fake := &fakeTaskwarrior{ tasks: []task.Task{ |
