diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-22 13:51:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-22 13:51:57 +0300 |
| commit | c3f3c356beda685e0327fd7346b43310fba990ab (patch) | |
| tree | 49c3a6b172ffa7215839ecd778358be2567552c0 /internal/ui | |
| parent | ea9fe5e52b3a95651792b5781327f6100afd8fcb (diff) | |
Fix lq0 task mutation cancellation
Diffstat (limited to 'internal/ui')
| -rw-r--r-- | internal/ui/editor_handlers.go | 4 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 33 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 49 | ||||
| -rw-r--r-- | internal/ui/table.go | 18 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 41 |
5 files changed, 111 insertions, 34 deletions
diff --git a/internal/ui/editor_handlers.go b/internal/ui/editor_handlers.go index 8f81420..2db20d0 100644 --- a/internal/ui/editor_handlers.go +++ b/internal/ui/editor_handlers.go @@ -56,7 +56,9 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { // Update the description newDesc := strings.TrimSpace(string(content)) if m.currentTaskDetail != nil { - err = task.SetDescription(m.currentTaskDetail.ID, newDesc) + ctx, cancel := m.taskOperationContext() + err = task.SetDescriptionContext(ctx, m.currentTaskDetail.ID, newDesc) + cancel() if err != nil { m.statusMsg = fmt.Sprintf("Error updating description: %v", err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 45c0b10..05309b2 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -47,14 +47,14 @@ func (m *Model) handleAnnotationMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } if m.replaceAnnotations { - ctx, cancel := m.taskExportContext() + ctx, cancel := m.taskOperationContext() defer cancel() if err := task.ReplaceAnnotations(ctx, m.annotateID, value); err != nil { return err } m.replaceAnnotations = false } else { - ctx, cancel := m.taskExportContext() + ctx, cancel := m.taskOperationContext() defer cancel() if err := task.AnnotateContext(ctx, m.annotateID, value); err != nil { return err @@ -85,7 +85,9 @@ func (m *Model) handleDescriptionMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) if err := validateDescription(value); err != nil { return err } - if err := task.SetDescription(m.descID, value); err != nil { + ctx, cancel := m.taskOperationContext() + defer cancel() + if err := task.SetDescriptionContext(ctx, m.descID, value); err != nil { return err } if err := m.reload(); err != nil { @@ -130,7 +132,7 @@ func (m *Model) handleTagsMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } } if len(adds) > 0 || len(removes) > 0 { - ctx, cancel := m.taskExportContext() + ctx, cancel := m.taskOperationContext() defer cancel() if len(adds) > 0 { if err := task.AddTagsContext(ctx, m.tagsID, adds); err != nil { @@ -168,7 +170,10 @@ func (m *Model) handleTagsMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m *Model) handleDueEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": - if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.SetDueDateContext(ctx, m.dueID, m.dueDate.Format("2006-01-02")) + cancel() + if err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { return struct{ clearStatus bool }{true} @@ -213,7 +218,9 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if err := validateRecurrence(value); err != nil { return err } - if err := task.SetRecurrence(m.recurID, value); err != nil { + ctx, cancel := m.taskOperationContext() + defer cancel() + if err := task.SetRecurrenceContext(ctx, m.recurID, value); err != nil { return err } if err := m.reload(); err != nil { @@ -244,7 +251,9 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // handleProjectMode handles project editing func (m *Model) handleProjectMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { - return task.SetProject(m.projID, value) + ctx, cancel := m.taskOperationContext() + defer cancel() + return task.SetProjectContext(ctx, m.projID, value) } onExit := func() { @@ -275,7 +284,10 @@ func (m *Model) handlePriorityMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { }) return m, cmd } - if err := task.SetPriority(m.priorityID, priority); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.SetPriorityContext(ctx, m.priorityID, priority) + cancel() + if err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { return struct{ clearStatus bool }{true} @@ -351,7 +363,10 @@ func (m *Model) handleAddTaskMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { oldIDs[tsk.ID] = struct{}{} } - if err := task.AddLine(m.addInput.Value()); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.AddLineContext(ctx, m.addInput.Value()) + cancel() + if err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { return struct{ clearStatus bool }{true} diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index c84c172..4d8c682 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "math/rand" "os/exec" @@ -38,12 +39,18 @@ func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { } if started { - if err := task.Stop(id); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.StopContext(ctx, id) + cancel() + if err != nil { m.showError(err) return m, nil } } else { - if err := task.Start(id); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.StartContext(ctx, id) + cancel() + if err != nil { m.showError(err) return m, nil } @@ -119,12 +126,15 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { } action := m.undoStack[len(m.undoStack)-1] + ctx, cancel := m.taskOperationContext() for _, restore := range action.restores { - if err := task.SetStatusUUID(restore.uuid, restore.status); err != nil { + if err := task.SetStatusUUIDContext(ctx, restore.uuid, restore.status); err != nil { + cancel() m.showError(err) return m, nil } } + cancel() m.undoStack = m.undoStack[:len(m.undoStack)-1] // Reload the task list to get the updated task with its new ID @@ -159,7 +169,7 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { } filters = append(filters, "status:"+restore.status) - ctx, cancel := m.taskExportContext() + ctx, cancel := m.taskOperationContext() tasks, err := task.Export(ctx, filters...) cancel() if err == nil && len(tasks) > 0 { @@ -199,10 +209,10 @@ func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) { recurring := isRecurringTask(tsk) tasks := []task.Task{tsk} + ctx, cancel := m.taskOperationContext() + defer cancel() if recurring { - ctx, cancel := m.taskExportContext() series, err := task.RecurringSeries(ctx, recurringRootUUID(tsk)) - cancel() if err != nil { return 0, true, fmt.Errorf("loading recurring series: %w", err) } @@ -223,8 +233,8 @@ func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) { completed := make([]undoRestore, 0, len(restores)) for _, restore := range restores { - if err := task.SetStatusUUID(restore.uuid, "deleted"); err != nil { - rollbackUndoRestores(completed) + if err := task.SetStatusUUIDContext(ctx, restore.uuid, "deleted"); err != nil { + rollbackUndoRestores(ctx, completed) return 0, recurring, fmt.Errorf("deleting task %s: %w", restore.uuid, err) } completed = append(completed, restore) @@ -294,9 +304,9 @@ func undoStatusForTask(tsk task.Task) string { return tsk.Status } -func rollbackUndoRestores(restores []undoRestore) { +func rollbackUndoRestores(ctx context.Context, restores []undoRestore) { for i := len(restores) - 1; i >= 0; i-- { - _ = task.SetStatusUUID(restores[i].uuid, restores[i].status) + _ = task.SetStatusUUIDContext(ctx, restores[i].uuid, restores[i].status) } } @@ -328,7 +338,10 @@ func (m *Model) handleRemoveDueDate() (tea.Model, tea.Cmd) { } // In Taskwarrior, passing an empty value to due: removes the due date - if err := task.SetDueDate(id, ""); err != nil { + ctx, cancel := m.taskOperationContext() + err = task.SetDueDateContext(ctx, id, "") + cancel() + if err != nil { m.showError(err) return m, nil } @@ -348,7 +361,10 @@ func (m *Model) handleRandomDueDate() (tea.Model, tea.Cmd) { days := rand.Intn(31) + 7 due := time.Now().AddDate(0, 0, days).Format("2006-01-02") - if err := task.SetDueDate(id, due); err != nil { + ctx, cancel := m.taskOperationContext() + err = task.SetDueDateContext(ctx, id, due) + cancel() + if err != nil { m.showError(err) return m, nil } @@ -489,18 +505,21 @@ func (m *Model) handleTagToProject() (tea.Model, tea.Cmd) { firstTag := currentTask.Tags[0] // Set the tag as project - if err := task.SetProject(id, firstTag); err != nil { + ctx, cancel := m.taskOperationContext() + err = task.SetProjectContext(ctx, id, firstTag) + if err != nil { + cancel() m.showError(err) return m, nil } // Remove the tag from the task - ctx, cancel := m.taskExportContext() - defer cancel() if err := task.RemoveTagsContext(ctx, id, []string{firstTag}); err != nil { + cancel() m.showError(err) return m, nil } + cancel() if !m.reloadAndReport() { return m, nil diff --git a/internal/ui/table.go b/internal/ui/table.go index c948d89..f37d2ab 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -25,7 +25,7 @@ import ( var priorityOptions = []string{"H", "M", "L", ""} -const taskExportTimeout = 30 * time.Second +const taskOperationTimeout = 30 * time.Second var ( urlRegex = regexp.MustCompile(`https?://\S+`) @@ -278,9 +278,9 @@ func (m *Model) cancelTaskOperations() { } } -func (m *Model) taskExportContext() (context.Context, context.CancelFunc) { +func (m *Model) taskOperationContext() (context.Context, context.CancelFunc) { m.initTaskContext() - return context.WithTimeout(m.taskContext, taskExportTimeout) + return context.WithTimeout(m.taskContext, taskOperationTimeout) } // blinkInterval controls how quickly the row flashes when a task changes. @@ -397,7 +397,10 @@ func (m *Model) startBlink(id int, markDone bool) tea.Cmd { break } } - if err := task.Done(id); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.DoneContext(ctx, id) + cancel() + if err != nil { m.showError(err) } } @@ -510,7 +513,7 @@ func (m *Model) fetchTasks() (reloadData, error) { // Always show only pending tasks by default. filters := append([]string(nil), m.filters...) filters = append(filters, "status:pending") - ctx, cancel := m.taskExportContext() + ctx, cancel := m.taskOperationContext() defer cancel() tasks, err := task.Export(ctx, filters...) @@ -752,7 +755,10 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { break } } - if err := task.Done(id); err != nil { + ctx, cancel := m.taskOperationContext() + err := task.DoneContext(ctx, id) + cancel() + if err != nil { m.showError(err) } } diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 5549e97..8880a03 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -1503,9 +1503,9 @@ func TestEscDoesNotQuitFromTable(t *testing.T) { } } -func TestQuitCancelsTaskExportContext(t *testing.T) { +func TestQuitCancelsTaskOperationContext(t *testing.T) { m := Model{} - ctx, cancel := m.taskExportContext() + ctx, cancel := m.taskOperationContext() defer cancel() _, cmd := m.handleQuitKey() @@ -1513,7 +1513,42 @@ func TestQuitCancelsTaskExportContext(t *testing.T) { t.Fatal("quit returned nil command; want tea.Quit") } if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("task export context error = %v, want context canceled", ctx.Err()) + t.Fatalf("task operation context error = %v, want context canceled", ctx.Err()) + } +} + +func TestCanceledTaskOperationContextReachesToggleStart(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + startedFile := filepath.Join(tmp, "started") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"alpha\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+ + " exit 0\n"+ + "fi\n"+ + "if [ \"$1\" = \"1\" ] && [ \"$2\" = \"start\" ]; then\n"+ + " printf started > %q\n"+ + " exit 0\n"+ + "fi\n", startedFile) + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + m.cancelTaskOperations() + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + m = *mv.(*Model) + if !strings.Contains(m.statusMsg, context.Canceled.Error()) { + t.Fatalf("status = %q, want context canceled error", m.statusMsg) + } + if _, err := os.Stat(startedFile); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("start command ran despite canceled model context; stat error = %v", err) } } |
