summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-27 00:10:51 +0300
committerPaul Buetow <paul@buetow.org>2026-06-27 00:10:51 +0300
commit25c1fe6439892e9b5ba5bf36c195cfe538490352 (patch)
tree875eafb1633c2c503590c9e1c74c51fae92b1535
parentbca7d87f11a3ee88c75dc1ed706009d63022ef98 (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.go10
-rw-r--r--internal/ui/handlers.go6
-rw-r--r--internal/ui/keyhandlers_test.go17
-rw-r--r--internal/ui/table.go8
-rw-r--r--internal/ui/table_test.go126
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{